beetkeeper-plugin 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ import logging
2
+ from dataclasses import dataclass, field
3
+ from functools import cached_property
4
+ from typing import TYPE_CHECKING, Any, ClassVar, Final
5
+
6
+ from beets.importer import ImportTask # pants: no-infer-dep
7
+ from beets.library import Album, Item # pants: no-infer-dep
8
+ from beets.plugins import BeetsPlugin # pants: no-infer-dep
9
+ from beetsplug._utils.requests import ( # type: ignore[import-untyped]
10
+ RequestHandler, # pants: no-infer-dep
11
+ TimeoutAndRetrySession, # pants: no-infer-dep
12
+ )
13
+ from requests.auth import AuthBase
14
+
15
+ if TYPE_CHECKING:
16
+ from beets.importer import ImportSession # pants: no-infer-dep
17
+ from beets.library import Library # pants: no-infer-dep
18
+ from beets.plugins import EventType # pants: no-infer-dep
19
+ from requests import PreparedRequest, Response
20
+
21
+
22
+ _LOGGER_NAME: Final[str] = __name__
23
+
24
+
25
+ class BeetkeeperPlugin(BeetsPlugin):
26
+ """
27
+ Plugin which registers a number of beets event listeners, all of which submit a POST request to the beetkeeper
28
+ server. This allows the server to collect event data without 'peeking' into the beets database.
29
+ https://beets.readthedocs.io/en/stable/dev/plugins/events.html"""
30
+
31
+ def __init__(self, beetkeeper_server_url: str, raw_api_token: str | None = None):
32
+ """
33
+ Args:
34
+ beetkeeper_server_url: The base url to reach the beetkeeper server when submitting event-based HTTP requests.
35
+ raw_api_token: Optional beetkeeper API authentication token string.
36
+ """
37
+ self._client = _BeetKeeperClient(url=beetkeeper_server_url, api_token=_APIToken(value=raw_api_token or ""))
38
+ super().__init__("beetkeeper_listener")
39
+ self.register_listener("album_imported", self._on_album_import)
40
+ self.register_listener("album_removed", self._on_album_removed)
41
+ self.register_listener("import_task_files", self._on_import_task_files)
42
+ self.register_listener("item_imported", self._on_item_imported)
43
+ self.register_listener("item_removed", self._on_item_removed)
44
+
45
+ @cached_property
46
+ def log(self) -> logging.Logger:
47
+ return logging.getLogger(_LOGGER_NAME)
48
+
49
+ def _on_album_import(self, lib: Library, album: Album) -> None:
50
+ self.log.debug("Run listener for 'album_imported' ...")
51
+ self._client.post(event_type="album_imported", event_element=album)
52
+
53
+ def _on_album_removed(self, lib: Library, album: Album) -> None:
54
+ self.log.debug("Run listener for 'album_removed' ...")
55
+ self._client.post(event_type="album_removed", event_element=album)
56
+
57
+ def _on_import_task_files(self, task: ImportTask, session: ImportSession) -> None:
58
+ self.log.debug("Run listener for 'import_task_files' ...")
59
+ self._client.post(event_type="import_task_files", event_element=task)
60
+
61
+ def _on_item_imported(self, lib: Library, item: Item) -> None:
62
+ self.log.debug("Run listener for 'item_imported' ...")
63
+ self._client.post(event_type="item_imported", event_element=item)
64
+
65
+ def _on_item_removed(self, item: Item) -> None:
66
+ self.log.debug("Run listener for 'item_removed' ...")
67
+ self._client.post(event_type="item_removed", event_element=item)
68
+
69
+
70
+ class _BeetKeeperClient(RequestHandler):
71
+ """
72
+ Custom `RequestHandler` for submitting beetkeeper API requests to the `/api/events` endpoint.
73
+ See code in `beets.importer.session.ImportSession` to understand why we can't run a long-lived async client.
74
+ """
75
+
76
+ _base_path: ClassVar[str] = "/api/events"
77
+
78
+ def __init__(self, url: str, api_token: _APIToken):
79
+ self._url = url
80
+ self._api_token = api_token
81
+ super().__init__()
82
+
83
+ def create_session(self):
84
+ return TimeoutAndRetrySession(auth=_BkAuth(token=self._api_token), url=self._url)
85
+
86
+ def post(self, event_type: EventType, event_element: Album | Item | ImportTask | ImportSession) -> Response:
87
+ """Submits a POST reques to the Beetkeeper server, with a request body containing relevant beets event info."""
88
+ return self.request(
89
+ method="post",
90
+ url=self._base_path + self._url_subpath(event_element=event_element),
91
+ json=_jsonify(event_type=event_type, event_element=event_element),
92
+ )
93
+
94
+ def _url_subpath(self, event_element: Album | Item | ImportTask | ImportSession) -> str:
95
+ match event_element:
96
+ case Album():
97
+ return "/album"
98
+ case Item():
99
+ return "/track"
100
+ case _:
101
+ return ""
102
+
103
+
104
+ def _jsonify(event_type: EventType, event_element: Album | Item | ImportTask | ImportSession) -> dict[str, Any]:
105
+ """Returns a JSON-serializable dictionary with the beets event details. Result is used as POST request body."""
106
+ if isinstance(event_element, (Album, Item)):
107
+ return dict(event_type=event_type, beets_field={k: v for k, v in event_element.items()})
108
+ elif isinstance(event_type, ImportTask):
109
+ jsonified_imported_items = []
110
+ if imported_items := event_element.imported_items():
111
+ jsonified_imported_items = [_jsonify(event_type=event_type, event_element=item) for item in imported_items]
112
+ return dict(
113
+ event_type=event_type, choice_flag=event_element.choice_flag, imported_items=jsonified_imported_items
114
+ )
115
+ return dict(event_type=event_type, event_element="unknown")
116
+
117
+
118
+ class _BkAuth(AuthBase):
119
+ """https://requests.readthedocs.io/en/latest/user/authentication/"""
120
+
121
+ _token: _APIToken
122
+
123
+ def __init__(self, token: _APIToken):
124
+ self._token = token
125
+
126
+ def __call__(self, r: PreparedRequest):
127
+ r.headers["Authorization"] = self._token.header_value
128
+ return r
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class _APIToken:
133
+ """Wrapper class for a BeetKeeper API token."""
134
+
135
+ value: str = field(repr=False) # mitigate logging the token accidentally.
136
+
137
+ def get_value(self) -> str:
138
+ return self.value
139
+
140
+ @property
141
+ def header_value(self) -> str:
142
+ return f"Bearer {self.value}"
File without changes
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: beetkeeper-plugin
3
+ Version: 0.0.2
4
+ Summary: Beets plugin client to integrate with the beetkeeper server.
5
+ Author: Zach Gottesman
6
+ Maintainer: zach-overflow
7
+ License-Expression: AGPL-3.0-or-later
8
+ Project-URL: Documentation, https://github.com/zach-overflow/beetkeeper/README.md
9
+ Project-URL: Repository, https://github.com/zach-overflow/beetkeeper
10
+ Project-URL: Bug Tracker, https://github.com/zach-overflow/beetkeeper/issues
11
+ Project-URL: Changelog, https://github.com/zach-overflow/beetkeeper/releases
12
+ Keywords: beets,beets plugin,music tagging,music-library,music tags,self-hosted
13
+ Requires-Python: <3.15,>=3.14
14
+ Requires-Dist: beets>=2.11.0
15
+ Requires-Dist: requests>=2.34
@@ -0,0 +1,6 @@
1
+ beetkeeper_plugin/beetkeeper_plugin.py,sha256=sOzwqM4KWzLmuJQroUh2-d021rVTsDehDEF3AA62hPk,5991
2
+ beetkeeper_plugin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ beetkeeper_plugin-0.0.2.dist-info/METADATA,sha256=tq1vB3s0ZPIyAtmsAmd4OYu_tNf1FjI-HEpFX4GHPX8,692
4
+ beetkeeper_plugin-0.0.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ beetkeeper_plugin-0.0.2.dist-info/top_level.txt,sha256=tojtaChievoLWZADraQU7YBFqZ21vhJ6aSDEKyF89q8,18
6
+ beetkeeper_plugin-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ beetkeeper_plugin