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.
Files changed (46) hide show
  1. beetkeeper/__init__.py +5 -0
  2. beetkeeper/_version.py +3 -0
  3. beetkeeper/api/__init__.py +8 -0
  4. beetkeeper/api/api_models/__init__.py +23 -0
  5. beetkeeper/api/api_models/events_api_models.py +231 -0
  6. beetkeeper/api/api_models/import_api_models.py +17 -0
  7. beetkeeper/api/api_routes/__init__.py +21 -0
  8. beetkeeper/api/api_routes/events_router.py +80 -0
  9. beetkeeper/api/api_routes/health_router.py +47 -0
  10. beetkeeper/api/api_routes/import_router.py +63 -0
  11. beetkeeper/api/api_routes/query_router.py +71 -0
  12. beetkeeper/api/constants.py +13 -0
  13. beetkeeper/api/dependencies.py +34 -0
  14. beetkeeper/api/fastapi_app.py +73 -0
  15. beetkeeper/api/ui_routes/__init__.py +38 -0
  16. beetkeeper/api/ui_routes/events_ui_fragments_router.py +70 -0
  17. beetkeeper/api/ui_routes/import_ui_fragments_router.py +233 -0
  18. beetkeeper/api/ui_routes/pages_ui_router.py +35 -0
  19. beetkeeper/api/ui_routes/search_ui_fragments_router.py +89 -0
  20. beetkeeper/core/__init__.py +50 -0
  21. beetkeeper/core/import_jobs.py +157 -0
  22. beetkeeper/core/import_store.py +324 -0
  23. beetkeeper/core/import_worker.py +483 -0
  24. beetkeeper/core/library.py +221 -0
  25. beetkeeper/db/__init__.py +16 -0
  26. beetkeeper/db/alembic/__init__.py +10 -0
  27. beetkeeper/db/alembic/env.py +116 -0
  28. beetkeeper/db/alembic/script.py.mako +26 -0
  29. beetkeeper/db/alembic/versions/7f3051110f7f_initial_event_tables.py +55 -0
  30. beetkeeper/db/alembic/versions/__init__.py +5 -0
  31. beetkeeper/db/alembic/versions/bcdd3073515d_import_job_persistence_tables.py +51 -0
  32. beetkeeper/db/alembic/versions/c7a2f4e1b9d0_import_job_output_column.py +32 -0
  33. beetkeeper/db/alembic/versions/e2b9f4c1a307_import_job_quiet_column.py +35 -0
  34. beetkeeper/db/alembic.ini +52 -0
  35. beetkeeper/db/migrations.py +55 -0
  36. beetkeeper/db/models.py +124 -0
  37. beetkeeper/db/session.py +54 -0
  38. beetkeeper/main.py +83 -0
  39. beetkeeper/py.typed +0 -0
  40. beetkeeper/settings/__init__.py +9 -0
  41. beetkeeper/settings/user_config.py +79 -0
  42. beetkeeper-0.0.1.dist-info/METADATA +28 -0
  43. beetkeeper-0.0.1.dist-info/RECORD +46 -0
  44. beetkeeper-0.0.1.dist-info/WHEEL +5 -0
  45. beetkeeper-0.0.1.dist-info/entry_points.txt +2 -0
  46. 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,3 @@
1
+ """This is the canonical source of truth for the `beetkeeper` version."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,8 @@
1
+ """
2
+ This module contains the components necessary for defining `FastAPI` app instance, along with its composite
3
+ subrouters, and any static files / HTML templates.
4
+ """
5
+
6
+ from beetkeeper.api.fastapi_app import beetkeeper_app
7
+
8
+ __all__ = ["beetkeeper_app"]
@@ -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)]