beetkeeper 0.0.1__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.
- beetkeeper/__init__.py +5 -0
- beetkeeper/_version.py +3 -0
- beetkeeper/api/__init__.py +8 -0
- beetkeeper/api/api_models/__init__.py +23 -0
- beetkeeper/api/api_models/events_api_models.py +231 -0
- beetkeeper/api/api_models/import_api_models.py +17 -0
- beetkeeper/api/api_routes/__init__.py +21 -0
- beetkeeper/api/api_routes/events_router.py +80 -0
- beetkeeper/api/api_routes/health_router.py +47 -0
- beetkeeper/api/api_routes/import_router.py +63 -0
- beetkeeper/api/api_routes/query_router.py +71 -0
- beetkeeper/api/constants.py +13 -0
- beetkeeper/api/dependencies.py +34 -0
- beetkeeper/api/fastapi_app.py +73 -0
- beetkeeper/api/ui_routes/__init__.py +38 -0
- beetkeeper/api/ui_routes/events_ui_fragments_router.py +70 -0
- beetkeeper/api/ui_routes/import_ui_fragments_router.py +233 -0
- beetkeeper/api/ui_routes/pages_ui_router.py +35 -0
- beetkeeper/api/ui_routes/search_ui_fragments_router.py +89 -0
- beetkeeper/core/__init__.py +50 -0
- beetkeeper/core/import_jobs.py +157 -0
- beetkeeper/core/import_store.py +324 -0
- beetkeeper/core/import_worker.py +483 -0
- beetkeeper/core/library.py +221 -0
- beetkeeper/db/__init__.py +16 -0
- beetkeeper/db/alembic/__init__.py +10 -0
- beetkeeper/db/alembic/env.py +116 -0
- beetkeeper/db/alembic/script.py.mako +26 -0
- beetkeeper/db/alembic/versions/7f3051110f7f_initial_event_tables.py +55 -0
- beetkeeper/db/alembic/versions/__init__.py +5 -0
- beetkeeper/db/alembic/versions/bcdd3073515d_import_job_persistence_tables.py +51 -0
- beetkeeper/db/alembic/versions/c7a2f4e1b9d0_import_job_output_column.py +32 -0
- beetkeeper/db/alembic/versions/e2b9f4c1a307_import_job_quiet_column.py +35 -0
- beetkeeper/db/alembic.ini +52 -0
- beetkeeper/db/migrations.py +55 -0
- beetkeeper/db/models.py +124 -0
- beetkeeper/db/session.py +54 -0
- beetkeeper/main.py +83 -0
- beetkeeper/py.typed +0 -0
- beetkeeper/settings/__init__.py +9 -0
- beetkeeper/settings/user_config.py +79 -0
- beetkeeper-0.0.1.dist-info/METADATA +28 -0
- beetkeeper-0.0.1.dist-info/RECORD +46 -0
- beetkeeper-0.0.1.dist-info/WHEEL +5 -0
- beetkeeper-0.0.1.dist-info/entry_points.txt +2 -0
- beetkeeper-0.0.1.dist-info/top_level.txt +1 -0
beetkeeper/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# beetkeeper: A highly configurable, self-hosted app for beets music library management. Supports both automated and manual workflows.
|
|
2
|
+
# Copyright (C) 2026 Zach Gottesman
|
|
3
|
+
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
4
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
|
5
|
+
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
beetkeeper/_version.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from beetkeeper.api.api_models.events_api_models import (
|
|
2
|
+
AlbumEventBody,
|
|
3
|
+
APIAlbum,
|
|
4
|
+
APIEventType,
|
|
5
|
+
APITrack,
|
|
6
|
+
EventIngestResponse,
|
|
7
|
+
ImportTaskFilesEventBody,
|
|
8
|
+
MultiItemEventIngestResponse,
|
|
9
|
+
TrackEventBody,
|
|
10
|
+
)
|
|
11
|
+
from beetkeeper.api.api_models.import_api_models import ImportSubmitRequest
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"APIAlbum",
|
|
15
|
+
"APIEventType",
|
|
16
|
+
"APITrack",
|
|
17
|
+
"AlbumEventBody",
|
|
18
|
+
"EventIngestResponse",
|
|
19
|
+
"ImportSubmitRequest",
|
|
20
|
+
"ImportTaskFilesEventBody",
|
|
21
|
+
"MultiItemEventIngestResponse",
|
|
22
|
+
"TrackEventBody",
|
|
23
|
+
]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Define any custom FastAPI request or response pydantic models for the `/api/events` subrouter here."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum, unique
|
|
4
|
+
|
|
5
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@unique
|
|
9
|
+
class APIEventType(StrEnum):
|
|
10
|
+
"""
|
|
11
|
+
Subset of beets listener `event_type`s which the API accepts from our plugin client.
|
|
12
|
+
See also:
|
|
13
|
+
https://beets.readthedocs.io/en/stable/dev/plugins/events.html
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
ALBUM_IMPORTED = "album_imported"
|
|
17
|
+
ALBUM_REMOVED = "album_removed"
|
|
18
|
+
IMPORT_TASK_FILES = "import_task_files"
|
|
19
|
+
TRACK_IMPORTED = "item_imported"
|
|
20
|
+
TRACK_REMOVED = "item_removed"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _BaseEventResponse(BaseModel):
|
|
24
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
25
|
+
event_type: APIEventType
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EventIngestResponse(_BaseEventResponse):
|
|
29
|
+
ingested_id: int | None = Field(
|
|
30
|
+
default=None, description="The beets db ID of the processed album / item, if successful."
|
|
31
|
+
)
|
|
32
|
+
error_msg: str | None = Field(None, description="Error message to return to the client, if any.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MultiItemEventIngestResponse(_BaseEventResponse):
|
|
36
|
+
event_ingest_responses: list[EventIngestResponse] = Field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _BaseEventBody(BaseModel):
|
|
40
|
+
model_config = ConfigDict(frozen=True, extra="allow")
|
|
41
|
+
event_type: APIEventType
|
|
42
|
+
pushed_at: AwareDatetime
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AlbumEventBody(_BaseEventBody):
|
|
46
|
+
"""
|
|
47
|
+
A post request payload expected from the plugin's listener for the `beetsplug.beetkeeper_plugin.event_listener`
|
|
48
|
+
client's `album_imported` event pushes.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
album_fields: APIAlbum
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TrackEventBody(AlbumEventBody):
|
|
55
|
+
"""
|
|
56
|
+
A post request payload expected from the plugin's listener for the `beetsplug.beetkeeper_plugin.event_listener`
|
|
57
|
+
client's `item_imported` (track imported) event pushes.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
track_fields: APITrack
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# TODO[later]: add submodels corresponding to relevant parts of the following beets event models:
|
|
64
|
+
# `beets.importer.ImportSession`, `beets.importer.ImportTask`, `beets.autotag.AlbumMatch`
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ImportTaskFilesEventBody(_BaseEventBody):
|
|
68
|
+
choice_flag: str | None
|
|
69
|
+
imported_items: list[TrackEventBody]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class APIAlbum(BaseModel):
|
|
73
|
+
"""
|
|
74
|
+
Model for the relevant parts of a `beets.library.Album` instance. used during beets plugin client push events
|
|
75
|
+
for an event tied to a given album. For simplicity, we only mark `id` as required, with all other fields as
|
|
76
|
+
optional (which might not be the case for beets internals).
|
|
77
|
+
|
|
78
|
+
NOTE: This was generated from `build_scripts/code_gen/pydantic_field_info_from_beets_model.py`.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
id: int
|
|
82
|
+
added: float | None = Field(default=None)
|
|
83
|
+
album: str = Field(default="")
|
|
84
|
+
albumartist: str = Field(default="")
|
|
85
|
+
albumartist_credit: str = Field(default="")
|
|
86
|
+
albumartist_sort: str = Field(default="")
|
|
87
|
+
albumartists: list | None = Field(default=None)
|
|
88
|
+
albumartists_credit: list | None = Field(default=None)
|
|
89
|
+
albumartists_sort: list | None = Field(default=None)
|
|
90
|
+
albumdisambig: str = Field(default="")
|
|
91
|
+
albumstatus: str = Field(default="")
|
|
92
|
+
albumtype: str = Field(default="")
|
|
93
|
+
albumtypes: list | None = Field(default=None)
|
|
94
|
+
artpath: bytes = Field(default=b"")
|
|
95
|
+
asin: str = Field(default="")
|
|
96
|
+
barcode: str = Field(default="")
|
|
97
|
+
catalognum: str = Field(default="")
|
|
98
|
+
comp: bool = Field(default=False)
|
|
99
|
+
country: str = Field(default="")
|
|
100
|
+
day: int | None = Field(default=None)
|
|
101
|
+
discogs_albumid: int | None = Field(default=None)
|
|
102
|
+
discogs_artistid: int | None = Field(default=None)
|
|
103
|
+
discogs_labelid: int | None = Field(default=None)
|
|
104
|
+
disctotal: int | None = Field(default=None)
|
|
105
|
+
genres: list | None = Field(default=None)
|
|
106
|
+
label: str = Field(default="")
|
|
107
|
+
language: str = Field(default="")
|
|
108
|
+
mb_albumartistid: str = Field(default="")
|
|
109
|
+
mb_albumartistids: list | None = Field(default=None)
|
|
110
|
+
mb_albumid: str = Field(default="")
|
|
111
|
+
mb_releasegroupid: str = Field(default="")
|
|
112
|
+
month: int | None = Field(default=None)
|
|
113
|
+
original_day: int | None = Field(default=None)
|
|
114
|
+
original_month: int | None = Field(default=None)
|
|
115
|
+
original_year: int | None = Field(default=None)
|
|
116
|
+
r128_album_gain: float | None = Field(default=None)
|
|
117
|
+
release_group_title: str = Field(default="")
|
|
118
|
+
releasegroupdisambig: str = Field(default="")
|
|
119
|
+
rg_album_gain: float | None = Field(default=None)
|
|
120
|
+
rg_album_peak: float | None = Field(default=None)
|
|
121
|
+
script: str = Field(default="")
|
|
122
|
+
style: str = Field(default="")
|
|
123
|
+
year: int | None = Field(default=None)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class APITrack(BaseModel):
|
|
127
|
+
"""
|
|
128
|
+
Model for the relevant parts of a `beets.library.Item` (track) instance. used during beets plugin client push events
|
|
129
|
+
for an event tied to a single track ('Item'). For simplicity, we only mark `id` as required, with all other fields as
|
|
130
|
+
optional (which might not be the case for beets internals).
|
|
131
|
+
|
|
132
|
+
NOTE: This was generated from `build_scripts/code_gen/pydantic_field_info_from_beets_model.py`.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
id: int
|
|
136
|
+
acoustid_fingerprint: str = Field(default="")
|
|
137
|
+
acoustid_id: str = Field(default="")
|
|
138
|
+
added: float | None = Field(default=None)
|
|
139
|
+
album: str = Field(default="")
|
|
140
|
+
album_id: int | None = Field(default=None)
|
|
141
|
+
albumartist: str = Field(default="")
|
|
142
|
+
albumartist_credit: str = Field(default="")
|
|
143
|
+
albumartist_sort: str = Field(default="")
|
|
144
|
+
albumartists: list | None = Field(default=None)
|
|
145
|
+
albumartists_credit: list | None = Field(default=None)
|
|
146
|
+
albumartists_sort: list | None = Field(default=None)
|
|
147
|
+
albumdisambig: str = Field(default="")
|
|
148
|
+
albumstatus: str = Field(default="")
|
|
149
|
+
albumtype: str = Field(default="")
|
|
150
|
+
albumtypes: list | None = Field(default=None)
|
|
151
|
+
arrangers: list | None = Field(default=None)
|
|
152
|
+
arrangers_ids: list | None = Field(default=None)
|
|
153
|
+
artist: str = Field(default="")
|
|
154
|
+
artist_credit: str = Field(default="")
|
|
155
|
+
artist_sort: str = Field(default="")
|
|
156
|
+
artists: list | None = Field(default=None)
|
|
157
|
+
artists_credit: list | None = Field(default=None)
|
|
158
|
+
artists_ids: list | None = Field(default=None)
|
|
159
|
+
artists_sort: list | None = Field(default=None)
|
|
160
|
+
asin: str = Field(default="")
|
|
161
|
+
barcode: str = Field(default="")
|
|
162
|
+
bitdepth: int | None = Field(default=None)
|
|
163
|
+
bitrate: int | None = Field(default=None)
|
|
164
|
+
bitrate_mode: str = Field(default="")
|
|
165
|
+
bpm: int | None = Field(default=None)
|
|
166
|
+
catalognum: str = Field(default="")
|
|
167
|
+
channels: int | None = Field(default=None)
|
|
168
|
+
comments: str = Field(default="")
|
|
169
|
+
comp: bool = Field(default=False)
|
|
170
|
+
composer_sort: str = Field(default="")
|
|
171
|
+
composers: list | None = Field(default=None)
|
|
172
|
+
composers_ids: list | None = Field(default=None)
|
|
173
|
+
country: str = Field(default="")
|
|
174
|
+
day: int | None = Field(default=None)
|
|
175
|
+
disc: int | None = Field(default=None)
|
|
176
|
+
discogs_albumid: int | None = Field(default=None)
|
|
177
|
+
discogs_artistid: int | None = Field(default=None)
|
|
178
|
+
discogs_labelid: int | None = Field(default=None)
|
|
179
|
+
disctitle: str = Field(default="")
|
|
180
|
+
disctotal: int | None = Field(default=None)
|
|
181
|
+
encoder: str = Field(default="")
|
|
182
|
+
encoder_info: str = Field(default="")
|
|
183
|
+
encoder_settings: str = Field(default="")
|
|
184
|
+
format: str = Field(default="")
|
|
185
|
+
genres: list | None = Field(default=None)
|
|
186
|
+
grouping: str = Field(default="")
|
|
187
|
+
initial_key: str = Field(default="")
|
|
188
|
+
isrc: str = Field(default="")
|
|
189
|
+
label: str = Field(default="")
|
|
190
|
+
language: str = Field(default="")
|
|
191
|
+
length: float | None = Field(default=None)
|
|
192
|
+
lyricists: list | None = Field(default=None)
|
|
193
|
+
lyricists_ids: list | None = Field(default=None)
|
|
194
|
+
lyrics: str = Field(default="")
|
|
195
|
+
mb_albumartistid: str = Field(default="")
|
|
196
|
+
mb_albumartistids: list | None = Field(default=None)
|
|
197
|
+
mb_albumid: str = Field(default="")
|
|
198
|
+
mb_artistid: str = Field(default="")
|
|
199
|
+
mb_artistids: list | None = Field(default=None)
|
|
200
|
+
mb_releasegroupid: str = Field(default="")
|
|
201
|
+
mb_releasetrackid: str = Field(default="")
|
|
202
|
+
mb_trackid: str = Field(default="")
|
|
203
|
+
mb_workid: str = Field(default="")
|
|
204
|
+
media: str = Field(default="")
|
|
205
|
+
month: int | None = Field(default=None)
|
|
206
|
+
mtime: float | None = Field(default=None)
|
|
207
|
+
original_day: int | None = Field(default=None)
|
|
208
|
+
original_month: int | None = Field(default=None)
|
|
209
|
+
original_year: int | None = Field(default=None)
|
|
210
|
+
path: bytes = Field(default=b"")
|
|
211
|
+
r128_album_gain: float | None = Field(default=None)
|
|
212
|
+
r128_track_gain: float | None = Field(default=None)
|
|
213
|
+
release_group_title: str = Field(default="")
|
|
214
|
+
releasegroupdisambig: str = Field(default="")
|
|
215
|
+
remixers: list | None = Field(default=None)
|
|
216
|
+
remixers_ids: list | None = Field(default=None)
|
|
217
|
+
rg_album_gain: float | None = Field(default=None)
|
|
218
|
+
rg_album_peak: float | None = Field(default=None)
|
|
219
|
+
rg_track_gain: float | None = Field(default=None)
|
|
220
|
+
rg_track_peak: float | None = Field(default=None)
|
|
221
|
+
samplerate: int | None = Field(default=None)
|
|
222
|
+
script: str = Field(default="")
|
|
223
|
+
style: str = Field(default="")
|
|
224
|
+
subtitle: str = Field(default="")
|
|
225
|
+
title: str = Field(default="")
|
|
226
|
+
track: int | None = Field(default=None)
|
|
227
|
+
trackdisambig: str = Field(default="")
|
|
228
|
+
tracktotal: int | None = Field(default=None)
|
|
229
|
+
work: str = Field(default="")
|
|
230
|
+
work_disambig: str = Field(default="")
|
|
231
|
+
year: int | None = Field(default=None)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Request/response models for the `/api/import` endpoints (see `beetkeeper.core.import_worker`)."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ImportSubmitRequest(BaseModel):
|
|
7
|
+
"""Body for starting an import: the filesystem path(s) beets should import."""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
10
|
+
paths: list[str] = Field(min_length=1, description="One or more paths (files/dirs) to import.")
|
|
11
|
+
quiet: bool = Field(
|
|
12
|
+
default=False,
|
|
13
|
+
description=(
|
|
14
|
+
"Run non-interactively (like `beet import -q`): never prompt. Strong matches are applied "
|
|
15
|
+
"automatically; anything else falls back to beets' `quiet_fallback` config (skip by default)."
|
|
16
|
+
),
|
|
17
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Aggregates the JSON `APIRouter`s (grouped per functional domain) under the `/api` prefix.
|
|
3
|
+
|
|
4
|
+
The HTML-serving `ui_routes` router is aggregated separately and mounted (not under `/api`) in
|
|
5
|
+
`beetkeeper.api.fastapi_app`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter
|
|
9
|
+
|
|
10
|
+
from beetkeeper.api.api_routes.events_router import events_router
|
|
11
|
+
from beetkeeper.api.api_routes.health_router import health_router
|
|
12
|
+
from beetkeeper.api.api_routes.import_router import import_router
|
|
13
|
+
from beetkeeper.api.api_routes.query_router import query_router
|
|
14
|
+
|
|
15
|
+
api_router = APIRouter(prefix="/api")
|
|
16
|
+
api_router.include_router(events_router)
|
|
17
|
+
api_router.include_router(health_router)
|
|
18
|
+
api_router.include_router(import_router)
|
|
19
|
+
api_router.include_router(query_router)
|
|
20
|
+
|
|
21
|
+
__all__ = ["api_router"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event endpoints which the `beetsplug.beetkeeper_plugin.event_listener.BeetKeeperClient` will push beets events to.
|
|
3
|
+
|
|
4
|
+
Each push is recorded as one `ListenerEvent` row plus its per-entity child rows (`AlbumEvent` / `TrackEvent`),
|
|
5
|
+
written through the async `SessionDep` dependency (see `beetkeeper.db.session`).
|
|
6
|
+
See also:
|
|
7
|
+
https://beets.readthedocs.io/en/stable/dev/plugins/events.html
|
|
8
|
+
`src/beetsplug/beetkeeper_plugin/event_listener.py`
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import cast
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, status
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
from beetkeeper.api.api_models import (
|
|
19
|
+
AlbumEventBody,
|
|
20
|
+
EventIngestResponse,
|
|
21
|
+
ImportTaskFilesEventBody,
|
|
22
|
+
MultiItemEventIngestResponse,
|
|
23
|
+
TrackEventBody,
|
|
24
|
+
)
|
|
25
|
+
from beetkeeper.api.api_models.events_api_models import APIEventType
|
|
26
|
+
from beetkeeper.db.models import AlbumEvent, ListenerEvent, TrackEvent
|
|
27
|
+
from beetkeeper.db.session import SessionDep
|
|
28
|
+
|
|
29
|
+
_LOGGER = logging.getLogger(__name__)
|
|
30
|
+
events_router = APIRouter(prefix="/events")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _record_listener_event(session: AsyncSession, event_type: APIEventType, pushed_at: datetime) -> int:
|
|
34
|
+
"""Inserts the parent `ListenerEvent` and flushes to obtain its generated `event_id` for child FKs."""
|
|
35
|
+
listener_event = ListenerEvent(event_type=event_type, pushed_at=pushed_at)
|
|
36
|
+
session.add(listener_event)
|
|
37
|
+
await session.flush() # populates the autoincrement `event_id` used as the child rows' FK
|
|
38
|
+
return cast("int", listener_event.event_id)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@events_router.post("/album", status_code=status.HTTP_201_CREATED)
|
|
42
|
+
async def album(album_event: AlbumEventBody, session: SessionDep) -> EventIngestResponse:
|
|
43
|
+
_LOGGER.debug("Processing album event type: %s ...", album_event.event_type)
|
|
44
|
+
listener_event_id = await _record_listener_event(session, album_event.event_type, album_event.pushed_at)
|
|
45
|
+
session.add(AlbumEvent(listener_event_id=listener_event_id, beets_album_id=album_event.album_fields.id))
|
|
46
|
+
await session.commit()
|
|
47
|
+
return EventIngestResponse(event_type=album_event.event_type, ingested_id=album_event.album_fields.id)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@events_router.post("/track", status_code=status.HTTP_201_CREATED)
|
|
51
|
+
async def track(track_event: TrackEventBody, session: SessionDep) -> EventIngestResponse:
|
|
52
|
+
_LOGGER.debug("Processing track event type: %s ...", track_event.event_type)
|
|
53
|
+
listener_event_id = await _record_listener_event(session, track_event.event_type, track_event.pushed_at)
|
|
54
|
+
session.add(
|
|
55
|
+
TrackEvent(
|
|
56
|
+
listener_event_id=listener_event_id,
|
|
57
|
+
beets_item_id=track_event.track_fields.id,
|
|
58
|
+
beets_album_id=track_event.album_fields.id,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
await session.commit()
|
|
62
|
+
return EventIngestResponse(event_type=track_event.event_type, ingested_id=track_event.track_fields.id)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@events_router.post("/filesystem", status_code=status.HTTP_201_CREATED)
|
|
66
|
+
async def filesystem(fs_event: ImportTaskFilesEventBody, session: SessionDep) -> MultiItemEventIngestResponse:
|
|
67
|
+
_LOGGER.debug("Processing filesystem event type: %s ...", fs_event.event_type)
|
|
68
|
+
listener_event_id = await _record_listener_event(session, fs_event.event_type, fs_event.pushed_at)
|
|
69
|
+
ingest_responses: list[EventIngestResponse] = []
|
|
70
|
+
for item in fs_event.imported_items:
|
|
71
|
+
session.add(
|
|
72
|
+
TrackEvent(
|
|
73
|
+
listener_event_id=listener_event_id,
|
|
74
|
+
beets_item_id=item.track_fields.id,
|
|
75
|
+
beets_album_id=item.album_fields.id,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
ingest_responses.append(EventIngestResponse(event_type=item.event_type, ingested_id=item.track_fields.id))
|
|
79
|
+
await session.commit()
|
|
80
|
+
return MultiItemEventIngestResponse(event_type=fs_event.event_type, event_ingest_responses=ingest_responses)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Health / diagnostics endpoint.
|
|
3
|
+
|
|
4
|
+
Reports the serving process's pid and the current import-leader (the `import_lock` holder). With
|
|
5
|
+
`server_workers > 1` this makes the multi-worker behaviour observable: requests are served by different
|
|
6
|
+
pids, but exactly one process is the import leader, and all share the same DB-backed job state.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter
|
|
14
|
+
from pydantic import BaseModel, ConfigDict
|
|
15
|
+
|
|
16
|
+
from beetkeeper.api.dependencies import ImportStoreDep
|
|
17
|
+
|
|
18
|
+
_LOGGER = logging.getLogger(__name__)
|
|
19
|
+
health_router = APIRouter(prefix="/health")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HealthInfo(BaseModel):
|
|
23
|
+
"""Per-process health snapshot."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(frozen=True)
|
|
26
|
+
process_pid: int
|
|
27
|
+
hostname: str
|
|
28
|
+
import_lock_holder: str | None
|
|
29
|
+
is_import_leader: bool
|
|
30
|
+
job_count: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@health_router.get("")
|
|
34
|
+
async def health(store: ImportStoreDep) -> HealthInfo:
|
|
35
|
+
"""Report this process's pid, the current import leader (lock holder), and the shared job count."""
|
|
36
|
+
pid = os.getpid()
|
|
37
|
+
hostname = socket.gethostname()
|
|
38
|
+
holder = await store.lock_holder()
|
|
39
|
+
jobs = await store.list()
|
|
40
|
+
return HealthInfo(
|
|
41
|
+
process_pid=pid,
|
|
42
|
+
hostname=hostname,
|
|
43
|
+
import_lock_holder=holder,
|
|
44
|
+
# The worker id is "<hostname>:<pid>:<uuid>"; this process is the leader iff it holds the lock.
|
|
45
|
+
is_import_leader=holder is not None and holder.startswith(f"{hostname}:{pid}:"),
|
|
46
|
+
job_count=len(jobs),
|
|
47
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RESTful (JSON) endpoints to drive interactive beets imports.
|
|
3
|
+
|
|
4
|
+
Job state lives in the cross-process DB-backed `ImportStore`; the leader-elected `ImportWorker`
|
|
5
|
+
(`beetkeeper.core.import_worker`) runs the actual beets import. These routes only read/write the store, so
|
|
6
|
+
they work on any uvicorn process. HTML/HTMX equivalents live in `ui_routes.import_ui_fragments_router`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, HTTPException, status
|
|
12
|
+
|
|
13
|
+
from beetkeeper.api.api_models import ImportSubmitRequest
|
|
14
|
+
from beetkeeper.api.dependencies import ImportStoreDep
|
|
15
|
+
from beetkeeper.core import ImportDecision, ImportJob
|
|
16
|
+
|
|
17
|
+
_LOGGER = logging.getLogger(__name__)
|
|
18
|
+
import_router = APIRouter(prefix="/import")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@import_router.post("", status_code=status.HTTP_201_CREATED)
|
|
22
|
+
async def start_import(body: ImportSubmitRequest, store: ImportStoreDep) -> ImportJob:
|
|
23
|
+
"""Enqueue an import of the given paths and return the created (PENDING) job.
|
|
24
|
+
|
|
25
|
+
Set `quiet=true` to import non-interactively (the `beet import -q` equivalent): no decision prompts.
|
|
26
|
+
"""
|
|
27
|
+
return await store.create(body.paths, quiet=body.quiet)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@import_router.get("")
|
|
31
|
+
async def list_imports(store: ImportStoreDep) -> list[ImportJob]:
|
|
32
|
+
"""List all known import jobs."""
|
|
33
|
+
return await store.list()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@import_router.get("/{job_id}")
|
|
37
|
+
async def get_import(job_id: str, store: ImportStoreDep) -> ImportJob:
|
|
38
|
+
"""Return a single import job (poll this for status / the pending decision)."""
|
|
39
|
+
return await _require_job(store, job_id)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@import_router.post("/{job_id}/decision")
|
|
43
|
+
async def decide_import(job_id: str, decision: ImportDecision, store: ImportStoreDep) -> ImportJob:
|
|
44
|
+
"""Answer the decision an import is parked on; 409 if it isn't awaiting one."""
|
|
45
|
+
await _require_job(store, job_id)
|
|
46
|
+
if not await store.submit_decision(job_id, decision):
|
|
47
|
+
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Job is not awaiting a decision.")
|
|
48
|
+
return await _require_job(store, job_id)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@import_router.post("/{job_id}/abort")
|
|
52
|
+
async def abort_import(job_id: str, store: ImportStoreDep) -> ImportJob:
|
|
53
|
+
"""Request cooperative cancellation of an in-flight import."""
|
|
54
|
+
await _require_job(store, job_id)
|
|
55
|
+
await store.request_abort(job_id)
|
|
56
|
+
return await _require_job(store, job_id)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _require_job(store: ImportStoreDep, job_id: str) -> ImportJob:
|
|
60
|
+
job = await store.get(job_id)
|
|
61
|
+
if job is None:
|
|
62
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"No import job '{job_id}'.")
|
|
63
|
+
return job
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Read-only beets query routes (`list`, `stats`, `fields`), backed by `core.BeetsLibrary`."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Query
|
|
8
|
+
|
|
9
|
+
from beetkeeper.api.dependencies import BeetsLibraryDep
|
|
10
|
+
|
|
11
|
+
_LOGGER = logging.getLogger(__name__)
|
|
12
|
+
query_router = APIRouter(prefix="/query")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# For each possible query param and its corresponding beets query search filter, see:
|
|
16
|
+
# https://beets.readthedocs.io/en/v2.12.0/reference/query.html#combining-keywords
|
|
17
|
+
# Some params are repeatable (multiple values per request) via the FastAPI `Query()` list pattern:
|
|
18
|
+
# https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#query-parameter-list-multiple-values
|
|
19
|
+
@query_router.get("/list")
|
|
20
|
+
async def list_( # trailing underscore: avoid shadowing the builtin `list` (used in annotations below).
|
|
21
|
+
library: BeetsLibraryDep,
|
|
22
|
+
albums: bool = False,
|
|
23
|
+
keyword: Annotated[list[str] | None, Query()] = None,
|
|
24
|
+
field: Annotated[list[str] | None, Query()] = None,
|
|
25
|
+
phrase: str | None = None,
|
|
26
|
+
filepath: Path | None = None,
|
|
27
|
+
sort_by: str | None = None,
|
|
28
|
+
) -> list[dict[str, Any]]:
|
|
29
|
+
"""Execute `beet list ...`: return matching tracks (or albums) as JSON objects.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
library: injected beets library adapter.
|
|
33
|
+
albums: like `-a`; return albums instead of individual tracks.
|
|
34
|
+
keyword: repeatable bare-keyword query parts (substring match across default fields).
|
|
35
|
+
field: repeatable `field:value` parts (exact `=`, regex `::`, numeric/date ranges all supported).
|
|
36
|
+
phrase: a single multi-word phrase part.
|
|
37
|
+
filepath: a path within the beets library (becomes a path query).
|
|
38
|
+
sort_by: a beets sort token, e.g. `year+` or `artist-`.
|
|
39
|
+
|
|
40
|
+
See: https://beets.readthedocs.io/en/v2.12.0/reference/cli.html#list
|
|
41
|
+
"""
|
|
42
|
+
query_parts: list[str] = []
|
|
43
|
+
if keyword:
|
|
44
|
+
query_parts.extend(keyword)
|
|
45
|
+
if field:
|
|
46
|
+
query_parts.extend(field)
|
|
47
|
+
if phrase:
|
|
48
|
+
query_parts.append(phrase)
|
|
49
|
+
if filepath:
|
|
50
|
+
query_parts.append(str(filepath))
|
|
51
|
+
if sort_by:
|
|
52
|
+
query_parts.append(sort_by)
|
|
53
|
+
return await library.query_albums(query_parts) if albums else await library.query_items(query_parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@query_router.get("/stats")
|
|
57
|
+
async def stats(library: BeetsLibraryDep) -> dict[str, Any]:
|
|
58
|
+
"""Execute `beet stats`: track count, total time, approximate size, and artist/album counts.
|
|
59
|
+
|
|
60
|
+
See: https://beets.readthedocs.io/en/v2.12.0/reference/cli.html#stats
|
|
61
|
+
"""
|
|
62
|
+
return await library.stats()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@query_router.get("/fields")
|
|
66
|
+
async def fields(library: BeetsLibraryDep) -> dict[str, list[str]]:
|
|
67
|
+
"""Execute `beet fields`: the item/album query fields and flexible attributes available.
|
|
68
|
+
|
|
69
|
+
See: https://beets.readthedocs.io/en/v2.12.0/reference/cli.html#fields
|
|
70
|
+
"""
|
|
71
|
+
return await library.fields()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Final
|
|
3
|
+
|
|
4
|
+
from starlette.templating import Jinja2Templates
|
|
5
|
+
|
|
6
|
+
STATIC_DIRPATH: Final[Path] = Path(__file__).resolve().parent / "static"
|
|
7
|
+
# TODO[Claude]: two things to resolve here:
|
|
8
|
+
# 1. HTMX fragment rendering: this is a plain Starlette `Jinja2Templates`, but `jinja2-fragments` is a
|
|
9
|
+
# declared dependency. If routes need to return a single template block for an HTMX swap, switch to
|
|
10
|
+
# `jinja2_fragments.fastapi.Jinja2Blocks` (and standardize block usage). Decide and document.
|
|
11
|
+
# 2. Templates live inside the publicly mounted `static/` tree, so raw `.html` template source is also reachable at
|
|
12
|
+
# `/static/html_templates/...`. If that exposure is unintended, relocate templates out of `static/`.
|
|
13
|
+
TEMPLATES: Final[Jinja2Templates] = Jinja2Templates(directory=STATIC_DIRPATH / "html_templates")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared FastAPI dependencies for the API + UI routers."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Annotated, cast
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, Request
|
|
6
|
+
|
|
7
|
+
from beetkeeper.core import BeetsLibrary, ImportStore
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
11
|
+
|
|
12
|
+
from beetkeeper.settings import UserConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_beets_library(request: Request) -> BeetsLibrary:
|
|
16
|
+
"""Return a `BeetsLibrary` adapter bound to the configured beets library (read from `UserConfig`)."""
|
|
17
|
+
user_config = cast("UserConfig", request.app.state.user_config)
|
|
18
|
+
return BeetsLibrary(user_config.beets_config_filepath)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
BeetsLibraryDep = Annotated[BeetsLibrary, Depends(get_beets_library)]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_import_store(request: Request) -> ImportStore:
|
|
25
|
+
"""Return a DB-backed `ImportStore` over the app's sessionmaker (created in the lifespan).
|
|
26
|
+
|
|
27
|
+
The store is the cross-process source of truth for import jobs, so route handlers use it (not the
|
|
28
|
+
per-process `ImportWorker`) to submit/poll/answer/abort — see `beetkeeper.api.fastapi_app`.
|
|
29
|
+
"""
|
|
30
|
+
sessionmaker = cast("async_sessionmaker[AsyncSession]", request.app.state.db_sessionmaker)
|
|
31
|
+
return ImportStore(sessionmaker)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
ImportStoreDep = Annotated[ImportStore, Depends(get_import_store)]
|