Compare commits

...

18 Commits

Author SHA1 Message Date
Senko-san e45e578f54 feat(library): remote browse status + save/materialize API (§Phase2-3)
Docker Build & Publish / build (push) Successful in 1m11s
Docker Build & Publish / push (push) Failing after 6s
Docker Build & Publish / Prune old image versions (push) Has been skipped
Search results now report whether a hit is already saved (in_library,
track_id, availability). New RemoteLibraryService backs POST
/tracks/remote (idempotent placeholder save) and POST
/tracks/{id}/materialize (on-demand fetch via a new materialize_track
arq task, reusing in-flight jobs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 18:11:01 +03:00
Senko-san 58b98ab5ed feat(library): lazy materialization foundation for remote tracks (§Phase1)
Docker Build & Publish / build (push) Successful in 1m10s
Docker Build & Publish / push (push) Failing after 7s
Docker Build & Publish / Prune old image versions (push) Has been skipped
Adds nullable storage fields + availability column on tracks, remote
source/source_id identity on albums/artists, TrackRepository.materialize()
and get_or_create_remote() repos — groundwork for on-demand YTM library
(placeholders saved without audio, materialized in-place on first play).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:51:43 +03:00
Senko-san 78007461e1 feat(sources): YouTube Music search + download pipeline (§1C/§1E)
Docker Build & Publish / build (push) Successful in 2m39s
Docker Build & Publish / push (push) Failing after 36s
Docker Build & Publish / Prune old image versions (push) Has been skipped
Pluggable fetch source: ytmusicapi search + yt-dlp download (cookies-file guard), DownloadJob entity/repo + DownloadService, download_task worker with exponential-backoff retries, and wired /search, /sources/{source}/search, and /downloads endpoints. Adds youtube_enabled/cookies config, yt-dlp+ytmusicapi deps, and the download_jobs.track_id migration. Snapshot also bundles in-progress storage/tracks/acoustid edits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:04:33 +03:00
Senko-san ea880edd57 feat(tracks): filter track list by ingest source
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Add an optional `source` filter to `GET /api/v1/tracks` (and the
`TrackRepository.list`/`count` port + SQLAlchemy adapter). Lets clients
query, e.g., only uploaded tracks (`?source=upload`) newest-first — the
backing for the webui's persistent "Recently uploaded" view.

- test: upload then list with `?source=upload` (hit) / `?source=youtube`
  (miss)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:35:51 +03:00
Senko-san fa23568214 feat(storage): library + disk statistics endpoint (§A6)
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Implement `GET /api/v1/storage`, replacing the stub. Returns aggregate
library facts (track/artist/album counts, total footprint, playtime,
per-format / per-source / metadata-status breakdowns, top genres) plus
the real capacity of the backing volume.

- domain: `LibraryStats`, `FormatBreakdown`, `DiskUsage` value objects
- ports: `FileStorage.disk_usage()` (local = shutil.disk_usage walking up
  to the nearest existing ancestor; S3 returns None — no fixed disk)
- repo: `TrackRepository.library_stats()` (single set of GROUP BYs)
- tests: storage stats API (auth, empty library, upload counting)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:19:53 +03:00
Senko-san 636820afb8 fix: invalid Python 2 except syntax in AcoustID client
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
The except clause used the Python 2 multi-exception syntax, which is
a SyntaxError under Python 3.14 and broke import of this module.
2026-06-14 01:01:33 +03:00
Senko-san 63c7d05eca feat(metadata): implement single-track metadata editor API (§A7/§1H)
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Adds inline AcoustID match-finding (multiple ranked candidates via
lookup_all) and PUT /tracks/{id}/metadata for manual edits, resolving
artist/album and setting metadata_status=manual. Extends TrackOut with
genre/year/track_number.
2026-06-13 14:34:43 +03:00
Senko-san 73d7da440f feat(enrichment): record status/errors and trust high-confidence AcoustID
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Two related gaps surfaced from "uploaded a track, nothing changed / no status":

- A track could stay stuck on `pending` forever (an unexpected worker error
  rolled back the run without recording anything), and `failed` carried no
  reason. Add `tracks.metadata_error` + `tracks.enriched_at` (migration), stamp
  the outcome in apply_enrichment, add TrackRepository.mark_enrichment_failed,
  wrap enrich_task to persist crashes as `failed` in a fresh session, and emit a
  human-readable no-match reason. Expose metadata_error/enriched_at in TrackOut.

- The tag-first merge let junk embedded tags (e.g. "Music Track"/"Sound_13958")
  override even a 0.99-confidence AcoustID match. Add acoustid_trust_score
  (default 0.85): above it the acoustic identity wins for title/artist/album/
  year, tags are fallback; below it, tag-first as before.

Add a license-free real-file fixture (Scarlet Fire / Otis McDonald) whose junk
tags AcoustID overrides, with an always-on tag-reader test plus fpcalc/AcoustID/
network-gated identity + full-pipeline tests (skip on host, run in the container).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:29:08 +03:00
Senko-san 30cb8901f2 fix(tests): isolate suite to a dedicated *_test database
Integration fixtures call Base.metadata.drop_all/create_all on get_engine(),
whose DATABASE_URL points at the developer's real DB — localhost:5432/mcma for
host pytest, db:5432/mcma for `make test-api` (pytest runs inside the api
container). Every run silently wiped dev data: drop_all removes ORM tables but
leaves alembic_version (outside Base.metadata), the exact "tables keep
disappearing, version survives" symptom.

conftest now redirects the whole suite to a <db>_test database before settings
load and creates it on demand via asyncpg, so the dev DB is never opened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:27:58 +03:00
Senko-san 0bb752f582 feat: cover-art pipeline (§1D)
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Resolve, store and serve album cover art.

Sources (tag-first, mirroring enrichment): embedded artwork extracted
offline via mutagen (ID3 APIC / FLAC+OGG Picture / MP4 covr), then Cover
Art Archive by release-group MBID as a network fallback. Resolution runs
inside MetadataEnrichmentService after album resolution, only when the
album has no cover yet (idempotent, never overwrites), and is best-effort
so a cover failure never affects enrichment status.

- CoverArt value object + CoverArtExtractor/CoverArtProvider ports
- MutagenCoverExtractor + CoverArtArchiveClient adapters
- AcoustID parser now captures release_group_mbid
- Covers stored via FileStorage at covers/{album_id}.{ext} (local + S3)
- AlbumRepository.set_cover_path
- Serve real covers: GET /api/v1/albums|tracks/{id}/cover (StreamUser,
  ?token=), Subsonic getCoverArt (placeholder fallback)
- has_cover flag on AlbumOut/TrackOut
- coverart_enabled / coverart_base_url settings
- tests: cover resolution units + release_group parse + DB-backed
  test_cover_api.py (139 green via make test-api)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:10:05 +03:00
Senko-san c7e078d758 feat(config): derive MusicBrainz/AcoustID User-Agent from app name+version
Docker Build & Publish / build (push) Successful in 1m8s
Docker Build & Publish / push (push) Failing after 6s
Docker Build & Publish / Prune old image versions (push) Has been skipped
Replace the placeholder MUSICBRAINZ_USER_AGENT env var with
MUSICBRAINZ_OWNER_EMAIL. The User-Agent ("MCMA/<version> ( <contact> )")
is now composed from the fixed app name, the installed package version,
and the operator's contact email — falling back to the project URL when
no email is configured. Also use the same version for the FastAPI app.
2026-06-11 00:39:24 +03:00
Senko-san 356cd00772 fix(health): expose health endpoints under /api/v1
Docker Build & Publish / build (push) Successful in 1m15s
Docker Build & Publish / push (push) Failing after 5s
Docker Build & Publish / Prune old image versions (push) Has been skipped
The webui connection ping requests ${apiBase}/health where apiBase is
/api/v1, so it was hitting /api/v1/health — a route that never existed
(health was mounted only at the root). Mount health_router under the v1
aggregator too; root /health stays in main.py for compose/nginx probes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:20:00 +03:00
Senko-san 14c1bc16e0 feat(auth): public self-service registration (ALLOW_REGISTRATION)
Docker Build & Publish / build (push) Successful in 1m8s
Docker Build & Publish / push (push) Failing after 34s
Docker Build & Publish / Prune old image versions (push) Has been skipped
Add POST /auth/register: creates a non-superuser then auto-logs in,
returning the same TokenResponse as login. Gated by the new
allow_registration setting (env ALLOW_REGISTRATION, default true);
when disabled it raises PermissionDeniedError (403). Accounts remain
admin-only for superusers.

Tests cover create+login, duplicate (409), short password (422), and
the disabled (403) path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:06:52 +03:00
Senko-san c72d19599a feat(enrichment): tag-first metadata pipeline (§1D)
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Failing after 10m8s
Implements the §6.2 enrichment pipeline: embedded tags → Chromaprint
fingerprint → AcoustID lookup. Well-tagged files get correct
artist/album/title offline; the rest are identified via AcoustID
(which also yields a MusicBrainz recording id in one call).

- domain: AudioTags/Fingerprint/RecordingMatch value objects; ports
  AudioTagReader, AudioFingerprinter, AcoustIdClient; TrackRepository
  .apply_enrichment (gap-fill, never erases) + AlbumRepository.get_or_create
- infrastructure/metadata: MutagenTagReader, FpcalcFingerprinter,
  AcoustIdHttpClient (rich meta=recordings+releasegroups, throttled)
- application: MetadataEnrichmentService — tags preferred, AcoustID fills
  gaps; resolves artist/album; status enriched/failed; skips manual;
  every external step wrapped (graceful degradation)
- workers: enrich_task registered; enqueue_enrich is best-effort and
  deferred so the caller's txn commits before the worker reads the row
- wiring: upload enqueues after add; import returns imported_ids and
  enqueues post-commit (mid-scan would race the worker); manual
  POST /tracks/{id}/metadata/enrich endpoint
- deps: add mutagen (fpcalc/ffmpeg already in the image)

Tests: metadata service orchestration, AcoustID parser, tag helpers.
125 passed; mypy strict + ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:04:02 +03:00
Senko-san 48e3418c7f feat(sources): local_folder source backend + import pipeline
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
First ingest path beyond manual upload (plan §1C). Source abstraction +
the first concrete backend, so a homelab can index an existing library.

- domain: SourceBackend/IndexableSource ports + SourceInfo/SourceFile shapes
- infrastructure/sources: LocalFolderSource (walks a mounted dir, idempotent
  source_id = relative path) + registry built from settings
- application: LibraryImportService — batch sibling of UploadService; dedup on
  (source, source_id), copy into storage, minimal track (metadata_status=pending,
  enrichment fills the rest in 1D), per-file failures isolated
- workers: scan_local_folder arq task (registered) + enqueue helper (503 if
  Redis down)
- api: GET /sources, POST /sources/{source}/scan (admin, enqueues), /health
- config: LOCAL_MEDIA_IMPORT_PATH; README + .env.example documented
- tests: scanner, registry, import service (fakes) + DB-gated sources API path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:02:09 +03:00
Senko-san 551afbab13 feat(subsonic): browsing, search, media, playlist, annotation endpoints
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Thin adapters over the existing services/repositories (no business logic):

- system: ping (auth check), getLicense
- browsing: getArtists/getArtist/getAlbum, getAlbumList(2) (newest/alpha/random),
  getSong, getGenres, getMusicFolders/getIndexes/getMusicDirectory (one folder)
- search: search3 (delegates to the library repos)
- media: stream + download (reuse StreamingService, honor Range); getCoverArt
  returns a placeholder until the cover pipeline lands
- playlists: get/create/update/delete over the playlist repo (owner-scoped)
- annotation: star/unstar → append-only like log, scrobble → play history,
  setRating → clean no-op
- all endpoints also accept the .view suffix and GET+POST for client compat

Repo support: album list ordering (newest/random), track genre facets.
README documents the mandatory-HTTPS requirement and app-password workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:24:06 +03:00
Senko-san b975164fc2 feat(subsonic): response envelope, id scheme, and error mapping
- envelope: one serializer emitting the <subsonic-response> wrapper in XML
  (default) and JSON (f=json), carrying status/version/type/serverVersion
- ids: stable, reversible type-prefixed ids (tr-/al-/ar-/pl-) ↔ UUIDs
- errors: /rest requests render the Subsonic error envelope (always HTTP 200)
  with standard codes (10 missing param, 40 wrong creds, 50, 70 not found)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:23:30 +03:00
Senko-san 7a17e3babd feat(subsonic): per-user encrypted app-password foundation
Subsonic auth (t=md5(password+salt), legacy p=) needs a recoverable secret,
but login passwords are stored as a one-way argon2 hash. Add a separate,
per-user app-password: high-entropy, random, and encrypted at rest with a
Fernet key derived from SUBSONIC_SECRET_KEY (never stored in the DB).

- SubsonicPasswordCipher + generate_subsonic_password in core.security
- users.subsonic_password_enc column (+ Alembic migration), repo + port methods
- SubsonicAuthService: verify (t+s / p / p=enc:) and rotate/reveal lifecycle
- self-service GET/POST /users/me/subsonic-password + admin rotate endpoint
- domain SubsonicCredentials + SubsonicCipher port; deps wiring

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:23:19 +03:00
116 changed files with 9249 additions and 168 deletions
+14 -1
View File
@@ -16,14 +16,27 @@ REDIS_URL=redis://localhost:6379/0
JWT_SECRET=change-me-in-prod JWT_SECRET=change-me-in-prod
ACCESS_TOKEN_TTL_SECONDS=900 ACCESS_TOKEN_TTL_SECONDS=900
REFRESH_TOKEN_TTL_SECONDS=2592000 REFRESH_TOKEN_TTL_SECONDS=2592000
# Public self-service sign-up (POST /auth/register). Set to false to make
# accounts admin-only. Registered users are never superusers.
ALLOW_REGISTRATION=true
# subsonic — key that encrypts per-user Subsonic app-passwords at rest.
# GENERATE a strong secret for prod (`openssl rand -hex 32`); rotating it
# invalidates all stored app-passwords. NOTE: /rest must be served over HTTPS.
SUBSONIC_SECRET_KEY=change-me-subsonic-key
# media / storage # media / storage
MEDIA_PATH=/data/media MEDIA_PATH=/data/media
TRANSCODE_CACHE_PATH=/data/transcode-cache TRANSCODE_CACHE_PATH=/data/transcode-cache
MAX_PARALLEL_DOWNLOADS=2 MAX_PARALLEL_DOWNLOADS=2
# sources — mounted folder the `local` source indexes (copies into MEDIA_PATH).
# Unset → the local source is not registered. Mount read-only in compose.
# LOCAL_MEDIA_IMPORT_PATH=/import
# external services (all optional — backend degrades gracefully if unset) # external services (all optional — backend degrades gracefully if unset)
# ML_SERVICE_URL=http://ml:9000 # ML_SERVICE_URL=http://ml:9000
# ACOUSTID_API_KEY= # ACOUSTID_API_KEY=
MUSICBRAINZ_USER_AGENT=mcma-backend/0.1.0 ( https://github.com/your/repo ) # Sent to MusicBrainz/AcoustID as part of the User-Agent (MCMA/<version> ( <email> )).
# MUSICBRAINZ_OWNER_EMAIL=you@example.com
# YOUTUBE_COOKIES_PATH=/data/cookies.txt # YOUTUBE_COOKIES_PATH=/data/cookies.txt
+54
View File
@@ -70,3 +70,57 @@ The DB URL is injected from app settings — never hardcoded in `alembic.ini`.
All settings come from environment variables (or `.env` in dev). See All settings come from environment variables (or `.env` in dev). See
[`.env.example`](.env.example). External services (ML, AcoustID, MusicBrainz) [`.env.example`](.env.example). External services (ML, AcoustID, MusicBrainz)
are **optional** — the backend degrades gracefully when they are absent. are **optional** — the backend degrades gracefully when they are absent.
## Sources & importing music
Music enters the library through **source backends** (`app/infrastructure/sources`),
selected via a registry. The first backend is **`local`** — it indexes a mounted
folder, copying each audio file into managed storage and creating a track
(`metadata_status=pending`; real metadata is filled later by enrichment).
```bash
# point the instance at an existing library (mount read-only in compose)
LOCAL_MEDIA_IMPORT_PATH=/import
GET /api/v1/sources # list configured sources + availability
POST /api/v1/sources/local/scan # admin: enqueue an import (runs in the worker)
GET /api/v1/sources/local/health # availability check
```
Scanning is a background job (arq worker) — the endpoint only enqueues it; the
walk + file copies never run in the request cycle. Re-scans are idempotent
(dedup on `(source, source_id)`, where `source_id` is the path within the root).
## Subsonic API (`/rest`)
A Subsonic-compatible API is mounted at `/rest`, so standard clients (Symfonium,
DSub, play:Sub, …) can browse the library and stream. It is a thin adapter over
the native services — it adds no business logic of its own.
**HTTPS is mandatory.** Subsonic authentication puts the credential in the URL
(`t=md5(password+salt)&s=…`, or the legacy `p=`), so `/rest` must only ever be
exposed behind TLS (terminate at the reverse proxy). Never serve it over plain
HTTP.
### App-passwords
Subsonic auth needs a recoverable secret, but login passwords are stored as a
one-way argon2 hash. So Subsonic clients authenticate against a separate,
per-user **app-password** — high-entropy, random, and encrypted at rest with a
key derived from `SUBSONIC_SECRET_KEY` (set this to a strong random string in
prod; rotating it invalidates all stored app-passwords).
Self-service lifecycle (native API, needs a normal JWT login):
```bash
GET /api/v1/users/me/subsonic-password # reveal (generated lazily on first read)
POST /api/v1/users/me/subsonic-password # rotate
# admin, for any user:
POST /api/v1/admin/users/{user_id}/subsonic-password
```
Point the client at the instance URL, use your **username** + the revealed
**app-password** (not your login password).
> **Cover art** (`getCoverArt`) currently returns a placeholder — the cover
> pipeline (`/api/v1/.../cover` endpoints) is not implemented yet.
@@ -0,0 +1,32 @@
"""subsonic: per-user encrypted app-password
Revision ID: 20260608_subsonic_pw
Revises: 20260608_storage_uri
Create Date: 2026-06-08 12:00:00.000000
Adds ``users.subsonic_password_enc`` — the recoverable, Fernet-encrypted
Subsonic app-password (plan §7). NULL until the user generates one.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260608_subsonic_pw"
down_revision: str | None = "20260608_storage_uri"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("subsonic_password_enc", sa.String(length=255), nullable=True),
)
def downgrade() -> None:
op.drop_column("users", "subsonic_password_enc")
@@ -0,0 +1,39 @@
"""tracks: enrichment outcome (error reason + completion time)
Revision ID: 20260613_enrich_outcome
Revises: 20260608_subsonic_pw
Create Date: 2026-06-13 13:00:00.000000
Adds ``tracks.metadata_error`` and ``tracks.enriched_at`` so a finished
enrichment run records *why* it failed and *when* it completed. Lets the UI
distinguish a still-pending/running track from one that is done or failed, and
surface an actionable reason instead of a silent spinner (plan §6.2).
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260613_enrich_outcome"
down_revision: str | None = "20260608_subsonic_pw"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"tracks",
sa.Column("metadata_error", sa.String(length=2048), nullable=True),
)
op.add_column(
"tracks",
sa.Column("enriched_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("tracks", "enriched_at")
op.drop_column("tracks", "metadata_error")
@@ -0,0 +1,47 @@
"""download_jobs: link finished job to its imported track
Revision ID: 20260614_dl_track_id
Revises: 20260613_enrich_outcome
Create Date: 2026-06-14 10:00:00.000000
Adds ``download_jobs.track_id`` (nullable FK → ``tracks.id``) so a completed
download can point at the library track it produced — the §A5 download manager
links a "done" job to the track, and re-runs can tell a job already imported
(plan §6.1).
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260614_dl_track_id"
down_revision: str | None = "20260613_enrich_outcome"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"download_jobs",
sa.Column("track_id", sa.Uuid(), nullable=True),
)
op.create_foreign_key(
op.f("fk_download_jobs_track_id_tracks"),
"download_jobs",
"tracks",
["track_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint(
op.f("fk_download_jobs_track_id_tracks"),
"download_jobs",
type_="foreignkey",
)
op.drop_column("download_jobs", "track_id")
@@ -0,0 +1,65 @@
"""remote placeholders: track availability, album/artist remote ids
Revision ID: dc126696f5a6
Revises: 20260614_dl_track_id
Create Date: 2026-06-14 11:25:30.643588
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'dc126696f5a6'
down_revision: str | None = '20260614_dl_track_id'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('albums', sa.Column('source', sa.String(length=32), nullable=True))
op.add_column('albums', sa.Column('source_id', sa.String(length=512), nullable=True))
op.create_unique_constraint('uq_albums_source_source_id', 'albums', ['source', 'source_id'])
op.add_column('artists', sa.Column('source', sa.String(length=32), nullable=True))
op.add_column('artists', sa.Column('source_id', sa.String(length=512), nullable=True))
op.create_unique_constraint('uq_artists_source_source_id', 'artists', ['source', 'source_id'])
op.add_column(
'tracks',
sa.Column('availability', sa.String(length=16), nullable=False, server_default='local'),
)
op.alter_column('tracks', 'availability', server_default=None)
op.alter_column('tracks', 'storage_uri',
existing_type=sa.VARCHAR(length=2048),
nullable=True)
op.alter_column('tracks', 'file_format',
existing_type=sa.VARCHAR(length=32),
nullable=True)
op.alter_column('tracks', 'file_size',
existing_type=sa.INTEGER(),
nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('tracks', 'file_size',
existing_type=sa.INTEGER(),
nullable=False)
op.alter_column('tracks', 'file_format',
existing_type=sa.VARCHAR(length=32),
nullable=False)
op.alter_column('tracks', 'storage_uri',
existing_type=sa.VARCHAR(length=2048),
nullable=False)
op.drop_column('tracks', 'availability')
op.drop_constraint('uq_artists_source_source_id', 'artists', type_='unique')
op.drop_column('artists', 'source_id')
op.drop_column('artists', 'source')
op.drop_constraint('uq_albums_source_source_id', 'albums', type_='unique')
op.drop_column('albums', 'source_id')
op.drop_column('albums', 'source')
# ### end Alembic commands ###
+57
View File
@@ -0,0 +1,57 @@
"""Shared cover-art serving helper (presentation).
Streams a stored cover image from the :class:`FileStorage` port. Used by the
native ``/api/v1`` cover endpoints and the Subsonic ``getCoverArt`` adapter so
the streaming/content-type logic lives in one place.
"""
import uuid
from fastapi.responses import StreamingResponse
from app.domain.entities.album import Album
from app.domain.errors import NotFoundError, StorageError
from app.domain.ports import AlbumRepository, FileStorage, TrackRepository
_CONTENT_TYPE_BY_EXT: dict[str, str] = {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"webp": "image/webp",
"gif": "image/gif",
}
# Covers are immutable for a given album (a new cover means a new key), so let
# clients cache aggressively.
_CACHE_CONTROL = "public, max-age=86400"
def _content_type_for(key: str) -> str:
ext = key.rsplit(".", 1)[-1].lower() if "." in key else ""
return _CONTENT_TYPE_BY_EXT.get(ext, "application/octet-stream")
async def stream_cover(storage: FileStorage, cover_path: str) -> StreamingResponse:
"""Stream a stored cover by its storage key. Raises ``NotFoundError`` if the
object is missing (a dangling ``cover_path`` reads as "no cover")."""
try:
stream, total = await storage.open_range(cover_path, 0, None)
except StorageError as exc:
raise NotFoundError("Cover not found.") from exc
return StreamingResponse(
stream,
media_type=_content_type_for(cover_path),
headers={"Content-Length": str(total), "Cache-Control": _CACHE_CONTROL},
)
async def resolve_album_for_track(
track_repo: TrackRepository,
album_repo: AlbumRepository,
track_id: uuid.UUID,
) -> Album | None:
"""The album that owns a track (cover lives on the album), or ``None``."""
track = await track_repo.get_by_id(track_id)
if track is None or track.album_id is None:
return None
return await album_repo.get_by_id(track.album_id)
+107 -3
View File
@@ -10,23 +10,28 @@ from collections.abc import AsyncIterator
from functools import lru_cache from functools import lru_cache
from typing import Annotated from typing import Annotated
from fastapi import Depends from fastapi import Depends, Query
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.application.auth_service import AuthService from app.application.auth_service import AuthService
from app.application.download_service import DownloadService
from app.application.metadata_service import MetadataEnrichmentService
from app.application.remote_library_service import RemoteLibraryService
from app.application.streaming_service import StreamingService from app.application.streaming_service import StreamingService
from app.application.subsonic_auth_service import SubsonicAuthService
from app.application.upload_service import UploadService from app.application.upload_service import UploadService
from app.application.user_service import UserService from app.application.user_service import UserService
from app.core.config import get_settings from app.core.config import get_settings
from app.core.security import Argon2PasswordHasher, JwtTokenService from app.core.security import Argon2PasswordHasher, JwtTokenService, SubsonicPasswordCipher
from app.domain.entities import User from app.domain.entities import User
from app.domain.errors import AuthenticationError, PermissionDeniedError from app.domain.errors import AuthenticationError, PermissionDeniedError
from app.domain.ports import FileStorage, PasswordHasher, TokenService from app.domain.ports import FileStorage, PasswordHasher, SubsonicCipher, TokenService
from app.infrastructure.db import get_sessionmaker from app.infrastructure.db import get_sessionmaker
from app.infrastructure.db.repositories import ( from app.infrastructure.db.repositories import (
SqlAlchemyAlbumRepository, SqlAlchemyAlbumRepository,
SqlAlchemyArtistRepository, SqlAlchemyArtistRepository,
SqlAlchemyDownloadJobRepository,
SqlAlchemyHistoryRepository, SqlAlchemyHistoryRepository,
SqlAlchemyLikeRepository, SqlAlchemyLikeRepository,
SqlAlchemyPlaylistRepository, SqlAlchemyPlaylistRepository,
@@ -34,7 +39,12 @@ from app.infrastructure.db.repositories import (
SqlAlchemyTrackRepository, SqlAlchemyTrackRepository,
SqlAlchemyUserRepository, SqlAlchemyUserRepository,
) )
from app.infrastructure.metadata.acoustid import AcoustIdHttpClient
from app.infrastructure.metadata.fingerprint import FpcalcFingerprinter
from app.infrastructure.metadata.tags import MutagenTagReader
from app.infrastructure.sources.registry import SourceRegistry, build_source_registry
from app.infrastructure.storage.provider import get_file_storage from app.infrastructure.storage.provider import get_file_storage
from app.workers.queue import enqueue_download, enqueue_enrich, enqueue_materialize
async def get_session() -> AsyncIterator[AsyncSession]: async def get_session() -> AsyncIterator[AsyncSession]:
@@ -64,6 +74,19 @@ def get_token_service() -> TokenService:
return JwtTokenService(get_settings()) return JwtTokenService(get_settings())
@lru_cache
def get_subsonic_cipher() -> SubsonicCipher:
return SubsonicPasswordCipher(get_settings().subsonic_secret_key.get_secret_value())
@lru_cache
def get_source_registry() -> SourceRegistry:
return build_source_registry(get_settings())
SourceRegistryDep = Annotated[SourceRegistry, Depends(get_source_registry)]
# -- request-scoped services --------------------------------------------------- # -- request-scoped services ---------------------------------------------------
def get_auth_service(session: SessionDep) -> AuthService: def get_auth_service(session: SessionDep) -> AuthService:
return AuthService( return AuthService(
@@ -82,8 +105,16 @@ def get_user_service(session: SessionDep) -> UserService:
) )
def get_subsonic_auth_service(session: SessionDep) -> SubsonicAuthService:
return SubsonicAuthService(
users=SqlAlchemyUserRepository(session),
cipher=get_subsonic_cipher(),
)
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
UserServiceDep = Annotated[UserService, Depends(get_user_service)] UserServiceDep = Annotated[UserService, Depends(get_user_service)]
SubsonicAuthServiceDep = Annotated[SubsonicAuthService, Depends(get_subsonic_auth_service)]
# -- file storage (process-cached) --------------------------------------------- # -- file storage (process-cached) ---------------------------------------------
@@ -97,6 +128,7 @@ def get_upload_service(session: SessionDep, storage: FileStorageDep) -> UploadSe
artists=SqlAlchemyArtistRepository(session), artists=SqlAlchemyArtistRepository(session),
storage=storage, storage=storage,
tmp_dir=settings.upload_tmp_dir, tmp_dir=settings.upload_tmp_dir,
enqueue_enrich=enqueue_enrich,
) )
@@ -107,8 +139,54 @@ def get_streaming_service(session: SessionDep, storage: FileStorageDep) -> Strea
) )
def get_metadata_service(session: SessionDep, storage: FileStorageDep) -> MetadataEnrichmentService:
"""Wires the §6.2 fingerprint/AcoustID adapters for read-only, inline use
(the metadata editor's "find matches" — §A7). The full pipeline (incl.
cover art) stays in the worker (`tasks/enrich_task.py`)."""
settings = get_settings()
api_key = settings.acoustid_api_key.get_secret_value() if settings.acoustid_api_key else None
acoustid = AcoustIdHttpClient(
api_key=api_key,
user_agent=settings.musicbrainz_user_agent,
api_url=settings.acoustid_api_url,
)
return MetadataEnrichmentService(
tracks=SqlAlchemyTrackRepository(session),
artists=SqlAlchemyArtistRepository(session),
albums=SqlAlchemyAlbumRepository(session),
storage=storage,
tag_reader=MutagenTagReader(),
fingerprinter=FpcalcFingerprinter(settings.fpcalc_path),
acoustid=acoustid,
acoustid_trust_score=settings.acoustid_trust_score,
)
def get_download_service(session: SessionDep, storage: FileStorageDep) -> DownloadService:
return DownloadService(
jobs=SqlAlchemyDownloadJobRepository(session),
tracks=SqlAlchemyTrackRepository(session),
artists=SqlAlchemyArtistRepository(session),
storage=storage,
enqueue_download=enqueue_download,
enqueue_enrich=enqueue_enrich,
)
def get_remote_library_service(session: SessionDep) -> RemoteLibraryService:
return RemoteLibraryService(
tracks=SqlAlchemyTrackRepository(session),
artists=SqlAlchemyArtistRepository(session),
jobs=SqlAlchemyDownloadJobRepository(session),
enqueue_materialize=enqueue_materialize,
)
UploadServiceDep = Annotated[UploadService, Depends(get_upload_service)] UploadServiceDep = Annotated[UploadService, Depends(get_upload_service)]
StreamingServiceDep = Annotated[StreamingService, Depends(get_streaming_service)] StreamingServiceDep = Annotated[StreamingService, Depends(get_streaming_service)]
MetadataServiceDep = Annotated[MetadataEnrichmentService, Depends(get_metadata_service)]
DownloadServiceDep = Annotated[DownloadService, Depends(get_download_service)]
RemoteLibraryServiceDep = Annotated[RemoteLibraryService, Depends(get_remote_library_service)]
# -- library repository deps --------------------------------------------------- # -- library repository deps ---------------------------------------------------
@@ -187,3 +265,29 @@ async def get_streaming_user(
StreamUser = Annotated[User, Depends(get_streaming_user)] StreamUser = Annotated[User, Depends(get_streaming_user)]
# -- subsonic (/rest) authentication -------------------------------------------
# Subsonic puts credentials in the query string: u + (t & s) | p, plus c/v/f.
# The dep extracts them and delegates verification to the service; domain errors
# propagate to the rest-aware exception handler, which renders the Subsonic
# error envelope (HTTP 200). HTTPS is mandatory — the secret rides in the URL.
async def get_subsonic_user(
service: SubsonicAuthServiceDep,
u: Annotated[str | None, Query()] = None,
t: Annotated[str | None, Query()] = None,
s: Annotated[str | None, Query()] = None,
p: Annotated[str | None, Query()] = None,
) -> User:
return await service.authenticate(username=u, token=t, salt=s, password=p)
SubsonicUser = Annotated[User, Depends(get_subsonic_user)]
async def get_subsonic_format(f: Annotated[str | None, Query()] = None) -> str | None:
"""The requested response format (``f``): ``xml`` (default) or ``json``."""
return f
SubsonicFormat = Annotated[str | None, Depends(get_subsonic_format)]
+33 -4
View File
@@ -1,8 +1,15 @@
"""Maps domain exceptions to HTTP responses. The only place that knows both.""" """Maps domain exceptions to HTTP responses. The only place that knows both.
from fastapi import FastAPI, Request, status Two surfaces share this mapping: the native ``/api/v1`` API answers with a JSON
error body and an HTTP status code, while the Subsonic ``/rest`` layer answers
with its own envelope and **always HTTP 200** (the status lives in the body). A
request is routed to the Subsonic renderer by path prefix.
"""
from fastapi import FastAPI, Request, Response, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.api.rest.envelope import subsonic_error
from app.core.logging import get_logger from app.core.logging import get_logger
from app.domain.errors import ( from app.domain.errors import (
AlreadyExistsError, AlreadyExistsError,
@@ -30,6 +37,21 @@ _STATUS_BY_ERROR: dict[type[DomainError], int] = {
StorageError: status.HTTP_500_INTERNAL_SERVER_ERROR, StorageError: status.HTTP_500_INTERNAL_SERVER_ERROR,
} }
# Subsonic error codes (subsonic.org/restapi): 10 missing param, 40 wrong
# credentials, 50 not authorized, 70 not found, 0 generic.
_SUBSONIC_CODE_BY_ERROR: dict[type[DomainError], int] = {
ValidationError: 10,
AuthenticationError: 40,
PermissionDeniedError: 50,
NotFoundError: 70,
}
_SUBSONIC_PREFIX = "/rest"
def _is_subsonic(request: Request) -> bool:
return request.url.path.startswith(_SUBSONIC_PREFIX)
def _error_body(code: str, message: str) -> dict[str, dict[str, str]]: def _error_body(code: str, message: str) -> dict[str, dict[str, str]]:
return {"error": {"code": code, "message": message}} return {"error": {"code": code, "message": message}}
@@ -45,7 +67,10 @@ def register_exception_handlers(app: FastAPI) -> None:
) )
@app.exception_handler(DomainError) @app.exception_handler(DomainError)
async def _handle_domain_error(_request: Request, exc: DomainError) -> JSONResponse: async def _handle_domain_error(request: Request, exc: DomainError) -> Response:
if _is_subsonic(request):
code = _SUBSONIC_CODE_BY_ERROR.get(type(exc), 0)
return subsonic_error(code, exc.message, fmt=request.query_params.get("f"))
http_status = _STATUS_BY_ERROR.get(type(exc), status.HTTP_400_BAD_REQUEST) http_status = _STATUS_BY_ERROR.get(type(exc), status.HTTP_400_BAD_REQUEST)
return JSONResponse( return JSONResponse(
status_code=http_status, status_code=http_status,
@@ -53,8 +78,12 @@ def register_exception_handlers(app: FastAPI) -> None:
) )
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def _handle_unexpected(_request: Request, exc: Exception) -> JSONResponse: async def _handle_unexpected(request: Request, exc: Exception) -> Response:
log.error("unhandled_exception", exc_info=exc) log.error("unhandled_exception", exc_info=exc)
if _is_subsonic(request):
return subsonic_error(
0, "An unexpected error occurred.", fmt=request.query_params.get("f")
)
return JSONResponse( return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=_error_body("internal_error", "An unexpected error occurred."), content=_error_body("internal_error", "An unexpected error occurred."),
+89 -11
View File
@@ -1,23 +1,101 @@
"""Subsonic annotation endpoints: star, rating, scrobble.""" """Subsonic annotation endpoints: star/unstar, rating, scrobble.
from typing import Any * ``star``/``unstar`` map to the **append-only** like event-log (a new event per
call — never a mutated boolean; CLAUDE.md invariant). Album/artist stars are
accepted but not persisted (no album/artist likes yet).
* ``scrobble`` appends to play history.
* ``setRating`` has no backing store yet — it's accepted as a clean no-op.
"""
from fastapi import APIRouter import datetime as dt
from typing import Annotated
from fastapi import APIRouter, Query, Response
from app.api.deps import HistoryRepoDep, LikeRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
from app.api.rest.envelope import subsonic_response
from app.api.rest.ids import decode_track
from app.domain.errors import NotFoundError
router = APIRouter() router = APIRouter()
@router.get("/star") @router.api_route("/star", methods=["GET", "POST"])
async def star() -> Any: ... @router.api_route("/star.view", methods=["GET", "POST"])
async def star(
user: SubsonicUser,
fmt: SubsonicFormat,
like_repo: LikeRepoDep,
track_repo: TrackRepoDep,
id: Annotated[list[str] | None, Query()] = None,
albumId: Annotated[list[str] | None, Query()] = None,
artistId: Annotated[list[str] | None, Query()] = None,
) -> Response:
# albumId/artistId are accepted for client compatibility but not persisted.
for raw in id or []:
track_id = decode_track(raw)
if await track_repo.get_by_id(track_id) is None:
raise NotFoundError("Song not found.")
await like_repo.add(user_id=user.id, track_id=track_id, value="like")
return subsonic_response(fmt=fmt)
@router.get("/unstar") @router.api_route("/unstar", methods=["GET", "POST"])
async def unstar() -> Any: ... @router.api_route("/unstar.view", methods=["GET", "POST"])
async def unstar(
user: SubsonicUser,
fmt: SubsonicFormat,
like_repo: LikeRepoDep,
track_repo: TrackRepoDep,
id: Annotated[list[str] | None, Query()] = None,
albumId: Annotated[list[str] | None, Query()] = None,
artistId: Annotated[list[str] | None, Query()] = None,
) -> Response:
for raw in id or []:
track_id = decode_track(raw)
if await track_repo.get_by_id(track_id) is None:
raise NotFoundError("Song not found.")
await like_repo.add(user_id=user.id, track_id=track_id, value="neutral")
return subsonic_response(fmt=fmt)
@router.get("/setRating") @router.api_route("/setRating", methods=["GET", "POST"])
async def set_rating() -> Any: ... @router.api_route("/setRating.view", methods=["GET", "POST"])
async def set_rating(
_user: SubsonicUser,
fmt: SubsonicFormat,
id: Annotated[str, Query()],
rating: Annotated[int, Query(ge=0, le=5)],
) -> Response:
# No rating store yet — accept cleanly so clients don't error.
return subsonic_response(fmt=fmt)
@router.get("/scrobble") @router.api_route("/scrobble", methods=["GET", "POST"])
async def scrobble() -> Any: ... @router.api_route("/scrobble.view", methods=["GET", "POST"])
async def scrobble(
user: SubsonicUser,
fmt: SubsonicFormat,
history_repo: HistoryRepoDep,
track_repo: TrackRepoDep,
id: Annotated[list[str] | None, Query()] = None,
time: Annotated[list[int] | None, Query()] = None,
submission: Annotated[bool, Query()] = True,
) -> Response:
times = time or []
for index, raw in enumerate(id or []):
track_id = decode_track(raw)
if await track_repo.get_by_id(track_id) is None:
raise NotFoundError("Song not found.")
if index < len(times):
played_at = dt.datetime.fromtimestamp(times[index] / 1000, tz=dt.UTC)
else:
played_at = dt.datetime.now(dt.UTC)
await history_repo.add(
user_id=user.id,
track_id=track_id,
played_at=played_at,
play_duration_seconds=None,
completed=submission,
)
return subsonic_response(fmt=fmt)
+254 -24
View File
@@ -1,47 +1,277 @@
"""Subsonic browsing endpoints.""" """Subsonic browsing endpoints — thin adapters over the library repositories.
from typing import Any A single synthetic music folder (id ``0``) is exposed; this is a homelab, not a
multi-library server. Heavy lifting stays in the repositories; these handlers
only fan out queries and reshape rows into the Subsonic element dicts.
"""
from fastapi import APIRouter from typing import Annotated, Any
from fastapi import APIRouter, Query, Response
from app.api.deps import AlbumRepoDep, ArtistRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
from app.api.rest.envelope import subsonic_response
from app.api.rest.ids import IdKind, encode_album, encode_artist, parse
from app.api.rest.serializers import album_dict, artist_dict, iso, song_dict
from app.domain.entities import Album, Artist
from app.domain.errors import NotFoundError
router = APIRouter() router = APIRouter()
_IGNORED_ARTICLES = "The El La Los Las Le Les"
@router.get("/getMusicFolders") _MAX_ARTISTS = 10_000 # homelab scale; one pass is fine
async def get_music_folders() -> Any: ...
@router.get("/getIndexes") async def _artists_index(artist_repo: ArtistRepoDep) -> list[dict[str, Any]]:
async def get_indexes() -> Any: ... """Group artists into Subsonic A-Z index buckets, each with an album count."""
artists = await artist_repo.list(q=None, limit=_MAX_ARTISTS, offset=0)
buckets: dict[str, list[dict[str, Any]]] = {}
for artist in artists:
album_count = await artist_repo.album_count(artist.id)
letter = artist.name[:1].upper()
if not letter.isalpha():
letter = "#"
buckets.setdefault(letter, []).append(artist_dict(artist, album_count=album_count))
return [{"name": name, "artist": buckets[name]} for name in sorted(buckets)]
@router.get("/getMusicDirectory") async def _albums_for_artist(artist: Artist, album_repo: AlbumRepoDep) -> list[dict[str, Any]]:
async def get_music_directory() -> Any: ... albums = await album_repo.list(artist_id=artist.id, q=None, limit=500, offset=0)
counts = await album_repo.track_count_many([a.id for a in albums])
return [album_dict(a, artist, song_count=counts.get(a.id, 0)) for a in albums]
@router.get("/getArtists") @router.api_route("/getMusicFolders", methods=["GET", "POST"])
async def get_artists() -> Any: ... @router.api_route("/getMusicFolders.view", methods=["GET", "POST"])
async def get_music_folders(_user: SubsonicUser, fmt: SubsonicFormat) -> Response:
return subsonic_response(
{"musicFolders": {"musicFolder": [{"id": 0, "name": "Music"}]}}, fmt=fmt
)
@router.get("/getArtist") @router.api_route("/getIndexes", methods=["GET", "POST"])
async def get_artist() -> Any: ... @router.api_route("/getIndexes.view", methods=["GET", "POST"])
async def get_indexes(
_user: SubsonicUser, fmt: SubsonicFormat, artist_repo: ArtistRepoDep
) -> Response:
index = await _artists_index(artist_repo)
return subsonic_response(
{"indexes": {"ignoredArticles": _IGNORED_ARTICLES, "lastModified": 0, "index": index}},
fmt=fmt,
)
@router.get("/getAlbum") @router.api_route("/getArtists", methods=["GET", "POST"])
async def get_album() -> Any: ... @router.api_route("/getArtists.view", methods=["GET", "POST"])
async def get_artists(
_user: SubsonicUser, fmt: SubsonicFormat, artist_repo: ArtistRepoDep
) -> Response:
index = await _artists_index(artist_repo)
return subsonic_response(
{"artists": {"ignoredArticles": _IGNORED_ARTICLES, "index": index}}, fmt=fmt
)
@router.get("/getAlbumList") @router.api_route("/getArtist", methods=["GET", "POST"])
async def get_album_list() -> Any: ... @router.api_route("/getArtist.view", methods=["GET", "POST"])
async def get_artist(
_user: SubsonicUser,
fmt: SubsonicFormat,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
id: Annotated[str, Query()],
) -> Response:
_, artist_id = parse(id)
artist = await artist_repo.get_by_id(artist_id)
if artist is None:
raise NotFoundError("Artist not found.")
albums = await _albums_for_artist(artist, album_repo)
payload = {
**artist_dict(artist, album_count=len(albums)),
"album": albums,
}
return subsonic_response({"artist": payload}, fmt=fmt)
@router.get("/getAlbumList2") @router.api_route("/getAlbum", methods=["GET", "POST"])
async def get_album_list2() -> Any: ... @router.api_route("/getAlbum.view", methods=["GET", "POST"])
async def get_album(
_user: SubsonicUser,
fmt: SubsonicFormat,
album_repo: AlbumRepoDep,
artist_repo: ArtistRepoDep,
track_repo: TrackRepoDep,
id: Annotated[str, Query()],
) -> Response:
_, album_id = parse(id)
album = await album_repo.get_by_id(album_id)
if album is None:
raise NotFoundError("Album not found.")
artist = await artist_repo.get_by_id(album.artist_id)
tracks = await track_repo.list(
artist_id=None,
album_id=album_id,
q=None,
sort_by="title",
order="asc",
limit=500,
offset=0,
)
duration = sum(t.duration_seconds or 0 for t in tracks)
songs = [song_dict(t, artist, album) for t in tracks]
payload = {
**album_dict(album, artist, song_count=len(songs), duration=duration),
"song": songs,
}
return subsonic_response({"album": payload}, fmt=fmt)
@router.get("/getSong") @router.api_route("/getAlbumList", methods=["GET", "POST"])
async def get_song() -> Any: ... @router.api_route("/getAlbumList.view", methods=["GET", "POST"])
async def get_album_list(
_user: SubsonicUser,
fmt: SubsonicFormat,
album_repo: AlbumRepoDep,
artist_repo: ArtistRepoDep,
type: Annotated[str, Query()] = "newest",
size: Annotated[int, Query(ge=1, le=500)] = 10,
offset: Annotated[int, Query(ge=0)] = 0,
) -> Response:
albums = await _list_albums(album_repo, artist_repo, type, size, offset)
return subsonic_response({"albumList": {"album": albums}}, fmt=fmt)
@router.get("/getGenres") @router.api_route("/getAlbumList2", methods=["GET", "POST"])
async def get_genres() -> Any: ... @router.api_route("/getAlbumList2.view", methods=["GET", "POST"])
async def get_album_list2(
_user: SubsonicUser,
fmt: SubsonicFormat,
album_repo: AlbumRepoDep,
artist_repo: ArtistRepoDep,
type: Annotated[str, Query()] = "newest",
size: Annotated[int, Query(ge=1, le=500)] = 10,
offset: Annotated[int, Query(ge=0)] = 0,
) -> Response:
albums = await _list_albums(album_repo, artist_repo, type, size, offset)
return subsonic_response({"albumList2": {"album": albums}}, fmt=fmt)
async def _list_albums(
album_repo: AlbumRepoDep,
artist_repo: ArtistRepoDep,
type_: str,
size: int,
offset: int,
) -> list[dict[str, Any]]:
if type_ == "alphabeticalByName":
sort_by, order = "title", "asc"
elif type_ == "random":
sort_by, order = "title", "random"
else: # newest / recent / frequent → newest (no play stats yet)
sort_by, order = "created", "desc"
albums = await album_repo.list(
artist_id=None, q=None, limit=size, offset=offset, sort_by=sort_by, order=order
)
return await _decorate_albums(albums, album_repo, artist_repo)
async def _decorate_albums(
albums: list[Album], album_repo: AlbumRepoDep, artist_repo: ArtistRepoDep
) -> list[dict[str, Any]]:
artist_ids = list({a.artist_id for a in albums})
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
counts = await album_repo.track_count_many([a.id for a in albums])
return [album_dict(a, artists.get(a.artist_id), song_count=counts.get(a.id, 0)) for a in albums]
@router.api_route("/getSong", methods=["GET", "POST"])
@router.api_route("/getSong.view", methods=["GET", "POST"])
async def get_song(
_user: SubsonicUser,
fmt: SubsonicFormat,
track_repo: TrackRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
id: Annotated[str, Query()],
) -> Response:
_, track_id = parse(id)
track = await track_repo.get_by_id(track_id)
if track is None:
raise NotFoundError("Song not found.")
artist = await artist_repo.get_by_id(track.artist_id)
album = await album_repo.get_by_id(track.album_id) if track.album_id else None
return subsonic_response({"song": song_dict(track, artist, album)}, fmt=fmt)
@router.api_route("/getGenres", methods=["GET", "POST"])
@router.api_route("/getGenres.view", methods=["GET", "POST"])
async def get_genres(
_user: SubsonicUser, fmt: SubsonicFormat, track_repo: TrackRepoDep
) -> Response:
genres = [
{"value": name, "songCount": count, "albumCount": 0}
for name, count in await track_repo.genres()
]
return subsonic_response({"genres": {"genre": genres}}, fmt=fmt)
@router.api_route("/getMusicDirectory", methods=["GET", "POST"])
@router.api_route("/getMusicDirectory.view", methods=["GET", "POST"])
async def get_music_directory(
_user: SubsonicUser,
fmt: SubsonicFormat,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
track_repo: TrackRepoDep,
id: Annotated[str, Query()],
) -> Response:
kind, entity_id = parse(id)
if kind is IdKind.ARTIST:
artist = await artist_repo.get_by_id(entity_id)
if artist is None:
raise NotFoundError("Artist not found.")
albums = await album_repo.list(artist_id=artist.id, q=None, limit=500, offset=0)
counts = await album_repo.track_count_many([a.id for a in albums])
children = [
{
"id": encode_album(a.id),
"parent": encode_artist(artist.id),
"isDir": True,
"title": a.title,
"name": a.title,
"artist": artist.name,
"artistId": encode_artist(artist.id),
"coverArt": encode_album(a.id),
"songCount": counts.get(a.id, 0),
"created": iso(a.created_at),
"year": a.year,
}
for a in albums
]
directory = {"id": id, "name": artist.name, "child": children}
return subsonic_response({"directory": directory}, fmt=fmt)
if kind is IdKind.ALBUM:
album = await album_repo.get_by_id(entity_id)
if album is None:
raise NotFoundError("Album not found.")
artist = await artist_repo.get_by_id(album.artist_id)
tracks = await track_repo.list(
artist_id=None,
album_id=album.id,
q=None,
sort_by="title",
order="asc",
limit=500,
offset=0,
)
children = [song_dict(t, artist, album) for t in tracks]
directory = {
"id": id,
"parent": encode_artist(album.artist_id),
"name": album.title,
"child": children,
}
return subsonic_response({"directory": directory}, fmt=fmt)
raise NotFoundError("Directory not found.")
+102
View File
@@ -0,0 +1,102 @@
"""The Subsonic response envelope — one serializer, two wire formats.
Every Subsonic endpoint answers with a ``<subsonic-response>`` wrapper carrying
``status`` / ``version`` / ``type`` / ``serverVersion``, in XML (default) or JSON
(``f=json``). All handlers return through :func:`subsonic_response`; errors go
through the rest-aware exception handler (see ``app.api.errors``).
Payload data model (shared by both formats):
* a scalar value → an XML attribute / a JSON field
* a nested dict → a single child element / nested object
* a list of dicts → repeated child elements / a JSON array
* the key ``"value"`` → element text content (used by e.g. lyrics)
``None`` values are dropped. Subsonic always replies with **HTTP 200**, even for
errors — the status lives inside the envelope — so clients parse the body.
"""
import json
from collections.abc import Mapping
from typing import Any
from xml.etree import ElementTree as ET
from fastapi import Response
SUBSONIC_API_VERSION = "1.16.1"
SERVER_TYPE = "mcma"
SERVER_VERSION = "0.1.0"
_XML_NS = "http://subsonic.org/restapi"
_XML_MEDIA_TYPE = "application/xml; charset=utf-8"
_JSON_MEDIA_TYPE = "application/json; charset=utf-8"
def _is_json(fmt: str | None) -> bool:
return fmt in ("json", "jsonp")
def _scalar(value: object) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _build_xml(parent: ET.Element, data: Mapping[str, Any]) -> None:
for key, value in data.items():
if value is None:
continue
if key == "value":
parent.text = _scalar(value)
elif isinstance(value, Mapping):
_build_xml(ET.SubElement(parent, key), value)
elif isinstance(value, list):
for item in value:
_build_xml(ET.SubElement(parent, key), item)
else:
parent.set(key, _scalar(value))
def _strip_none(value: Any) -> Any:
"""Recursively drop ``None`` values so JSON output matches XML (no empty attrs)."""
if isinstance(value, Mapping):
return {k: _strip_none(v) for k, v in value.items() if v is not None}
if isinstance(value, list):
return [_strip_none(v) for v in value]
return value
def _render(body: Mapping[str, Any], fmt: str | None) -> Response:
envelope: dict[str, Any] = {
"status": body["status"],
"version": SUBSONIC_API_VERSION,
"type": SERVER_TYPE,
"serverVersion": SERVER_VERSION,
"openSubsonic": True,
**{k: v for k, v in body.items() if k != "status"},
}
if _is_json(fmt):
payload = json.dumps({"subsonic-response": _strip_none(envelope)})
return Response(content=payload, media_type=_JSON_MEDIA_TYPE)
root = ET.Element("subsonic-response", {"xmlns": _XML_NS})
_build_xml(root, envelope)
xml = b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="utf-8")
return Response(content=xml, media_type=_XML_MEDIA_TYPE)
def subsonic_response(
payload: Mapping[str, Any] | None = None, *, fmt: str | None = None
) -> Response:
"""A successful ``status="ok"`` envelope wrapping ``payload``."""
body: dict[str, Any] = {"status": "ok"}
if payload:
body.update(payload)
return _render(body, fmt)
def subsonic_error(code: int, message: str, *, fmt: str | None = None) -> Response:
"""A ``status="failed"`` envelope carrying a Subsonic ``<error>``."""
body = {"status": "failed", "error": {"code": code, "message": message}}
return _render(body, fmt)
+75
View File
@@ -0,0 +1,75 @@
"""Stable, reversible mapping between Subsonic opaque string ids and our UUIDs.
Subsonic ids are opaque strings; ours are UUIDs. We use a type-prefixed,
human-debuggable convention (``tr-<uuid>`` track, ``al-<uuid>`` album,
``ar-<uuid>`` artist, ``pl-<uuid>`` playlist). Cover-art ids reuse the entity's
own id (an album cover is ``al-<uuid>``, a track cover ``tr-<uuid>``). Centralize
encode/decode here so the convention lives in exactly one place.
"""
import uuid
from enum import StrEnum
from app.domain.errors import NotFoundError
class IdKind(StrEnum):
TRACK = "tr"
ALBUM = "al"
ARTIST = "ar"
PLAYLIST = "pl"
def encode(kind: IdKind, value: uuid.UUID) -> str:
return f"{kind.value}-{value}"
def encode_track(value: uuid.UUID) -> str:
return encode(IdKind.TRACK, value)
def encode_album(value: uuid.UUID) -> str:
return encode(IdKind.ALBUM, value)
def encode_artist(value: uuid.UUID) -> str:
return encode(IdKind.ARTIST, value)
def encode_playlist(value: uuid.UUID) -> str:
return encode(IdKind.PLAYLIST, value)
def parse(raw: str) -> tuple[IdKind, uuid.UUID]:
"""Decode any prefixed id into its kind + UUID. Raises ``NotFoundError`` on a
malformed id (an unknown id is, from the client's view, simply not found)."""
prefix, _, rest = raw.partition("-")
try:
kind = IdKind(prefix)
value = uuid.UUID(rest)
except ValueError as exc:
raise NotFoundError(f"Unknown id {raw!r}.") from exc
return kind, value
def _decode_as(raw: str, expected: IdKind) -> uuid.UUID:
kind, value = parse(raw)
if kind is not expected:
raise NotFoundError(f"Expected a {expected.name.lower()} id, got {raw!r}.")
return value
def decode_track(raw: str) -> uuid.UUID:
return _decode_as(raw, IdKind.TRACK)
def decode_album(raw: str) -> uuid.UUID:
return _decode_as(raw, IdKind.ALBUM)
def decode_artist(raw: str) -> uuid.UUID:
return _decode_as(raw, IdKind.ARTIST)
def decode_playlist(raw: str) -> uuid.UUID:
return _decode_as(raw, IdKind.PLAYLIST)
+95 -10
View File
@@ -1,19 +1,104 @@
"""Subsonic media endpoints: stream, download, cover art.""" """Subsonic media endpoints: stream, download, cover art.
from typing import Any ``stream`` and ``download`` reuse :class:`StreamingService` (honouring HTTP
Range) — they return raw bytes, not the Subsonic envelope. Transcoding params
(``maxBitRate``/``format``) are accepted but ignored; the original file is served
(no in-request ffmpeg — CLAUDE.md). ``getCoverArt`` returns a placeholder until
the cover pipeline lands (the ``/api/v1`` cover endpoints are still stubs).
"""
from fastapi import APIRouter import base64
from typing import Annotated
from fastapi import APIRouter, Header, Query
from fastapi.responses import Response, StreamingResponse
from app.api.covers import resolve_album_for_track, stream_cover
from app.api.deps import (
AlbumRepoDep,
FileStorageDep,
StreamingServiceDep,
SubsonicUser,
TrackRepoDep,
)
from app.api.rest.ids import IdKind, decode_track, parse
from app.domain.entities.album import Album
from app.domain.errors import NotFoundError, StorageError
router = APIRouter() router = APIRouter()
# 1x1 transparent PNG - a graceful placeholder until cover art is wired up.
@router.get("/stream") _PLACEHOLDER_PNG = base64.b64decode(
async def stream() -> Any: ... "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
)
@router.get("/download") @router.api_route("/stream", methods=["GET", "POST"])
async def download() -> Any: ... @router.api_route("/stream.view", methods=["GET", "POST"])
async def stream(
_user: SubsonicUser,
service: StreamingServiceDep,
id: Annotated[str, Query()],
range_header: Annotated[str | None, Header(alias="Range")] = None,
) -> StreamingResponse:
result = await service.open_stream(decode_track(id), range_header)
headers = {"Accept-Ranges": "bytes", "Content-Length": str(result.content_length)}
status_code = 200
if result.is_partial:
headers["Content-Range"] = f"bytes {result.start}-{result.end}/{result.total_size}"
status_code = 206
return StreamingResponse(
result.stream, status_code=status_code, headers=headers, media_type=result.content_type
)
@router.get("/getCoverArt") @router.api_route("/download", methods=["GET", "POST"])
async def get_cover_art() -> Any: ... @router.api_route("/download.view", methods=["GET", "POST"])
async def download(
_user: SubsonicUser,
service: StreamingServiceDep,
track_repo: TrackRepoDep,
id: Annotated[str, Query()],
) -> StreamingResponse:
track_id = decode_track(id)
track = await track_repo.get_by_id(track_id)
if track is None:
raise NotFoundError("Song not found.")
result = await service.open_stream(track_id, None)
filename = f"{track.title}.{track.file_format or 'bin'}"
headers = {
"Content-Length": str(result.content_length),
"Content-Disposition": f'attachment; filename="{filename}"',
}
return StreamingResponse(result.stream, headers=headers, media_type=result.content_type)
@router.api_route("/getCoverArt", methods=["GET", "POST"])
@router.api_route("/getCoverArt.view", methods=["GET", "POST"])
async def get_cover_art(
_user: SubsonicUser,
album_repo: AlbumRepoDep,
track_repo: TrackRepoDep,
storage: FileStorageDep,
id: Annotated[str, Query()],
size: Annotated[int | None, Query()] = None,
) -> Response:
# Cover ids reuse the entity id: ``al-<uuid>`` (album) or ``tr-<uuid>``
# (track → its album). Unlike the native API, Subsonic clients expect an
# image either way, so a missing cover falls back to a placeholder rather
# than 404. ``size`` is accepted but ignored (we serve the stored image).
kind, value = parse(id)
album: Album | None
if kind is IdKind.ALBUM:
album = await album_repo.get_by_id(value)
elif kind is IdKind.TRACK:
album = await resolve_album_for_track(track_repo, album_repo, value)
else:
album = None
if album is not None and album.cover_path:
try:
return await stream_cover(storage, album.cover_path)
except NotFoundError, StorageError:
pass
return Response(content=_PLACEHOLDER_PNG, media_type="image/png")
+168 -13
View File
@@ -1,27 +1,182 @@
"""Subsonic playlist endpoints.""" """Subsonic playlist endpoints — adapters over the playlist repository.
from typing import Any Playlists are private to their owner (no public-playlist concept yet), so every
read/write is scoped to the authenticated user. ``createPlaylist`` doubles as a
full replace when given a ``playlistId`` (Subsonic overloads it that way).
"""
from fastapi import APIRouter from typing import Annotated, Any
from fastapi import APIRouter, Query, Response
from app.api.deps import (
AlbumRepoDep,
ArtistRepoDep,
PlaylistRepoDep,
SubsonicFormat,
SubsonicUser,
)
from app.api.rest.envelope import subsonic_response
from app.api.rest.ids import decode_playlist, decode_track, encode_playlist
from app.api.rest.serializers import iso, song_dict
from app.domain.entities import Playlist, User
from app.domain.errors import NotFoundError, PermissionDeniedError
router = APIRouter() router = APIRouter()
@router.get("/getPlaylists") def _playlist_dict(playlist: Playlist, owner: str, *, song_count: int) -> dict[str, Any]:
async def get_playlists() -> Any: ... return {
"id": encode_playlist(playlist.id),
"name": playlist.name,
"comment": playlist.description,
"owner": owner,
"public": False,
"songCount": song_count,
"created": iso(playlist.created_at),
"changed": iso(playlist.updated_at),
}
@router.get("/getPlaylist") async def _owned_playlist(
async def get_playlist() -> Any: ... playlist_id_raw: str, playlist_repo: PlaylistRepoDep, user: User
) -> Playlist:
playlist = await playlist_repo.get_by_id(decode_playlist(playlist_id_raw))
if playlist is None:
raise NotFoundError("Playlist not found.")
if playlist.owner_id != user.id:
raise PermissionDeniedError("You don't own this playlist.")
return playlist
@router.get("/createPlaylist") async def _playlist_songs(
async def create_playlist() -> Any: ... playlist_id: str,
playlist_repo: PlaylistRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
) -> list[dict[str, Any]]:
tracks = await playlist_repo.get_tracks(decode_playlist(playlist_id), limit=10_000, offset=0)
artist_map = {a.id: a for a in await artist_repo.get_many(list({t.artist_id for t in tracks}))}
album_map = {
a.id: a
for a in await album_repo.get_many(
list({t.album_id for t in tracks if t.album_id is not None})
)
}
return [
song_dict(
t,
artist_map.get(t.artist_id),
album_map.get(t.album_id) if t.album_id is not None else None,
)
for t in tracks
]
@router.get("/updatePlaylist") @router.api_route("/getPlaylists", methods=["GET", "POST"])
async def update_playlist() -> Any: ... @router.api_route("/getPlaylists.view", methods=["GET", "POST"])
async def get_playlists(
user: SubsonicUser, fmt: SubsonicFormat, playlist_repo: PlaylistRepoDep
) -> Response:
playlists = await playlist_repo.list(owner_id=user.id, limit=500, offset=0)
counts = await playlist_repo.track_count_many([p.id for p in playlists])
items = [_playlist_dict(p, user.username, song_count=counts.get(p.id, 0)) for p in playlists]
return subsonic_response({"playlists": {"playlist": items}}, fmt=fmt)
@router.get("/deletePlaylist") @router.api_route("/getPlaylist", methods=["GET", "POST"])
async def delete_playlist() -> Any: ... @router.api_route("/getPlaylist.view", methods=["GET", "POST"])
async def get_playlist(
user: SubsonicUser,
fmt: SubsonicFormat,
playlist_repo: PlaylistRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
id: Annotated[str, Query()],
) -> Response:
playlist = await _owned_playlist(id, playlist_repo, user)
songs = await _playlist_songs(id, playlist_repo, artist_repo, album_repo)
payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs}
return subsonic_response({"playlist": payload}, fmt=fmt)
@router.api_route("/createPlaylist", methods=["GET", "POST"])
@router.api_route("/createPlaylist.view", methods=["GET", "POST"])
async def create_playlist(
user: SubsonicUser,
fmt: SubsonicFormat,
playlist_repo: PlaylistRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
name: Annotated[str | None, Query()] = None,
playlistId: Annotated[str | None, Query()] = None,
songId: Annotated[list[str] | None, Query()] = None,
) -> Response:
song_ids = [decode_track(s) for s in (songId or [])]
if playlistId is not None:
# Overloaded form: replace the existing playlist's tracks (and name).
playlist = await _owned_playlist(playlistId, playlist_repo, user)
if name is not None:
playlist = await playlist_repo.update(playlist.id, name=name, description=None)
existing = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0)
for t in existing:
await playlist_repo.remove_track(playlist.id, t.id)
else:
playlist = await playlist_repo.add(
name=name or "Untitled", description=None, owner_id=user.id
)
for position, track_id in enumerate(song_ids, start=1):
await playlist_repo.add_track(playlist.id, track_id, position=float(position))
encoded = encode_playlist(playlist.id)
songs = await _playlist_songs(encoded, playlist_repo, artist_repo, album_repo)
payload = {**_playlist_dict(playlist, user.username, song_count=len(songs)), "entry": songs}
return subsonic_response({"playlist": payload}, fmt=fmt)
@router.api_route("/updatePlaylist", methods=["GET", "POST"])
@router.api_route("/updatePlaylist.view", methods=["GET", "POST"])
async def update_playlist(
user: SubsonicUser,
fmt: SubsonicFormat,
playlist_repo: PlaylistRepoDep,
playlistId: Annotated[str, Query()],
name: Annotated[str | None, Query()] = None,
comment: Annotated[str | None, Query()] = None,
songIdToAdd: Annotated[list[str] | None, Query()] = None,
songIndexToRemove: Annotated[list[int] | None, Query()] = None,
) -> Response:
playlist = await _owned_playlist(playlistId, playlist_repo, user)
if name is not None or comment is not None:
playlist = await playlist_repo.update(playlist.id, name=name, description=comment)
# Removals are by index into the current ordered track list — resolve first.
if songIndexToRemove:
current = await playlist_repo.get_tracks(playlist.id, limit=10_000, offset=0)
for index in sorted(set(songIndexToRemove)):
if 0 <= index < len(current):
await playlist_repo.remove_track(playlist.id, current[index].id)
if songIdToAdd:
position = await playlist_repo.max_position(playlist.id)
for raw in songIdToAdd:
position += 1.0
await playlist_repo.add_track(playlist.id, decode_track(raw), position=position)
return subsonic_response(fmt=fmt)
@router.api_route("/deletePlaylist", methods=["GET", "POST"])
@router.api_route("/deletePlaylist.view", methods=["GET", "POST"])
async def delete_playlist(
user: SubsonicUser,
fmt: SubsonicFormat,
playlist_repo: PlaylistRepoDep,
id: Annotated[str, Query()],
) -> Response:
playlist = await _owned_playlist(id, playlist_repo, user)
await playlist_repo.delete(playlist.id)
return subsonic_response(fmt=fmt)
+80 -5
View File
@@ -1,11 +1,86 @@
"""Subsonic search endpoints.""" """Subsonic search endpoints — search3 over the library repositories.
from typing import Any Mirrors the native ``/api/v1/search/library`` fan-out (tracks/albums/artists),
reshaped into the Subsonic ``searchResult3`` element. An empty query returns
results so clients can use search3 to browse.
"""
from fastapi import APIRouter from typing import Annotated, Any
from fastapi import APIRouter, Query, Response
from app.api.deps import AlbumRepoDep, ArtistRepoDep, SubsonicFormat, SubsonicUser, TrackRepoDep
from app.api.rest.envelope import subsonic_response
from app.api.rest.serializers import album_dict, artist_dict, song_dict
router = APIRouter() router = APIRouter()
@router.get("/search3") @router.api_route("/search3", methods=["GET", "POST"])
async def search3() -> Any: ... @router.api_route("/search3.view", methods=["GET", "POST"])
async def search3(
_user: SubsonicUser,
fmt: SubsonicFormat,
track_repo: TrackRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
query: Annotated[str, Query()] = "",
artistCount: Annotated[int, Query(ge=0, le=500)] = 20,
artistOffset: Annotated[int, Query(ge=0)] = 0,
albumCount: Annotated[int, Query(ge=0, le=500)] = 20,
albumOffset: Annotated[int, Query(ge=0)] = 0,
songCount: Annotated[int, Query(ge=0, le=500)] = 20,
songOffset: Annotated[int, Query(ge=0)] = 0,
) -> Response:
# Subsonic sends "" (and some clients '""') to mean "everything".
q: str | None = query.strip().strip('"') or None
artists_out: list[dict[str, Any]] = []
if artistCount:
artists = await artist_repo.list(q=q, limit=artistCount, offset=artistOffset)
for a in artists:
album_count = await artist_repo.album_count(a.id)
artists_out.append(artist_dict(a, album_count=album_count))
albums_out: list[dict[str, Any]] = []
if albumCount:
albums = await album_repo.list(artist_id=None, q=q, limit=albumCount, offset=albumOffset)
album_artist_ids = list({a.artist_id for a in albums})
album_artist_map = {a.id: a for a in await artist_repo.get_many(album_artist_ids)}
counts = await album_repo.track_count_many([a.id for a in albums])
albums_out = [
album_dict(a, album_artist_map.get(a.artist_id), song_count=counts.get(a.id, 0))
for a in albums
]
songs_out: list[dict[str, Any]] = []
if songCount:
tracks = await track_repo.list(
artist_id=None,
album_id=None,
q=q,
sort_by="title",
order="asc",
limit=songCount,
offset=songOffset,
)
song_artist_map = {
a.id: a for a in await artist_repo.get_many(list({t.artist_id for t in tracks}))
}
song_album_map = {
a.id: a
for a in await album_repo.get_many(
list({t.album_id for t in tracks if t.album_id is not None})
)
}
songs_out = [
song_dict(
t,
song_artist_map.get(t.artist_id),
song_album_map.get(t.album_id) if t.album_id is not None else None,
)
for t in tracks
]
payload = {"searchResult3": {"artist": artists_out, "album": albums_out, "song": songs_out}}
return subsonic_response(payload, fmt=fmt)
+92
View File
@@ -0,0 +1,92 @@
"""Entity → Subsonic child-dict mappers (presentation only).
Pure functions turning domain entities into the attribute dicts the envelope
serializer renders as ``<artist>`` / ``<album>`` / ``<song>`` elements (or their
JSON equivalents). No business logic — they only reshape and rename.
"""
import datetime as dt
from typing import Any
from app.api.rest.ids import encode_album, encode_artist, encode_track
from app.domain.entities import Album, Artist, Track
# Suffix → MIME, for the ``contentType``/``suffix`` song attributes. A
# presentation detail (mirrors StreamingService's content-type negotiation).
_CONTENT_TYPE: dict[str, str] = {
"mp3": "audio/mpeg",
"flac": "audio/flac",
"m4a": "audio/mp4",
"aac": "audio/aac",
"ogg": "audio/ogg",
"opus": "audio/ogg",
"wav": "audio/wav",
"aiff": "audio/aiff",
"aif": "audio/aiff",
}
def iso(value: dt.datetime) -> str:
return value.astimezone(dt.UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
def content_type_for(file_format: str) -> str:
return _CONTENT_TYPE.get(file_format.lower(), "application/octet-stream")
def artist_dict(artist: Artist, *, album_count: int) -> dict[str, Any]:
return {
"id": encode_artist(artist.id),
"name": artist.name,
"albumCount": album_count,
"coverArt": encode_artist(artist.id),
}
def album_dict(
album: Album,
artist: Artist | None,
*,
song_count: int,
duration: int | None = None,
) -> dict[str, Any]:
return {
"id": encode_album(album.id),
"name": album.title,
"title": album.title,
"artist": artist.name if artist is not None else None,
"artistId": encode_artist(album.artist_id),
"coverArt": encode_album(album.id),
"songCount": song_count,
"duration": duration,
"created": iso(album.created_at),
"year": album.year,
}
def song_dict(
track: Track,
artist: Artist | None,
album: Album | None,
) -> dict[str, Any]:
cover = encode_album(track.album_id) if track.album_id is not None else encode_track(track.id)
return {
"id": encode_track(track.id),
"parent": encode_album(track.album_id) if track.album_id is not None else None,
"isDir": False,
"title": track.title,
"album": album.title if album is not None else None,
"artist": artist.name if artist is not None else None,
"albumId": encode_album(track.album_id) if track.album_id is not None else None,
"artistId": encode_artist(track.artist_id),
"coverArt": cover,
"size": track.file_size or 0,
"contentType": content_type_for(track.file_format or ""),
"suffix": track.file_format,
"duration": track.duration_seconds,
"year": track.year,
"genre": track.genre,
"created": iso(track.created_at),
"type": "music",
"isVideo": False,
}
+13 -6
View File
@@ -1,15 +1,22 @@
"""Subsonic system endpoints: ping and license.""" """Subsonic system endpoints: ping and license."""
from typing import Any from fastapi import APIRouter, Response
from fastapi import APIRouter from app.api.deps import SubsonicFormat, SubsonicUser
from app.api.rest.envelope import subsonic_response
router = APIRouter() router = APIRouter()
@router.get("/ping") @router.api_route("/ping", methods=["GET", "POST"])
async def ping() -> Any: ... @router.api_route("/ping.view", methods=["GET", "POST"])
async def ping(_user: SubsonicUser, fmt: SubsonicFormat) -> Response:
# Requiring auth makes ping a credential check — exactly how clients use it.
return subsonic_response(fmt=fmt)
@router.get("/getLicense") @router.api_route("/getLicense", methods=["GET", "POST"])
async def get_license() -> Any: ... @router.api_route("/getLicense.view", methods=["GET", "POST"])
async def get_license(_user: SubsonicUser, fmt: SubsonicFormat) -> Response:
# Self-hosted and free — the license is always valid.
return subsonic_response({"license": {"valid": True}}, fmt=fmt)
+1
View File
@@ -13,4 +13,5 @@ class AlbumOut(BaseModel):
artist_name: str artist_name: str
year: int | None year: int | None
track_count: int track_count: int
has_cover: bool
created_at: dt.datetime created_at: dt.datetime
+5
View File
@@ -10,6 +10,11 @@ class LoginRequest(BaseModel):
password: str = Field(min_length=1) password: str = Field(min_length=1)
class RegisterRequest(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=8)
class RefreshRequest(BaseModel): class RefreshRequest(BaseModel):
refresh_token: str refresh_token: str
+59
View File
@@ -0,0 +1,59 @@
"""Schemas for the download job endpoints (§A5 download manager)."""
import datetime as dt
import uuid
from pydantic import BaseModel, Field
from app.domain.entities.download import DownloadJob
class DownloadCreate(BaseModel):
"""Request to download an item discovered on a fetch source."""
source: str
source_id: str = Field(min_length=1)
# Optional free-text the result came from — stored for display only.
query: str | None = None
class DownloadJobOut(BaseModel):
id: uuid.UUID
source: str
source_id: str | None
query: str | None
status: str
progress: float
error_message: str | None
retry_count: int
track_id: uuid.UUID | None
created_at: dt.datetime
updated_at: dt.datetime
@classmethod
def from_entity(cls, job: DownloadJob) -> DownloadJobOut:
return cls(
id=job.id,
source=job.source,
source_id=job.source_id,
query=job.query,
status=job.status,
progress=job.progress,
error_message=job.error_message,
retry_count=job.retry_count,
track_id=job.track_id,
created_at=job.created_at,
updated_at=job.updated_at,
)
class DownloadCreateResponse(BaseModel):
"""Result of requesting a download.
``already_in_library`` → the item was already imported (``track_id`` set, no
job). Otherwise ``job`` describes the queued (or already in-flight) download.
"""
already_in_library: bool
track_id: uuid.UUID | None
job: DownloadJobOut | None
+48
View File
@@ -0,0 +1,48 @@
"""Schemas for searching external (fetch) sources — the §A4 discover screen."""
import uuid
from pydantic import BaseModel
from app.domain.entities.track import Track
from app.domain.sources import SearchResult
class ExternalSearchResultOut(BaseModel):
source: str
source_id: str
title: str
artist: str | None
album: str | None
duration_seconds: int | None
thumbnail_url: str | None
# Remote browse (plan: Model C) — set when this hit is already saved in the
# library, so the UI can show "Play"/"Saved" instead of "Save to library".
in_library: bool
track_id: uuid.UUID | None
availability: str | None
@classmethod
def from_entity(
cls, r: SearchResult, *, existing: Track | None = None
) -> ExternalSearchResultOut:
return cls(
source=r.source,
source_id=r.source_id,
title=r.title,
artist=r.artist,
album=r.album,
duration_seconds=r.duration_seconds,
thumbnail_url=r.thumbnail_url,
in_library=existing is not None,
track_id=existing.id if existing is not None else None,
availability=existing.availability if existing is not None else None,
)
class ExternalSearchResponse(BaseModel):
"""Flat list of hits across one or more searchable sources, plus the names of
sources that were unavailable (so the UI can show a soft warning)."""
results: list[ExternalSearchResultOut]
searched_sources: list[str]
+29
View File
@@ -0,0 +1,29 @@
"""Schemas for the source endpoints."""
from pydantic import BaseModel
from app.domain.sources import SourceInfo
class SourceInfoOut(BaseModel):
name: str
label: str
kind: str
available: bool
@classmethod
def from_entity(cls, info: SourceInfo) -> SourceInfoOut:
return cls(name=info.name, label=info.label, kind=info.kind, available=info.available)
class ScanResponse(BaseModel):
"""Result of enqueuing a source scan."""
source: str
job_id: str
status: str = "queued"
class SourceHealthOut(BaseModel):
name: str
available: bool
+45
View File
@@ -0,0 +1,45 @@
"""Storage / library statistics response schemas (§A6)."""
import datetime as dt
from pydantic import BaseModel
class DiskUsageOut(BaseModel):
total: int
used: int
free: int
class FormatBreakdownOut(BaseModel):
file_format: str
track_count: int
total_size: int
class GenreCountOut(BaseModel):
genre: str
track_count: int
class StorageStatsOut(BaseModel):
"""Everything the Storage screen needs in a single call."""
# library catalogue
total_tracks: int
total_artists: int
total_albums: int
total_size: int
total_duration_seconds: int
largest_track_size: int
earliest_added: dt.datetime | None
latest_added: dt.datetime | None
# breakdowns
by_format: list[FormatBreakdownOut]
by_metadata_status: dict[str, int]
by_source: dict[str, int]
top_genres: list[GenreCountOut]
# backing volume (``None`` for object-store backends)
disk: DiskUsageOut | None
+13
View File
@@ -0,0 +1,13 @@
"""Schemas for Subsonic app-password self-service (native /api/v1 surface).
The Subsonic /rest layer itself returns its own XML/JSON envelope, not these
pydantic models — these only back the lifecycle endpoints that reveal/rotate the
recoverable app-password."""
from pydantic import BaseModel
class SubsonicPasswordResponse(BaseModel):
"""The plaintext Subsonic app-password, for pasting into a client."""
password: str
+63 -3
View File
@@ -3,7 +3,9 @@
import datetime as dt import datetime as dt
import uuid import uuid
from pydantic import BaseModel from pydantic import BaseModel, Field
from app.api.schemas.download import DownloadJobOut
class TrackOut(BaseModel): class TrackOut(BaseModel):
@@ -14,10 +16,17 @@ class TrackOut(BaseModel):
album_id: uuid.UUID | None album_id: uuid.UUID | None
album_title: str | None album_title: str | None
duration_seconds: int | None duration_seconds: int | None
file_format: str file_format: str | None
file_size: int file_size: int | None
genre: str | None
year: int | None
track_number: int | None
metadata_status: str metadata_status: str
metadata_error: str | None
enriched_at: dt.datetime | None
availability: str
source: str source: str
has_cover: bool
created_at: dt.datetime created_at: dt.datetime
@@ -25,3 +34,54 @@ class TrackUpdate(BaseModel):
title: str | None = None title: str | None = None
genre: str | None = None genre: str | None = None
year: int | None = None year: int | None = None
class MetadataMatch(BaseModel):
"""One AcoustID candidate for the metadata editor's match picker (§A7)."""
acoustid: str
score: float
recording_mbid: str | None
release_group_mbid: str | None
title: str | None
artist: str | None
album: str | None
year: int | None
class MetadataMatchesOut(BaseModel):
items: list[MetadataMatch]
class MetadataApply(BaseModel):
"""Manual edits / accepted match applied via ``PUT /tracks/{id}/metadata``.
Sets ``metadata_status = manual`` (never overwritten by auto-enrichment)."""
title: str | None = None
artist_name: str | None = None
album_title: str | None = None
year: int | None = None
genre: str | None = None
track_number: int | None = None
class RemoteTrackSave(BaseModel):
"""Save a remote browse hit (§A4 discover) as a library placeholder —
``availability="remote"``, no audio until first play (plan: Model C)."""
source: str
source_id: str = Field(min_length=1)
title: str
artist: str | None = None
class MaterializeResponse(BaseModel):
"""Result of requesting that a placeholder track's audio be fetched.
``job`` is ``None`` when the track is already ``local`` — nothing to wait
for, the caller can stream immediately. Otherwise it's the (new or
already in-flight) job; poll ``GET /downloads/{job.id}`` until ``done``."""
track: TrackOut
job: DownloadJobOut | None
+4
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.health import router as health_router
from app.api.v1.admin import router as admin_router from app.api.v1.admin import router as admin_router
from app.api.v1.albums import router as albums_router from app.api.v1.albums import router as albums_router
from app.api.v1.artists import router as artists_router from app.api.v1.artists import router as artists_router
@@ -22,6 +23,9 @@ from app.api.v1.user_settings import router as user_settings_router
from app.api.v1.users import router as users_router from app.api.v1.users import router as users_router
api_v1_router = APIRouter(prefix="/api/v1") api_v1_router = APIRouter(prefix="/api/v1")
# Also expose health under /api/v1 (root /health stays in main.py for compose/nginx
# probes); the webui pings ${apiBase}/health and apiBase is /api/v1.
api_v1_router.include_router(health_router)
api_v1_router.include_router(auth_router) api_v1_router.include_router(auth_router)
api_v1_router.include_router(users_router) api_v1_router.include_router(users_router)
api_v1_router.include_router(tracks_router) api_v1_router.include_router(tracks_router)
+10 -1
View File
@@ -9,7 +9,8 @@ from typing import Any
from fastapi import APIRouter, Query, status from fastapi import APIRouter, Query, status
from app.api.deps import SuperUser, UserServiceDep from app.api.deps import SubsonicAuthServiceDep, SuperUser, UserServiceDep
from app.api.schemas.subsonic import SubsonicPasswordResponse
from app.api.schemas.user import ( from app.api.schemas.user import (
CreateUserRequest, CreateUserRequest,
ResetPasswordRequest, ResetPasswordRequest,
@@ -81,6 +82,14 @@ async def deactivate_user(
return UserResponse.from_entity(await users.deactivate(user_id)) return UserResponse.from_entity(await users.deactivate(user_id))
@router.post("/users/{user_id}/subsonic-password", response_model=SubsonicPasswordResponse)
async def rotate_user_subsonic_password(
user_id: uuid.UUID, _admin: SuperUser, subsonic: SubsonicAuthServiceDep
) -> SubsonicPasswordResponse:
"""Rotate any user's Subsonic app-password and return the new plaintext."""
return SubsonicPasswordResponse(password=await subsonic.rotate(user_id))
@router.get("/services") @router.get("/services")
async def list_services(_admin: SuperUser) -> Any: ... async def list_services(_admin: SuperUser) -> Any: ...
+22 -3
View File
@@ -1,11 +1,19 @@
"""Album endpoints.""" """Album endpoints."""
import uuid import uuid
from typing import Any
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from fastapi.responses import StreamingResponse
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, TrackRepoDep from app.api.covers import stream_cover
from app.api.deps import (
AlbumRepoDep,
ArtistRepoDep,
CurrentUser,
FileStorageDep,
StreamUser,
TrackRepoDep,
)
from app.api.schemas.album import AlbumOut from app.api.schemas.album import AlbumOut
from app.api.schemas.pagination import PagedResponse from app.api.schemas.pagination import PagedResponse
from app.api.schemas.track import TrackOut from app.api.schemas.track import TrackOut
@@ -30,6 +38,7 @@ async def _build_album_out(
artist_name=artists[a.artist_id].name if a.artist_id in artists else "Unknown Artist", artist_name=artists[a.artist_id].name if a.artist_id in artists else "Unknown Artist",
year=a.year, year=a.year,
track_count=track_counts.get(a.id, 0), track_count=track_counts.get(a.id, 0),
has_cover=bool(a.cover_path),
created_at=a.created_at, created_at=a.created_at,
) )
for a in albums for a in albums
@@ -109,4 +118,14 @@ async def get_album_tracks(
@router.get("/{album_id}/cover") @router.get("/{album_id}/cover")
async def get_album_cover(album_id: uuid.UUID, _: CurrentUser) -> Any: ... async def get_album_cover(
album_id: uuid.UUID,
album_repo: AlbumRepoDep,
storage: FileStorageDep,
_: StreamUser,
) -> StreamingResponse:
# ``<img>`` can't send a bearer header → StreamUser accepts ``?token=``.
album = await album_repo.get_by_id(album_id)
if album is None or not album.cover_path:
raise NotFoundError("Cover not found.")
return await stream_cover(storage, album.cover_path)
+25 -2
View File
@@ -2,9 +2,16 @@
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import AuthServiceDep, CurrentUser from app.api.deps import AuthServiceDep, CurrentUser, UserServiceDep
from app.api.schemas.auth import LoginRequest, RefreshRequest, TokenResponse from app.api.schemas.auth import (
LoginRequest,
RefreshRequest,
RegisterRequest,
TokenResponse,
)
from app.api.schemas.user import UserResponse from app.api.schemas.user import UserResponse
from app.core.config import get_settings
from app.domain.errors import PermissionDeniedError
from app.domain.tokens import TokenPair from app.domain.tokens import TokenPair
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -23,6 +30,22 @@ async def login(body: LoginRequest, auth: AuthServiceDep) -> TokenResponse:
return _to_token_response(pair) return _to_token_response(pair)
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register(
body: RegisterRequest, users: UserServiceDep, auth: AuthServiceDep
) -> TokenResponse:
"""Public self-service sign-up (gated by ``ALLOW_REGISTRATION``).
Registered accounts are always regular users — superusers are created
admin-only. On success the new account is logged straight in.
"""
if not get_settings().allow_registration:
raise PermissionDeniedError("Registration is disabled on this instance.")
await users.create_user(username=body.username, password=body.password, is_superuser=False)
pair = await auth.login(body.username, body.password)
return _to_token_response(pair)
@router.post("/refresh", response_model=TokenResponse) @router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshRequest, auth: AuthServiceDep) -> TokenResponse: async def refresh(body: RefreshRequest, auth: AuthServiceDep) -> TokenResponse:
pair = await auth.refresh(body.refresh_token) pair = await auth.refresh(body.refresh_token)
+60 -18
View File
@@ -1,36 +1,78 @@
"""Download job endpoints. Heavy work is dispatched to arq workers.""" """Download job endpoints (§A5). Heavy work is dispatched to arq workers — these
handlers only create/inspect/cancel/retry job records."""
import uuid import uuid
from typing import Any
from fastapi import APIRouter from fastapi import APIRouter, Query, Response
from app.api.deps import CurrentUser, DownloadServiceDep
from app.api.schemas.download import DownloadCreate, DownloadCreateResponse, DownloadJobOut
from app.api.schemas.pagination import PagedResponse
router = APIRouter(prefix="/downloads", tags=["downloads"]) router = APIRouter(prefix="/downloads", tags=["downloads"])
@router.get("") @router.get("")
async def list_downloads() -> Any: ... async def list_downloads(
service: DownloadServiceDep,
user: CurrentUser,
status: str | None = Query(default=None),
mine: bool = Query(default=False),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PagedResponse[DownloadJobOut]:
jobs, total = await service.list(
requested_by=user.id if mine else None,
status=status,
limit=limit,
offset=offset,
)
return PagedResponse(
items=[DownloadJobOut.from_entity(j) for j in jobs],
total=total,
limit=limit,
offset=offset,
)
@router.post("") @router.post("", status_code=202)
async def create_download() -> Any: ... async def create_download(
body: DownloadCreate,
service: DownloadServiceDep,
user: CurrentUser,
) -> DownloadCreateResponse:
result = await service.request(
source=body.source,
source_id=body.source_id,
query=body.query,
requested_by=user.id,
)
return DownloadCreateResponse(
already_in_library=result.already_in_library,
track_id=result.track_id,
job=DownloadJobOut.from_entity(result.job) if result.job is not None else None,
)
@router.get("/{job_id}") @router.get("/{job_id}")
async def get_download(job_id: uuid.UUID) -> Any: ... async def get_download(
job_id: uuid.UUID, service: DownloadServiceDep, _: CurrentUser
) -> DownloadJobOut:
job = await service.get(job_id)
return DownloadJobOut.from_entity(job)
@router.delete("/{job_id}") @router.delete("/{job_id}", status_code=204)
async def cancel_download(job_id: uuid.UUID) -> Any: ... async def cancel_download(
job_id: uuid.UUID, service: DownloadServiceDep, _: CurrentUser
) -> Response:
await service.cancel(job_id)
return Response(status_code=204)
@router.post("/{job_id}/retry") @router.post("/{job_id}/retry")
async def retry_download(job_id: uuid.UUID) -> Any: ... async def retry_download(
job_id: uuid.UUID, service: DownloadServiceDep, _: CurrentUser
) -> DownloadJobOut:
@router.post("/pause") job = await service.retry(job_id)
async def pause_downloads() -> Any: ... return DownloadJobOut.from_entity(job)
@router.post("/resume")
async def resume_downloads() -> Any: ...
+27 -4
View File
@@ -1,12 +1,11 @@
"""Search endpoints: global and library-scoped.""" """Search endpoints: global and library-scoped."""
from typing import Any
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, TrackRepoDep from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, SourceRegistryDep, TrackRepoDep
from app.api.schemas.album import AlbumOut from app.api.schemas.album import AlbumOut
from app.api.schemas.artist import ArtistOut from app.api.schemas.artist import ArtistOut
from app.api.schemas.external_search import ExternalSearchResponse, ExternalSearchResultOut
from app.api.schemas.search import LibrarySearchResponse from app.api.schemas.search import LibrarySearchResponse
from app.api.schemas.track import TrackOut from app.api.schemas.track import TrackOut
from app.api.v1.albums import _build_album_out from app.api.v1.albums import _build_album_out
@@ -16,7 +15,31 @@ router = APIRouter(prefix="/search", tags=["search"])
@router.get("") @router.get("")
async def search(_: CurrentUser) -> Any: ... async def search(
_: CurrentUser,
registry: SourceRegistryDep,
track_repo: TrackRepoDep,
q: str = Query(min_length=1),
limit: int = Query(20, ge=1, le=50),
) -> ExternalSearchResponse:
"""Search every available fetch source and merge the hits (§A4 discover).
A source that is down contributes nothing rather than failing the whole
request (graceful degradation); only available sources are reported as
searched. Each hit is checked against the library by ``(source,
source_id)`` so the UI can show "Saved"/"Play" instead of "Save to
library" without a separate round-trip (remote browse, plan: Model C)."""
results: list[ExternalSearchResultOut] = []
searched: list[str] = []
for backend in registry.searchables():
if not backend.is_available():
continue
searched.append(backend.name)
hits = await backend.search(q, limit=limit)
for h in hits:
existing = await track_repo.get_by_source(h.source, h.source_id)
results.append(ExternalSearchResultOut.from_entity(h, existing=existing))
return ExternalSearchResponse(results=results, searched_sources=searched)
@router.get("/library") @router.get("/library")
+45 -7
View File
@@ -1,19 +1,57 @@
"""External source endpoints (yt-dlp etc.).""" """External source endpoints: enumerate sources, search, and trigger imports.
from typing import Any Listing/health/search are read-only (any authenticated user). Scanning a source
is an admin action and runs in a worker — the endpoint only enqueues it.
"""
from fastapi import APIRouter from fastapi import APIRouter, Query
from app.api.deps import CurrentUser, SourceRegistryDep, SuperUser, TrackRepoDep
from app.api.schemas.external_search import ExternalSearchResponse, ExternalSearchResultOut
from app.api.schemas.source import ScanResponse, SourceHealthOut, SourceInfoOut
from app.domain.errors import DependencyUnavailableError
from app.workers.queue import enqueue
router = APIRouter(prefix="/sources", tags=["sources"]) router = APIRouter(prefix="/sources", tags=["sources"])
@router.get("") @router.get("")
async def list_sources() -> Any: ... async def list_sources(_: CurrentUser, registry: SourceRegistryDep) -> list[SourceInfoOut]:
return [SourceInfoOut.from_entity(info) for info in registry.infos()]
@router.get("/{source}/search") @router.post("/{source}/scan")
async def search_source(source: str) -> Any: ... async def scan_source(source: str, user: SuperUser, registry: SourceRegistryDep) -> ScanResponse:
backend = registry.indexable(source) # 404 if unknown, 422 if not indexable
if not backend.is_available():
raise DependencyUnavailableError(f"Source {source!r} is not available.")
job_id = await enqueue("scan_local_folder", source=source, added_by=str(user.id))
return ScanResponse(source=source, job_id=job_id)
@router.get("/{source}/health") @router.get("/{source}/health")
async def source_health(source: str) -> Any: ... async def source_health(
source: str, _: CurrentUser, registry: SourceRegistryDep
) -> SourceHealthOut:
backend = registry.get(source) # 404 if unknown
return SourceHealthOut(name=backend.name, available=backend.is_available())
@router.get("/{source}/search")
async def search_source(
source: str,
_: CurrentUser,
registry: SourceRegistryDep,
track_repo: TrackRepoDep,
q: str = Query(min_length=1),
limit: int = Query(20, ge=1, le=50),
) -> ExternalSearchResponse:
backend = registry.searchable(source) # 404 if unknown, 422 if not searchable
if not backend.is_available():
raise DependencyUnavailableError(f"Source {source!r} is not available.")
results = await backend.search(q, limit=limit)
out: list[ExternalSearchResultOut] = []
for r in results:
existing = await track_repo.get_by_source(r.source, r.source_id)
out.append(ExternalSearchResultOut.from_entity(r, existing=existing))
return ExternalSearchResponse(results=out, searched_sources=[source])
+59 -1
View File
@@ -4,11 +4,69 @@ from typing import Any
from fastapi import APIRouter from fastapi import APIRouter
from app.api.deps import (
AlbumRepoDep,
ArtistRepoDep,
CurrentUser,
FileStorageDep,
TrackRepoDep,
)
from app.api.schemas.storage import (
DiskUsageOut,
FormatBreakdownOut,
GenreCountOut,
StorageStatsOut,
)
router = APIRouter(prefix="/storage", tags=["storage"]) router = APIRouter(prefix="/storage", tags=["storage"])
# How many of the most common genres the dashboard surfaces.
_TOP_GENRES = 8
@router.get("") @router.get("")
async def get_storage_stats() -> Any: ... async def get_storage_stats(
track_repo: TrackRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
storage: FileStorageDep,
_: CurrentUser,
) -> StorageStatsOut:
"""Library + disk statistics for the Storage dashboard (§A6).
Aggregates come from the catalogue (cheap GROUP BYs); ``disk`` reflects the
real backing volume and is ``None`` for backends without a fixed-capacity
disk (e.g. object stores)."""
stats = await track_repo.library_stats()
total_artists = await artist_repo.count(q=None)
total_albums = await album_repo.count(artist_id=None, q=None)
genres = await track_repo.genres()
disk = await storage.disk_usage()
return StorageStatsOut(
total_tracks=stats.total_tracks,
total_artists=total_artists,
total_albums=total_albums,
total_size=stats.total_size,
total_duration_seconds=stats.total_duration_seconds,
largest_track_size=stats.largest_track_size,
earliest_added=stats.earliest_added,
latest_added=stats.latest_added,
by_format=[
FormatBreakdownOut(
file_format=f.file_format,
track_count=f.track_count,
total_size=f.total_size,
)
for f in stats.by_format
],
by_metadata_status=stats.by_metadata_status,
by_source=stats.by_source,
top_genres=[
GenreCountOut(genre=genre, track_count=count) for genre, count in genres[:_TOP_GENRES]
],
disk=DiskUsageOut(total=disk.total, used=disk.used, free=disk.free) if disk else None,
)
@router.get("/duplicates") @router.get("/duplicates")
+188 -8
View File
@@ -4,13 +4,34 @@ import uuid
from typing import Any from typing import Any
from fastapi import APIRouter, Query, Response from fastapi import APIRouter, Query, Response
from fastapi.responses import StreamingResponse
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, FileStorageDep, TrackRepoDep from app.api.covers import resolve_album_for_track, stream_cover
from app.api.deps import (
AlbumRepoDep,
ArtistRepoDep,
CurrentUser,
FileStorageDep,
MetadataServiceDep,
RemoteLibraryServiceDep,
StreamUser,
TrackRepoDep,
)
from app.api.schemas.download import DownloadJobOut
from app.api.schemas.pagination import PagedResponse from app.api.schemas.pagination import PagedResponse
from app.api.schemas.track import TrackOut, TrackUpdate from app.api.schemas.track import (
MaterializeResponse,
MetadataApply,
MetadataMatch,
MetadataMatchesOut,
RemoteTrackSave,
TrackOut,
TrackUpdate,
)
from app.domain.entities.album import Album from app.domain.entities.album import Album
from app.domain.entities.track import Artist, Track from app.domain.entities.track import Artist, Track
from app.domain.errors import NotFoundError from app.domain.errors import NotFoundError
from app.workers.queue import enqueue
router = APIRouter(prefix="/tracks", tags=["tracks"]) router = APIRouter(prefix="/tracks", tags=["tracks"])
@@ -31,8 +52,15 @@ async def _build_track_out(
duration_seconds=t.duration_seconds, duration_seconds=t.duration_seconds,
file_format=t.file_format, file_format=t.file_format,
file_size=t.file_size, file_size=t.file_size,
genre=t.genre,
year=t.year,
track_number=t.track_number,
metadata_status=t.metadata_status, metadata_status=t.metadata_status,
metadata_error=t.metadata_error,
enriched_at=t.enriched_at,
availability=t.availability,
source=t.source, source=t.source,
has_cover=bool(t.album_id and albums.get(t.album_id) and albums[t.album_id].cover_path),
created_at=t.created_at, created_at=t.created_at,
) )
for t in tracks for t in tracks
@@ -48,6 +76,7 @@ async def list_tracks(
artist_id: uuid.UUID | None = None, artist_id: uuid.UUID | None = None,
album_id: uuid.UUID | None = None, album_id: uuid.UUID | None = None,
q: str | None = None, q: str | None = None,
source: str | None = Query(None, max_length=32),
sort_by: str = Query("created_at", pattern="^(title|created_at|artist)$"), sort_by: str = Query("created_at", pattern="^(title|created_at|artist)$"),
order: str = Query("desc", pattern="^(asc|desc)$"), order: str = Query("desc", pattern="^(asc|desc)$"),
limit: int = Query(50, ge=1, le=200), limit: int = Query(50, ge=1, le=200),
@@ -57,12 +86,13 @@ async def list_tracks(
artist_id=artist_id, artist_id=artist_id,
album_id=album_id, album_id=album_id,
q=q, q=q,
source=source,
sort_by=sort_by, sort_by=sort_by,
order=order, order=order,
limit=limit, limit=limit,
offset=offset, offset=offset,
) )
total = await track_repo.count(artist_id=artist_id, album_id=album_id, q=q) total = await track_repo.count(artist_id=artist_id, album_id=album_id, q=q, source=source)
artist_ids = list({t.artist_id for t in tracks}) artist_ids = list({t.artist_id for t in tracks})
album_ids = list({t.album_id for t in tracks if t.album_id is not None}) album_ids = list({t.album_id for t in tracks if t.album_id is not None})
@@ -73,6 +103,57 @@ async def list_tracks(
return PagedResponse(items=items, total=total, limit=limit, offset=offset) return PagedResponse(items=items, total=total, limit=limit, offset=offset)
@router.post("/remote", status_code=201)
async def save_remote_track(
body: RemoteTrackSave,
service: RemoteLibraryServiceDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
user: CurrentUser,
) -> TrackOut:
"""Save a remote browse hit (§A4 discover) as a library placeholder —
no audio is fetched yet (plan: Model C). Idempotent on ``(source,
source_id)``: saving an already-saved hit returns the existing track."""
track = await service.save_remote(
source=body.source,
source_id=body.source_id,
title=body.title,
artist=body.artist,
added_by=user.id,
)
artists = {a.id: a for a in await artist_repo.get_many([track.artist_id])}
album_ids = [track.album_id] if track.album_id else []
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
items = await _build_track_out([track], artists, albums)
return items[0]
@router.post("/{track_id}/materialize")
async def materialize_track(
track_id: uuid.UUID,
service: RemoteLibraryServiceDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
user: CurrentUser,
) -> MaterializeResponse:
"""Fetch a placeholder track's audio on demand (plan: Model C lazy
materialization). Already-local tracks return ``job=None`` — nothing to
wait for. Otherwise poll ``GET /downloads/{job.id}`` until ``done``, then
stream as usual."""
outcome = await service.request_materialize(track_id, requested_by=user.id)
artists = {a.id: a for a in await artist_repo.get_many([outcome.track.artist_id])}
album_ids = [outcome.track.album_id] if outcome.track.album_id else []
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
track_out = (await _build_track_out([outcome.track], artists, albums))[0]
return MaterializeResponse(
track=track_out,
job=DownloadJobOut.from_entity(outcome.job) if outcome.job is not None else None,
)
@router.get("/{track_id}") @router.get("/{track_id}")
async def get_track( async def get_track(
track_id: uuid.UUID, track_id: uuid.UUID,
@@ -130,7 +211,8 @@ async def delete_track(
if track is None: if track is None:
raise NotFoundError(f"Track {track_id} not found.") raise NotFoundError(f"Track {track_id} not found.")
await track_repo.delete(track_id) await track_repo.delete(track_id)
await storage.delete(track.storage_uri) if track.storage_uri is not None:
await storage.delete(track.storage_uri)
return Response(status_code=204) return Response(status_code=204)
@@ -143,16 +225,114 @@ async def optimize_track(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
@router.get("/{track_id}/cover") @router.get("/{track_id}/cover")
async def get_track_cover(track_id: uuid.UUID, _: CurrentUser) -> Any: ... async def get_track_cover(
track_id: uuid.UUID,
track_repo: TrackRepoDep,
album_repo: AlbumRepoDep,
storage: FileStorageDep,
_: StreamUser,
) -> StreamingResponse:
# A track's cover is its album's cover. ``<img>`` can't send a bearer
# header → StreamUser accepts ``?token=``.
album = await resolve_album_for_track(track_repo, album_repo, track_id)
if album is None or not album.cover_path:
raise NotFoundError("Cover not found.")
return await stream_cover(storage, album.cover_path)
@router.post("/{track_id}/metadata/enrich") @router.post("/{track_id}/metadata/enrich")
async def enrich_metadata(track_id: uuid.UUID, _: CurrentUser) -> Any: ... async def enrich_metadata(
track_id: uuid.UUID,
track_repo: TrackRepoDep,
_: CurrentUser,
) -> dict[str, str]:
"""Re-run metadata enrichment for a track (admin/user-triggered). The work
happens in a worker; this only enqueues it. 503 if the queue is down."""
track = await track_repo.get_by_id(track_id)
if track is None:
raise NotFoundError(f"Track {track_id} not found.")
job_id = await enqueue("enrich_track", track_id=str(track_id))
return {"track_id": str(track_id), "job_id": job_id}
@router.get("/{track_id}/metadata/matches") @router.get("/{track_id}/metadata/matches")
async def get_metadata_matches(track_id: uuid.UUID, _: CurrentUser) -> Any: ... async def get_metadata_matches(
track_id: uuid.UUID,
track_repo: TrackRepoDep,
metadata_service: MetadataServiceDep,
_: CurrentUser,
) -> MetadataMatchesOut:
"""AcoustID candidates for the metadata editor's match picker (§A7).
Runs the fingerprint lookup inline (single track, user-triggered) and
never mutates the track. Degrades to an empty list if fpcalc/AcoustID are
unavailable or no match is found.
"""
track = await track_repo.get_by_id(track_id)
if track is None:
raise NotFoundError(f"Track {track_id} not found.")
matches = await metadata_service.find_matches(track_id)
return MetadataMatchesOut(
items=[
MetadataMatch(
acoustid=m.acoustid,
score=m.score,
recording_mbid=m.recording_mbid,
release_group_mbid=m.release_group_mbid,
title=m.title,
artist=m.artist,
album=m.album,
year=m.year,
)
for m in matches
]
)
@router.put("/{track_id}/metadata") @router.put("/{track_id}/metadata")
async def set_metadata(track_id: uuid.UUID, _: CurrentUser) -> Any: ... async def set_metadata(
track_id: uuid.UUID,
body: MetadataApply,
track_repo: TrackRepoDep,
artist_repo: ArtistRepoDep,
album_repo: AlbumRepoDep,
_: CurrentUser,
) -> TrackOut:
"""Apply manual edits or an accepted AcoustID match (§A7). Sets
``metadata_status = manual`` — never overwritten by auto-enrichment."""
track = await track_repo.get_by_id(track_id)
if track is None:
raise NotFoundError(f"Track {track_id} not found.")
artist_id: uuid.UUID | None = None
if body.artist_name:
artist = await artist_repo.get_or_create(body.artist_name)
artist_id = artist.id
album_id: uuid.UUID | None = None
if body.album_title:
album = await album_repo.get_or_create(
title=body.album_title,
artist_id=artist_id or track.artist_id,
year=body.year,
musicbrainz_id=None,
)
album_id = album.id
track = await track_repo.update(
track_id,
title=body.title,
genre=body.genre,
year=body.year,
artist_id=artist_id,
album_id=album_id,
track_number=body.track_number,
)
artist_ids = [track.artist_id]
album_ids = [track.album_id] if track.album_id else []
artists = {a.id: a for a in await artist_repo.get_many(artist_ids)}
albums = {a.id: a for a in await album_repo.get_many(album_ids)}
items = await _build_track_out([track], artists, albums)
return items[0]
+21 -1
View File
@@ -2,7 +2,8 @@
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, UserServiceDep from app.api.deps import CurrentUser, SubsonicAuthServiceDep, UserServiceDep
from app.api.schemas.subsonic import SubsonicPasswordResponse
from app.api.schemas.user import ChangePasswordRequest from app.api.schemas.user import ChangePasswordRequest
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -17,3 +18,22 @@ async def change_my_password(
current_password=body.current_password, current_password=body.current_password,
new_password=body.new_password, new_password=body.new_password,
) )
@router.get("/me/subsonic-password", response_model=SubsonicPasswordResponse)
async def reveal_my_subsonic_password(
user: CurrentUser, subsonic: SubsonicAuthServiceDep
) -> SubsonicPasswordResponse:
"""Reveal the caller's Subsonic app-password for copying into a client.
It's recoverable, so it can be read on demand; one is generated lazily on
first access. Paste it (with the username) into Symfonium/DSub."""
return SubsonicPasswordResponse(password=await subsonic.reveal(user.id))
@router.post("/me/subsonic-password", response_model=SubsonicPasswordResponse)
async def rotate_my_subsonic_password(
user: CurrentUser, subsonic: SubsonicAuthServiceDep
) -> SubsonicPasswordResponse:
"""Rotate the caller's Subsonic app-password (invalidates the previous one)."""
return SubsonicPasswordResponse(password=await subsonic.rotate(user.id))
+183
View File
@@ -0,0 +1,183 @@
"""DownloadService — request external downloads and import their results.
Two roles (plan §6.1):
* **Request side** (HTTP): validate + dedup a download request, create a
``queued`` job, and enqueue the worker. Dedup is on ``(source, source_id)``
against both the library (already imported) and in-flight jobs (a double-click
must not queue twice) — idempotency per CLAUDE.md.
* **Worker side**: ``store_result`` turns a backend's :class:`DownloadResult`
into a managed file + minimal ``pending`` track (sibling of
:class:`~app.application.import_service.LibraryImportService`); enrichment
(§6.2) fills the rest.
The fingerprint-level dedup (a different id that turns out to be the same audio)
happens later in enrichment, where the fingerprint is computed.
"""
import contextlib
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import anyio
from app.core.logging import get_logger
from app.domain.entities.download import DownloadJob
from app.domain.errors import NotFoundError, ValidationError
from app.domain.ports import (
ArtistRepository,
DownloadJobRepository,
FileStorage,
TrackRepository,
)
from app.domain.sources import DownloadResult
log = get_logger(__name__)
_UNKNOWN_ARTIST = "Unknown Artist"
# (job_id) -> None — enqueue the download worker, deferred so the job row is
# committed before the worker reads it (same pattern as enrich).
DownloadEnqueuer = Callable[[uuid.UUID], Awaitable[None]]
EnrichEnqueuer = Callable[[uuid.UUID], Awaitable[None]]
@dataclass(frozen=True)
class DownloadRequest:
"""Outcome of asking for a download.
Exactly one of the three states holds: the item is already in the library
(``track_id`` set, ``already_in_library``), a job already covers it / was
just created (``job`` set), so the UI can route to the download manager.
"""
job: DownloadJob | None
track_id: uuid.UUID | None
already_in_library: bool
class DownloadService:
def __init__(
self,
*,
jobs: DownloadJobRepository,
tracks: TrackRepository,
artists: ArtistRepository,
storage: FileStorage,
enqueue_download: DownloadEnqueuer | None = None,
enqueue_enrich: EnrichEnqueuer | None = None,
) -> None:
self._jobs = jobs
self._tracks = tracks
self._artists = artists
self._storage = storage
self._enqueue_download = enqueue_download
self._enqueue_enrich = enqueue_enrich
# -- request side ---------------------------------------------------------
async def request(
self,
*,
source: str,
source_id: str,
query: str | None,
requested_by: uuid.UUID | None,
) -> DownloadRequest:
source_id = source_id.strip()
if not source_id:
raise ValidationError("A source_id is required to download.")
existing = await self._tracks.get_by_source(source, source_id)
if existing is not None:
return DownloadRequest(job=None, track_id=existing.id, already_in_library=True)
active = await self._jobs.get_active_for_source(source, source_id)
if active is not None:
return DownloadRequest(job=active, track_id=None, already_in_library=False)
job = await self._jobs.add(
source=source,
source_id=source_id,
query=query,
requested_by=requested_by,
)
if self._enqueue_download is not None:
await self._enqueue_download(job.id)
return DownloadRequest(job=job, track_id=None, already_in_library=False)
async def list(
self,
*,
requested_by: uuid.UUID | None,
status: str | None,
limit: int,
offset: int,
) -> tuple[list[DownloadJob], int]:
jobs = await self._jobs.list(
requested_by=requested_by, status=status, limit=limit, offset=offset
)
total = await self._jobs.count(requested_by=requested_by, status=status)
return jobs, total
async def get(self, job_id: uuid.UUID) -> DownloadJob:
job = await self._jobs.get_by_id(job_id)
if job is None:
raise NotFoundError(f"Download job {job_id} not found.")
return job
async def cancel(self, job_id: uuid.UUID) -> None:
"""Remove the job record. True mid-flight cancellation of an in-progress
yt-dlp download is out of scope (MVP); the worker tolerates a vanished
job row (its status writes become no-ops)."""
job = await self._jobs.get_by_id(job_id)
if job is None:
raise NotFoundError(f"Download job {job_id} not found.")
await self._jobs.delete(job_id)
async def retry(self, job_id: uuid.UUID) -> DownloadJob:
job = await self.get(job_id)
await self._jobs.set_status(job_id, status="queued", error_message=None)
if self._enqueue_download is not None:
await self._enqueue_download(job_id)
refreshed = await self._jobs.get_by_id(job_id)
return refreshed if refreshed is not None else job
# -- worker side ----------------------------------------------------------
async def store_result(
self,
*,
source: str,
result: DownloadResult,
requested_by: uuid.UUID | None,
) -> uuid.UUID:
"""Store a freshly downloaded file and create a minimal ``pending`` track.
Returns the new track id (the caller enqueues enrichment after commit).
The temp file produced by the backend is always removed."""
track_id = uuid.uuid4()
key = f"tracks/{str(track_id)[:2]}/{track_id}.{result.file_format}"
try:
await self._storage.save_file(key, result.path)
try:
artist = await self._artists.get_or_create(_UNKNOWN_ARTIST)
await self._tracks.add(
id=track_id,
title=result.suggested_title,
artist_id=artist.id,
storage_uri=key,
file_format=result.file_format,
file_size=result.file_size,
source=source,
source_id=result.source_id,
metadata_status="pending",
added_by=requested_by,
)
except Exception:
with contextlib.suppress(Exception):
await self._storage.delete(key)
raise
finally:
with contextlib.suppress(Exception):
await anyio.Path(result.path).unlink(missing_ok=True)
return track_id
+106
View File
@@ -0,0 +1,106 @@
"""LibraryImportService — imports files discovered by an indexable source.
Batch sibling of :class:`UploadService`: for each discovered file it dedups on
``(source, source_id)``, copies the file into managed storage, creates a minimal
track (artist ``Unknown Artist``, ``metadata_status=pending``), and leaves the
rest to enrichment (plan §6.2). Per-file failures are isolated — one bad file
must not abort the whole scan (graceful degradation).
"""
import contextlib
import uuid
from dataclasses import dataclass, field
from app.core.logging import get_logger
from app.domain.ports import ArtistRepository, FileStorage, IndexableSource, TrackRepository
from app.domain.sources import SourceFile
log = get_logger(__name__)
_UNKNOWN_ARTIST = "Unknown Artist"
@dataclass(frozen=True)
class ImportSummary:
source: str
seen: int
imported: int
skipped: int
failed: int
# IDs of freshly imported tracks, for the caller to enqueue enrichment
# *after* its transaction commits (enqueuing mid-scan would race the worker).
imported_ids: list[uuid.UUID] = field(default_factory=list)
class LibraryImportService:
def __init__(
self,
*,
tracks: TrackRepository,
artists: ArtistRepository,
storage: FileStorage,
) -> None:
self._tracks = tracks
self._artists = artists
self._storage = storage
async def scan_and_import(
self, source: IndexableSource, *, added_by: uuid.UUID | None
) -> ImportSummary:
seen = skipped = failed = 0
imported_ids: list[uuid.UUID] = []
for file in source.scan():
seen += 1
try:
existing = await self._tracks.get_by_source(source.name, file.source_id)
if existing is not None:
skipped += 1
continue
track_id = await self._import_one(source.name, file, added_by)
imported_ids.append(track_id)
except Exception:
failed += 1
log.warning("import_file_failed", source=source.name, source_id=file.source_id)
summary = ImportSummary(
source=source.name,
seen=seen,
imported=len(imported_ids),
skipped=skipped,
failed=failed,
imported_ids=imported_ids,
)
log.info(
"import_complete",
source=summary.source,
seen=summary.seen,
imported=summary.imported,
skipped=summary.skipped,
failed=summary.failed,
)
return summary
async def _import_one(
self, source_name: str, file: SourceFile, added_by: uuid.UUID | None
) -> uuid.UUID:
track_id = uuid.uuid4()
key = f"tracks/{str(track_id)[:2]}/{track_id}.{file.file_format}"
await self._storage.save_file(key, file.path)
try:
artist = await self._artists.get_or_create(_UNKNOWN_ARTIST)
await self._tracks.add(
id=track_id,
title=file.suggested_title,
artist_id=artist.id,
storage_uri=key,
file_format=file.file_format,
file_size=file.file_size,
source=source_name,
source_id=file.source_id,
metadata_status="pending",
added_by=added_by,
)
except Exception:
with contextlib.suppress(Exception):
await self._storage.delete(key)
raise
return track_id
+306
View File
@@ -0,0 +1,306 @@
"""MetadataEnrichmentService — the §6.2 pipeline orchestrator.
Order (tag-first): embedded tags → Chromaprint fingerprint → AcoustID lookup.
Tags fix the common well-tagged case offline; AcoustID identifies the rest and
supplies a MusicBrainz id. The result updates the track and sets
``metadata_status`` to ``enriched`` (identity found) or ``failed`` (nothing).
Invariants (plan §6.2, CLAUDE.md):
- **Never touch ``manual``** — a user-edited track is returned untouched.
- **Graceful degradation** — every external step is wrapped; one failure (no
fpcalc, no API key, service down) degrades the result, never crashes.
- **Idempotent** — re-running only fills gaps; ``apply_enrichment`` never erases.
"""
import tempfile
import uuid
from dataclasses import dataclass
from pathlib import Path
from app.core.logging import get_logger
from app.domain.entities.album import Album
from app.domain.entities.cover import CoverArt
from app.domain.entities.metadata import AudioTags, RecordingMatch
from app.domain.ports import (
AcoustIdClient,
AlbumRepository,
ArtistRepository,
AudioFingerprinter,
AudioTagReader,
CoverArtExtractor,
CoverArtProvider,
FileStorage,
TrackRepository,
)
log = get_logger(__name__)
_UNKNOWN_ARTIST = "Unknown Artist"
@dataclass(frozen=True)
class EnrichmentResult:
track_id: uuid.UUID
status: str # "enriched" | "failed" | "skipped"
matched_mbid: str | None = None
class MetadataEnrichmentService:
def __init__(
self,
*,
tracks: TrackRepository,
artists: ArtistRepository,
albums: AlbumRepository,
storage: FileStorage,
tag_reader: AudioTagReader,
fingerprinter: AudioFingerprinter,
acoustid: AcoustIdClient,
cover_extractor: CoverArtExtractor | None = None,
cover_provider: CoverArtProvider | None = None,
acoustid_trust_score: float = 0.85,
) -> None:
self._tracks = tracks
self._artists = artists
self._albums = albums
self._storage = storage
self._tag_reader = tag_reader
self._fingerprinter = fingerprinter
self._acoustid = acoustid
self._cover_extractor = cover_extractor
self._cover_provider = cover_provider
self._acoustid_trust_score = acoustid_trust_score
async def enrich(self, track_id: uuid.UUID) -> EnrichmentResult:
track = await self._tracks.get_by_id(track_id)
if track is None:
log.info("enrich_track_missing", track_id=str(track_id))
return EnrichmentResult(track_id=track_id, status="skipped")
if track.metadata_status == "manual":
log.info("enrich_skip_manual", track_id=str(track_id))
return EnrichmentResult(track_id=track_id, status="skipped")
storage_uri = track.storage_uri
if storage_uri is None:
log.info("enrich_skip_remote", track_id=str(track_id))
return EnrichmentResult(track_id=track_id, status="skipped")
tags = await self._read_local(storage_uri)
match = await self._identify(storage_uri)
# Merge order is tag-first by default — embedded tags fix the common
# well-tagged offline case. But a *high-confidence* AcoustID match is the
# more trustworthy identity (downloaded files routinely carry junk tags
# like "Music Track"/"Sound_12345"), so above the trust threshold the
# acoustic match wins for the identity fields and tags become fallback.
tag_title = tags.title if tags else None
tag_artist = tags.artist if tags else None
tag_album = tags.album if tags else None
match_title = match.title if match else None
match_artist = match.artist if match else None
match_album = match.album if match else None
match_year = match.year if match else None
tag_year = tags.year if tags else None
trust_match = match is not None and match.score >= self._acoustid_trust_score
if trust_match:
title = _opt_str(match_title, tag_title) or track.title
artist_name = _opt_str(match_artist, tag_artist)
album_title = _opt_str(match_album, tag_album)
year = _first_int(match_year, tag_year)
else:
title = _opt_str(tag_title, match_title) or track.title
artist_name = _opt_str(tag_artist, match_artist)
album_title = _opt_str(tag_album, match_album)
year = _first_int(tag_year, match_year)
genre = tags.genre if tags else None
track_number = tags.track_number if tags else None
duration = _first_int(
tags.duration_seconds if tags else None,
track.duration_seconds,
)
bitrate = tags.bitrate if tags else None
mbid = match.recording_mbid if match else None
acoustid_id = match.acoustid if match else None
artist_id = await self._resolve_artist(artist_name, fallback=track.artist_id)
album = await self._resolve_album(album_title, artist_id=artist_id, year=year, mbid=mbid)
album_id = album.id if album is not None else None
if album is not None:
await self._resolve_cover(
album,
storage_uri=storage_uri,
release_group_mbid=match.release_group_mbid if match else None,
)
identified = bool(artist_name) or album_id is not None or mbid is not None
status = "enriched" if identified else "failed"
# On a clean "no identity" outcome, record *why* so the UI shows a reason
# rather than a bare "failed". A successful run clears any prior error.
metadata_error = None if identified else self._no_match_reason()
await self._tracks.apply_enrichment(
track_id,
title=title,
artist_id=artist_id,
album_id=album_id,
genre=genre,
year=year,
track_number=track_number,
duration_seconds=duration,
bitrate=bitrate,
acoustid_fingerprint=acoustid_id,
musicbrainz_id=mbid,
metadata_status=status,
metadata_error=metadata_error,
)
log.info("enrich_complete", track_id=str(track_id), status=status, mbid=mbid)
return EnrichmentResult(track_id=track_id, status=status, matched_mbid=mbid)
def _no_match_reason(self) -> str:
"""Explain a ``failed`` (no-identity) run in terms a user can act on:
which optional identification step was unavailable, if any."""
if not self._fingerprinter.is_available():
return "No metadata match: audio fingerprinting (fpcalc) is unavailable."
if not self._acoustid.is_available():
return "No metadata match: AcoustID lookup is unavailable (no API key)."
return "No metadata match found in tags or AcoustID."
async def find_matches(self, track_id: uuid.UUID) -> list[RecordingMatch]:
"""AcoustID candidates for the metadata editor's match picker (§A7).
Read-only — unlike :meth:`enrich`, never touches the track. Runs
inline (single track, user-triggered) rather than via the worker.
Degrades to ``[]`` whenever fingerprinting/AcoustID is unavailable or
the file can't be read, same as the enrichment pipeline.
"""
track = await self._tracks.get_by_id(track_id)
if track is None:
return []
if not self._acoustid.is_available() or not self._fingerprinter.is_available():
return []
if track.storage_uri is None:
return []
try:
async with self._storage.as_local_path(track.storage_uri) as path:
fingerprint = await self._fingerprinter.calculate(path)
if fingerprint is None:
return []
return await self._acoustid.lookup_all(fingerprint)
except Exception:
log.warning("find_matches_failed", track_id=str(track_id))
return []
async def _read_local(self, storage_uri: str) -> AudioTags | None:
try:
async with self._storage.as_local_path(storage_uri) as path:
return await self._tag_reader.read(path)
except Exception:
log.warning("enrich_tag_step_failed", storage_uri=storage_uri)
return None
async def _identify(self, storage_uri: str) -> RecordingMatch | None:
if not self._acoustid.is_available() or not self._fingerprinter.is_available():
return None
try:
async with self._storage.as_local_path(storage_uri) as path:
fingerprint = await self._fingerprinter.calculate(path)
if fingerprint is None:
return None
return await self._acoustid.lookup(fingerprint)
except Exception:
log.warning("enrich_identify_step_failed", storage_uri=storage_uri)
return None
async def _resolve_artist(self, name: str | None, *, fallback: uuid.UUID) -> uuid.UUID:
if not name or name == _UNKNOWN_ARTIST:
return fallback
artist = await self._artists.get_or_create(name)
return artist.id
async def _resolve_album(
self,
title: str | None,
*,
artist_id: uuid.UUID,
year: int | None,
mbid: str | None,
) -> Album | None:
if not title:
return None
return await self._albums.get_or_create(
title=title,
artist_id=artist_id,
year=year,
musicbrainz_id=mbid,
)
async def _resolve_cover(
self,
album: Album,
*,
storage_uri: str,
release_group_mbid: str | None,
) -> None:
"""Fill in an album cover when it has none. Source order mirrors the
tag-first pipeline: embedded artwork (offline) → Cover Art Archive
(network, by release-group). Best-effort — any failure is swallowed so a
missing cover never affects enrichment status."""
if album.cover_path:
return # already has one — never overwrite (idempotent)
cover = await self._extract_cover(storage_uri)
if cover is None:
cover = await self._fetch_cover(release_group_mbid)
if cover is None:
return
try:
key = await self._save_cover(album.id, cover)
await self._albums.set_cover_path(album.id, key)
log.info("cover_resolved", album_id=str(album.id), content_type=cover.content_type)
except Exception:
log.warning("cover_save_failed", album_id=str(album.id))
async def _extract_cover(self, storage_uri: str) -> CoverArt | None:
if self._cover_extractor is None:
return None
try:
async with self._storage.as_local_path(storage_uri) as path:
return await self._cover_extractor.extract(path)
except Exception:
log.warning("cover_extract_step_failed", storage_uri=storage_uri)
return None
async def _fetch_cover(self, release_group_mbid: str | None) -> CoverArt | None:
if self._cover_provider is None or not release_group_mbid:
return None
if not self._cover_provider.is_available():
return None
try:
return await self._cover_provider.fetch_release_group(release_group_mbid)
except Exception:
log.warning("cover_fetch_step_failed", release_group=release_group_mbid)
return None
async def _save_cover(self, album_id: uuid.UUID, cover: CoverArt) -> str:
key = f"covers/{album_id}.{cover.extension}"
with tempfile.NamedTemporaryFile(suffix=f".{cover.extension}") as tmp:
tmp.write(cover.data)
tmp.flush()
await self._storage.save_file(key, Path(tmp.name))
return key
def _opt_str(*values: str | None) -> str | None:
for value in values:
if value:
return value
return None
def _first_int(*values: int | None) -> int | None:
for value in values:
if value is not None:
return value
return None
+122
View File
@@ -0,0 +1,122 @@
"""RemoteLibraryService — save-to-library + materialize for remote browse hits
(plan: Model C, on-demand YTM library).
Two operations:
* ``save_remote`` persists a placeholder ``Track`` (``availability="remote"``,
``storage_uri=None``) for a remote browse hit. Idempotent on
``(source, source_id)`` — CLAUDE.md dedup.
* ``request_materialize`` lazily fills a placeholder's audio in place: it
creates (or reuses) a ``DownloadJob`` pointing at the existing track and
enqueues the materialize worker, which calls ``TrackRepository.materialize``
on completion. ``track.id`` never changes (CLAUDE.md), so likes/playlists/
queue entries referencing the placeholder keep working once it's filled in.
"""
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from app.domain.entities.download import DownloadJob
from app.domain.entities.track import Track
from app.domain.errors import NotFoundError, ValidationError
from app.domain.ports import ArtistRepository, DownloadJobRepository, TrackRepository
_UNKNOWN_ARTIST = "Unknown Artist"
# (job_id) -> None — enqueue the materialize worker, same deferred pattern as
# download/enrich enqueuers.
MaterializeEnqueuer = Callable[[uuid.UUID], Awaitable[None]]
@dataclass(frozen=True)
class MaterializeOutcome:
"""Result of requesting materialization.
``job`` is ``None`` when the track is already ``local`` — nothing to do,
the caller can stream immediately. Otherwise it's the (new or already
in-flight) job filling the placeholder."""
track: Track
job: DownloadJob | None
class RemoteLibraryService:
def __init__(
self,
*,
tracks: TrackRepository,
artists: ArtistRepository,
jobs: DownloadJobRepository,
enqueue_materialize: MaterializeEnqueuer | None = None,
) -> None:
self._tracks = tracks
self._artists = artists
self._jobs = jobs
self._enqueue_materialize = enqueue_materialize
async def save_remote(
self,
*,
source: str,
source_id: str,
title: str,
artist: str | None,
added_by: uuid.UUID | None,
) -> Track:
"""Persist a placeholder for a remote browse hit. Idempotent: a hit
already saved (by ``(source, source_id)``) is returned as-is."""
source_id = source_id.strip()
if not source_id:
raise ValidationError("A source_id is required to save.")
existing = await self._tracks.get_by_source(source, source_id)
if existing is not None:
return existing
artist_entity = await self._artists.get_or_create(artist or _UNKNOWN_ARTIST)
return await self._tracks.add(
id=uuid.uuid4(),
title=title,
artist_id=artist_entity.id,
storage_uri=None,
file_format=None,
file_size=None,
source=source,
source_id=source_id,
metadata_status="pending",
added_by=added_by,
availability="remote",
)
async def request_materialize(
self, track_id: uuid.UUID, *, requested_by: uuid.UUID | None
) -> MaterializeOutcome:
"""Kick off (or report on) materializing a placeholder track.
Already-local tracks are a no-op (``job=None``). A track with no
remote ``source_id`` (e.g. a deleted upload row reused for something
else) can't be materialized."""
track = await self._tracks.get_by_id(track_id)
if track is None:
raise NotFoundError(f"Track {track_id} not found.")
if track.availability == "local":
return MaterializeOutcome(track=track, job=None)
if track.source_id is None:
raise ValidationError("Track has no remote source to materialize from.")
active = await self._jobs.get_active_for_source(track.source, track.source_id)
if active is not None:
return MaterializeOutcome(track=track, job=active)
job = await self._jobs.add(
source=track.source,
source_id=track.source_id,
query=None,
requested_by=requested_by,
)
await self._jobs.set_status(job.id, status="queued", track_id=track.id)
if self._enqueue_materialize is not None:
await self._enqueue_materialize(job.id)
refreshed = await self._jobs.get_by_id(job.id)
return MaterializeOutcome(track=track, job=refreshed if refreshed is not None else job)
+6 -3
View File
@@ -72,16 +72,19 @@ class StreamingService:
track = await self._tracks.get_by_id(track_id) track = await self._tracks.get_by_id(track_id)
if track is None: if track is None:
raise NotFoundError("Track not found.") raise NotFoundError("Track not found.")
storage_uri = track.storage_uri
if storage_uri is None:
raise NotFoundError("Track is not yet downloaded.")
stat = await self._storage.stat(track.storage_uri) stat = await self._storage.stat(storage_uri)
total_size = stat.size total_size = stat.size
content_type = stat.content_type or _FORMAT_CONTENT_TYPE.get( content_type = stat.content_type or _FORMAT_CONTENT_TYPE.get(
track.file_format.lower(), "application/octet-stream" (track.file_format or "").lower(), "application/octet-stream"
) )
start, end, is_partial = _parse_range(range_header, total_size) start, end, is_partial = _parse_range(range_header, total_size)
stream, _ = await self._storage.open_range(track.storage_uri, start, end) stream, _ = await self._storage.open_range(storage_uri, start, end)
actual_end = end if end is not None else total_size - 1 actual_end = end if end is not None else total_size - 1
content_length = actual_end - start + 1 content_length = actual_end - start + 1
+100
View File
@@ -0,0 +1,100 @@
"""SubsonicAuthService — app-password lifecycle + Subsonic auth verification.
The Subsonic protocol authenticates with either ``t=md5(password+salt)`` (+``s``)
or the legacy ``p=`` (plaintext or ``enc:<hex>``). Both need a *recoverable*
secret server-side, so Subsonic clients authenticate against a dedicated,
high-entropy app-password — never the argon2 login password. That app-password
is encrypted at rest (:class:`~app.domain.ports.SubsonicCipher`) and decrypted
only here, to verify a request or to reveal it for copying into a client.
This is an adapter over the existing user store; it adds no business state of its
own beyond the app-password column (CLAUDE.md: Subsonic is an adapter, not a
reimplementation).
"""
import hashlib
import hmac
import uuid
from app.core.security import generate_subsonic_password
from app.domain.entities import User
from app.domain.errors import AuthenticationError, NotFoundError, ValidationError
from app.domain.ports import SubsonicCipher, UserRepository
def _md5_hex(value: str) -> str:
return hashlib.md5(value.encode("utf-8"), usedforsecurity=False).hexdigest()
def _decode_legacy_password(p: str) -> str:
"""Decode a Subsonic ``p`` param: ``enc:<hex>`` (hex-encoded) or plaintext."""
if p.startswith("enc:"):
try:
return bytes.fromhex(p[4:]).decode("utf-8")
except ValueError as exc:
raise AuthenticationError("Wrong username or password.") from exc
return p
class SubsonicAuthService:
def __init__(self, *, users: UserRepository, cipher: SubsonicCipher) -> None:
self._users = users
self._cipher = cipher
async def authenticate(
self,
*,
username: str | None,
token: str | None,
salt: str | None,
password: str | None,
) -> User:
"""Resolve Subsonic query auth params to a domain :class:`User`.
Raises :class:`ValidationError` (Subsonic code 10) for missing params and
:class:`AuthenticationError` (code 40) for any credential mismatch — an
unknown user is reported identically to a wrong password (no enumeration).
"""
if not username:
raise ValidationError("Required parameter 'u' is missing.")
if not ((token and salt) or password):
raise ValidationError("Required authentication parameter is missing.")
creds = await self._users.get_subsonic_credentials_by_username(username)
if creds is None or not creds.user.is_active or creds.password_enc is None:
raise AuthenticationError("Wrong username or password.")
app_password = self._cipher.decrypt(creds.password_enc)
if token and salt:
expected = _md5_hex(app_password + salt)
if not hmac.compare_digest(expected, token.lower()):
raise AuthenticationError("Wrong username or password.")
else:
assert password is not None # guaranteed by the missing-param check above
supplied = _decode_legacy_password(password)
if not hmac.compare_digest(supplied, app_password):
raise AuthenticationError("Wrong username or password.")
return creds.user
async def rotate(self, user_id: uuid.UUID) -> str:
"""Generate a fresh app-password, store it encrypted, return the plaintext."""
await self._require_user(user_id)
password = generate_subsonic_password()
await self._users.set_subsonic_password_enc(user_id, self._cipher.encrypt(password))
return password
async def reveal(self, user_id: uuid.UUID) -> str:
"""Return the current app-password, generating one on first access."""
await self._require_user(user_id)
enc = await self._users.get_subsonic_password_enc(user_id)
if enc is None:
return await self.rotate(user_id)
return self._cipher.decrypt(enc)
async def _require_user(self, user_id: uuid.UUID) -> User:
user = await self._users.get_by_id(user_id)
if user is None:
raise NotFoundError("User not found.")
return user
+7 -1
View File
@@ -5,6 +5,7 @@ import hashlib
import os import os
import tempfile import tempfile
import uuid import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Protocol from typing import Protocol
@@ -14,6 +15,8 @@ import anyio
from app.domain.entities.user import User from app.domain.entities.user import User
from app.domain.ports import ArtistRepository, FileStorage, TrackRepository from app.domain.ports import ArtistRepository, FileStorage, TrackRepository
EnrichEnqueuer = Callable[[uuid.UUID], Awaitable[None]]
class UploadFileProtocol(Protocol): class UploadFileProtocol(Protocol):
filename: str | None filename: str | None
@@ -49,11 +52,13 @@ class UploadService:
artists: ArtistRepository, artists: ArtistRepository,
storage: FileStorage, storage: FileStorage,
tmp_dir: Path | None = None, tmp_dir: Path | None = None,
enqueue_enrich: EnrichEnqueuer | None = None,
) -> None: ) -> None:
self._tracks = tracks self._tracks = tracks
self._artists = artists self._artists = artists
self._storage = storage self._storage = storage
self._tmp_dir = tmp_dir self._tmp_dir = tmp_dir
self._enqueue_enrich = enqueue_enrich
async def handle_upload( async def handle_upload(
self, self,
@@ -105,7 +110,8 @@ class UploadService:
await self._storage.delete(key) await self._storage.delete(key)
raise raise
# TODO(1D): enqueue metadata enrichment task if self._enqueue_enrich is not None:
await self._enqueue_enrich(track.id)
return UploadResult( return UploadResult(
track_id=track.id, track_id=track.id,
+66 -1
View File
@@ -5,12 +5,25 @@ development). Access the cached singleton via :func:`get_settings`.
""" """
from functools import lru_cache from functools import lru_cache
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from pydantic import Field, SecretStr, field_validator from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# App identity for outbound API calls (e.g. the MusicBrainz/AcoustID
# User-Agent). Name is fixed; version comes from the installed package.
APP_NAME = "MCMA"
_PROJECT_URL = "https://git.ollyhearn.ru/olly/mcma-backend"
def app_version() -> str:
try:
return version("mcma-backend")
except PackageNotFoundError:
return "0.0.0"
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
@@ -44,14 +57,31 @@ class Settings(BaseSettings):
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
access_token_ttl_seconds: int = 60 * 15 # 15 min access_token_ttl_seconds: int = 60 * 15 # 15 min
refresh_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 30 days (offline-first) refresh_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 30 days (offline-first)
# Public self-service sign-up. When disabled, accounts are created
# admin-only (POST /admin/users). Registered users are never superusers.
allow_registration: bool = True
# -- subsonic ---------------------------------------------------------
# Symmetric key (any string) used to encrypt each user's recoverable
# Subsonic app-password at rest. A Fernet key is derived from it; rotating
# this value renders stored app-passwords undecryptable (rotate them too).
subsonic_secret_key: SecretStr = SecretStr("change-me-subsonic-key")
# -- media / storage -------------------------------------------------- # -- media / storage --------------------------------------------------
media_path: Path = Path("/data/media") media_path: Path = Path("/data/media")
transcode_cache_path: Path = Path("/data/transcode-cache") transcode_cache_path: Path = Path("/data/transcode-cache")
max_parallel_downloads: int = 2 max_parallel_downloads: int = 2
# How many times the download worker retries a failed fetch (yt-dlp fails
# often) before marking the job ``failed`` — exponential backoff between tries.
download_max_retries: int = 3
storage_backend: Literal["local", "s3"] = "local" storage_backend: Literal["local", "s3"] = "local"
upload_tmp_dir: Path | None = None upload_tmp_dir: Path | None = None
# -- sources ----------------------------------------------------------
# Mounted folder the ``local`` source indexes (copies into managed storage).
# Unset → the local source is simply not registered.
local_media_import_path: Path | None = None
# -- S3 storage (deferred; set storage_backend="s3" to use) ---------- # -- S3 storage (deferred; set storage_backend="s3" to use) ----------
s3_endpoint_url: str | None = None s3_endpoint_url: str | None = None
s3_bucket: str | None = None s3_bucket: str | None = None
@@ -62,9 +92,34 @@ class Settings(BaseSettings):
# -- external services (all optional; graceful degradation) ---------- # -- external services (all optional; graceful degradation) ----------
ml_service_url: str | None = None ml_service_url: str | None = None
acoustid_api_key: SecretStr | None = None acoustid_api_key: SecretStr | None = None
musicbrainz_user_agent: str = "mcma-backend/0.1.0 ( https://github.com/your/repo )" acoustid_api_url: str = "https://api.acoustid.org/v2/lookup"
# Above this AcoustID match score, trust the acoustic identification over
# embedded file tags (which are frequently junk on downloaded files —
# e.g. "Music Track" / "Sound_12345"). Below it, keep the tag-first merge.
acoustid_trust_score: float = 0.85
# MusicBrainz/AcoustID require a meaningful User-Agent identifying the
# application and a way to contact its maintainer (see
# https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting). Self-hosted
# deployments should set their own contact email; see
# ``musicbrainz_user_agent`` below for how it's used.
musicbrainz_owner_email: str | None = None
# ``youtube`` fetch source (search + download via ytmusicapi/yt-dlp). Enabled
# by default; the source still reports unavailable if the libs aren't present.
youtube_enabled: bool = True
# Optional cookies file (Netscape format) for yt-dlp — lets it fetch
# age-restricted / region-locked items via an authenticated session.
youtube_cookies_path: Path | None = None youtube_cookies_path: Path | None = None
# -- enrichment -------------------------------------------------------
# ``fpcalc`` (Chromaprint) binary; resolved on PATH by default. The Docker
# image installs it via libchromaprint-tools.
fpcalc_path: str = "fpcalc"
# Cover Art Archive — network fallback for album covers (after embedded art).
# Disable to keep enrichment fully offline; embedded artwork still works.
coverart_enabled: bool = True
coverart_base_url: str = "https://coverartarchive.org"
@field_validator("database_url") @field_validator("database_url")
@classmethod @classmethod
def _require_async_driver(cls, v: str) -> str: def _require_async_driver(cls, v: str) -> str:
@@ -76,6 +131,16 @@ class Settings(BaseSettings):
def is_prod(self) -> bool: def is_prod(self) -> bool:
return self.environment == "prod" return self.environment == "prod"
@property
def musicbrainz_user_agent(self) -> str:
"""User-Agent sent to MusicBrainz/AcoustID: ``MCMA/<version> ( <contact> )``.
Falls back to the project URL if the deployment hasn't set
``musicbrainz_owner_email``.
"""
contact = self.musicbrainz_owner_email or _PROJECT_URL
return f"{APP_NAME}/{app_version()} ( {contact} )"
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
+38
View File
@@ -6,16 +6,54 @@ to this module (CLAUDE.md: security is a cross-cutting concern in ``core``).
Higher layers depend only on the Protocols, never on pwdlib/pyjwt directly. Higher layers depend only on the Protocols, never on pwdlib/pyjwt directly.
""" """
import base64
import datetime as dt import datetime as dt
import hashlib
import secrets
import uuid import uuid
import jwt import jwt
from cryptography.fernet import Fernet, InvalidToken
from pwdlib import PasswordHash from pwdlib import PasswordHash
from app.core.config import Settings from app.core.config import Settings
from app.domain.errors import AuthenticationError from app.domain.errors import AuthenticationError
from app.domain.tokens import IssuedToken, TokenClaims, TokenType from app.domain.tokens import IssuedToken, TokenClaims, TokenType
# Length (in bytes of entropy) of a generated Subsonic app-password. 18 bytes of
# url-safe base64 → 24 characters, well above the Subsonic auth threat model.
_SUBSONIC_PASSWORD_ENTROPY_BYTES = 18
def generate_subsonic_password() -> str:
"""A fresh, high-entropy Subsonic app-password (url-safe, ~24 chars)."""
return secrets.token_urlsafe(_SUBSONIC_PASSWORD_ENTROPY_BYTES)
class SubsonicPasswordCipher:
"""Symmetric encrypt/decrypt for the recoverable Subsonic app-password.
Subsonic auth (``t=md5(password+salt)`` and legacy ``p=``) needs the plaintext
password server-side, so — unlike the argon2-hashed login password — the
app-password is stored *encrypted*, not hashed. A Fernet key (AES-128-CBC +
HMAC) is derived from the configured secret; the plaintext key never touches
the DB. Implements :class:`app.domain.ports.SubsonicCipher`.
"""
def __init__(self, secret_key: str) -> None:
digest = hashlib.sha256(secret_key.encode("utf-8")).digest()
self._fernet = Fernet(base64.urlsafe_b64encode(digest))
def encrypt(self, plaintext: str) -> str:
return self._fernet.encrypt(plaintext.encode("utf-8")).decode("ascii")
def decrypt(self, token: str) -> str:
try:
return self._fernet.decrypt(token.encode("ascii")).decode("utf-8")
except InvalidToken as exc:
# Wrong/rotated secret key, or corrupted ciphertext.
raise AuthenticationError("Stored Subsonic password could not be decrypted.") from exc
class Argon2PasswordHasher: class Argon2PasswordHasher:
"""argon2id hasher with sensible defaults from pwdlib.""" """argon2id hasher with sensible defaults from pwdlib."""
+19 -2
View File
@@ -1,21 +1,38 @@
"""Domain entities and value objects — pure, framework-free.""" """Domain entities and value objects — pure, framework-free."""
from app.domain.entities.album import Album from app.domain.entities.album import Album
from app.domain.entities.cover import CoverArt
from app.domain.entities.download import DownloadJob
from app.domain.entities.history import PlayHistoryEntry from app.domain.entities.history import PlayHistoryEntry
from app.domain.entities.like import Like from app.domain.entities.like import Like
from app.domain.entities.metadata import AudioTags, Fingerprint, RecordingMatch
from app.domain.entities.playlist import Playlist from app.domain.entities.playlist import Playlist
from app.domain.entities.storage import ObjectStat from app.domain.entities.storage import (
DiskUsage,
FormatBreakdown,
LibraryStats,
ObjectStat,
)
from app.domain.entities.track import Artist, Track from app.domain.entities.track import Artist, Track
from app.domain.entities.user import Credentials, User from app.domain.entities.user import Credentials, SubsonicCredentials, User
__all__ = [ __all__ = [
"Album", "Album",
"Artist", "Artist",
"AudioTags",
"CoverArt",
"Credentials", "Credentials",
"DiskUsage",
"DownloadJob",
"Fingerprint",
"FormatBreakdown",
"LibraryStats",
"Like", "Like",
"ObjectStat", "ObjectStat",
"PlayHistoryEntry", "PlayHistoryEntry",
"Playlist", "Playlist",
"RecordingMatch",
"SubsonicCredentials",
"Track", "Track",
"User", "User",
] ]
+2
View File
@@ -13,5 +13,7 @@ class Album:
year: int | None year: int | None
cover_path: str | None cover_path: str | None
musicbrainz_id: str | None musicbrainz_id: str | None
source: str | None
source_id: str | None
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
+28
View File
@@ -0,0 +1,28 @@
"""Cover-art value object — raw image bytes plus their MIME type.
Crosses the domain boundary between the cover sources (embedded extractor,
Cover Art Archive) and the storage/serving layers. The bytes are the encoded
image as-is; we never decode/resize in Phase 1.
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class CoverArt:
data: bytes
content_type: str # "image/jpeg" | "image/png" | …
@property
def extension(self) -> str:
"""File extension for the content type (no leading dot)."""
return _EXT_BY_TYPE.get(self.content_type.lower(), "jpg")
_EXT_BY_TYPE: dict[str, str] = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
}
+26
View File
@@ -0,0 +1,26 @@
"""Download job domain entity (plan §6.1).
A queued fetch from an external source, tracked through its lifecycle so the UI
download manager (screen §A5) can show progress, errors, and retries. The
``status`` strings mirror :class:`~app.infrastructure.db.models.enums.DownloadStatus`.
"""
import datetime as dt
import uuid
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class DownloadJob:
id: uuid.UUID
source: str
source_id: str | None
query: str | None
requested_by: uuid.UUID | None
status: str
progress: float
error_message: str | None
retry_count: int
track_id: uuid.UUID | None
created_at: dt.datetime
updated_at: dt.datetime
+54
View File
@@ -0,0 +1,54 @@
"""Value objects for the metadata-enrichment pipeline (plan §6.2).
Pure data carriers between the enrichment service and its adapters (tag reader,
fingerprinter, AcoustID). No framework imports — these cross the domain boundary.
"""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class AudioTags:
"""Embedded tags read from the file itself (ID3 / Vorbis / MP4 …).
Every field is optional — files are tagged inconsistently. The reader fills
what it can and leaves the rest ``None`` for downstream identification.
"""
title: str | None = None
artist: str | None = None
album: str | None = None
album_artist: str | None = None
genre: str | None = None
year: int | None = None
track_number: int | None = None
duration_seconds: int | None = None
bitrate: int | None = None
@dataclass(frozen=True, slots=True)
class Fingerprint:
"""Chromaprint fingerprint plus the decoded duration (both needed by AcoustID)."""
fingerprint: str
duration_seconds: int
@dataclass(frozen=True, slots=True)
class RecordingMatch:
"""A single AcoustID result, flattened to the fields enrichment cares about.
``acoustid`` is the stable AcoustID identifier (a UUID) — used as the
dedup key persisted on ``track.acoustid_fingerprint`` (fits the 64-char
column; the raw fingerprint does not). ``recording_mbid`` is the MusicBrainz
recording id when present.
"""
acoustid: str
score: float
recording_mbid: str | None = None
release_group_mbid: str | None = None
title: str | None = None
artist: str | None = None
album: str | None = None
year: int | None = None
+37
View File
@@ -1,5 +1,6 @@
"""Value objects for file storage.""" """Value objects for file storage."""
import datetime as dt
from dataclasses import dataclass from dataclasses import dataclass
@@ -7,3 +8,39 @@ from dataclasses import dataclass
class ObjectStat: class ObjectStat:
size: int size: int
content_type: str | None content_type: str | None
@dataclass(frozen=True, slots=True)
class DiskUsage:
"""Capacity of the volume backing the media store. ``None`` for backends
(e.g. object stores) that expose no notion of total disk capacity."""
total: int
used: int
free: int
@dataclass(frozen=True, slots=True)
class FormatBreakdown:
"""Per-container-format slice of the library (e.g. ``flac`` → 312 tracks)."""
file_format: str
track_count: int
total_size: int
@dataclass(frozen=True, slots=True)
class LibraryStats:
"""Aggregate facts about everything the instance has stored. Computed from
the catalogue (DB), not the filesystem — ``total_size`` is the sum of the
recorded ``file_size`` of every track."""
total_tracks: int
total_size: int
total_duration_seconds: int
by_format: list[FormatBreakdown]
by_metadata_status: dict[str, int]
by_source: dict[str, int]
largest_track_size: int
earliest_added: dt.datetime | None
latest_added: dt.datetime | None
+9 -3
View File
@@ -9,6 +9,8 @@ from dataclasses import dataclass
class Artist: class Artist:
id: uuid.UUID id: uuid.UUID
name: str name: str
source: str | None
source_id: str | None
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
@@ -19,14 +21,18 @@ class Track:
title: str title: str
artist_id: uuid.UUID artist_id: uuid.UUID
album_id: uuid.UUID | None album_id: uuid.UUID | None
storage_uri: str storage_uri: str | None
file_format: str file_format: str | None
file_size: int file_size: int | None
source: str source: str
source_id: str source_id: str
duration_seconds: int | None duration_seconds: int | None
genre: str | None genre: str | None
year: int | None year: int | None
track_number: int | None
metadata_status: str metadata_status: str
metadata_error: str | None
enriched_at: dt.datetime | None
availability: str
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
+11
View File
@@ -31,3 +31,14 @@ class Credentials:
user: User user: User
password_hash: str password_hash: str
@dataclass(frozen=True, slots=True)
class SubsonicCredentials:
"""A user paired with their *encrypted* Subsonic app-password.
``password_enc`` is ``None`` until the user generates one. Stays inside the
application layer; the plaintext is only recovered for auth verification."""
user: User
password_enc: str | None
+253 -5
View File
@@ -7,23 +7,37 @@ are bound to these ports at the composition root (``app.api.deps``).
import datetime as dt import datetime as dt
import uuid import uuid
from collections.abc import AsyncIterator from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
from contextlib import AbstractAsyncContextManager from contextlib import AbstractAsyncContextManager
from pathlib import Path from pathlib import Path
from typing import Protocol from typing import Protocol
from app.domain.entities import ( from app.domain.entities import (
Album, Album,
AudioTags,
CoverArt,
Credentials, Credentials,
DiskUsage,
DownloadJob,
Fingerprint,
LibraryStats,
Like, Like,
ObjectStat, ObjectStat,
PlayHistoryEntry, PlayHistoryEntry,
Playlist, Playlist,
RecordingMatch,
SubsonicCredentials,
User, User,
) )
from app.domain.entities.track import Artist, Track from app.domain.entities.track import Artist, Track
from app.domain.sources import DownloadResult, RawMetadata, SearchResult, SourceFile, SourceInfo
from app.domain.tokens import IssuedToken, TokenClaims, TokenType from app.domain.tokens import IssuedToken, TokenClaims, TokenType
# A fetch source reports download progress as a fraction in [0.0, 1.0]. It's a
# plain callback (not a port) because it's an inversion of control supplied per
# call by the worker, which persists it to the download job.
ProgressCallback = Callable[[float], Awaitable[None]]
class UserRepository(Protocol): class UserRepository(Protocol):
async def get_by_id(self, user_id: uuid.UUID) -> User | None: ... async def get_by_id(self, user_id: uuid.UUID) -> User | None: ...
@@ -34,6 +48,19 @@ class UserRepository(Protocol):
async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User: ... async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User: ...
async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User: ... async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User: ...
async def count(self) -> int: ... async def count(self) -> int: ...
# -- subsonic app-password (recoverable, encrypted at rest) ----------
async def get_subsonic_credentials_by_username(
self, username: str
) -> SubsonicCredentials | None: ...
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None: ...
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None: ...
class SubsonicCipher(Protocol):
"""Symmetric encrypt/decrypt for the recoverable Subsonic app-password."""
def encrypt(self, plaintext: str) -> str: ...
def decrypt(self, token: str) -> str: ...
class RefreshTokenRepository(Protocol): class RefreshTokenRepository(Protocol):
@@ -79,10 +106,19 @@ class FileStorage(Protocol):
async def exists(self, key: str) -> bool: ... async def exists(self, key: str) -> bool: ...
async def delete(self, key: str) -> None: ... async def delete(self, key: str) -> None: ...
def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]: ... def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]: ...
async def disk_usage(self) -> DiskUsage | None:
"""Capacity of the volume backing the store, or ``None`` when the
backend has no addressable disk (e.g. an object store)."""
...
class ArtistRepository(Protocol): class ArtistRepository(Protocol):
async def get_or_create(self, name: str) -> Artist: ... async def get_or_create(self, name: str) -> Artist: ...
async def get_or_create_remote(self, *, name: str, source: str, source_id: str) -> Artist:
"""Resolve/create an artist bound to a remote ``(source, source_id)``
(lazy materialization save-to-library)."""
...
async def get_by_id(self, artist_id: uuid.UUID) -> Artist | None: ... async def get_by_id(self, artist_id: uuid.UUID) -> Artist | None: ...
async def get_many(self, ids: list[uuid.UUID]) -> list[Artist]: ... async def get_many(self, ids: list[uuid.UUID]) -> list[Artist]: ...
async def list(self, *, q: str | None, limit: int, offset: int) -> list[Artist]: ... async def list(self, *, q: str | None, limit: int, offset: int) -> list[Artist]: ...
@@ -100,21 +136,41 @@ class TrackRepository(Protocol):
id: uuid.UUID, id: uuid.UUID,
title: str, title: str,
artist_id: uuid.UUID, artist_id: uuid.UUID,
storage_uri: str, storage_uri: str | None,
file_format: str, file_format: str | None,
file_size: int, file_size: int | None,
source: str, source: str,
source_id: str, source_id: str,
metadata_status: str, metadata_status: str,
added_by: uuid.UUID | None, added_by: uuid.UUID | None,
availability: str = ...,
) -> Track: ... ) -> Track: ...
async def materialize(
self,
track_id: uuid.UUID,
*,
storage_uri: str,
file_format: str,
file_size: int,
bitrate: int | None,
) -> Track:
"""Fill in a remote placeholder's audio fields after a download
(lazy materialization), flipping ``availability`` to ``local``."""
...
async def delete(self, track_id: uuid.UUID) -> None: ... async def delete(self, track_id: uuid.UUID) -> None: ...
# genres / library_stats must come before ``list`` — the method named
# ``list`` shadows the builtin in later annotations (same pattern as
# AlbumRepository below).
async def genres(self) -> list[tuple[str, int]]: ...
async def library_stats(self) -> LibraryStats: ...
async def list( async def list(
self, self,
*, *,
artist_id: uuid.UUID | None, artist_id: uuid.UUID | None,
album_id: uuid.UUID | None, album_id: uuid.UUID | None,
q: str | None, q: str | None,
source: str | None = None,
sort_by: str, sort_by: str,
order: str, order: str,
limit: int, limit: int,
@@ -126,6 +182,7 @@ class TrackRepository(Protocol):
artist_id: uuid.UUID | None, artist_id: uuid.UUID | None,
album_id: uuid.UUID | None, album_id: uuid.UUID | None,
q: str | None, q: str | None,
source: str | None = None,
) -> int: ... ) -> int: ...
async def update( async def update(
self, self,
@@ -135,9 +192,60 @@ class TrackRepository(Protocol):
genre: str | None, genre: str | None,
year: int | None, year: int | None,
) -> Track: ... ) -> Track: ...
async def apply_enrichment(
self,
track_id: uuid.UUID,
*,
title: str,
artist_id: uuid.UUID,
album_id: uuid.UUID | None,
genre: str | None,
year: int | None,
track_number: int | None,
duration_seconds: int | None,
bitrate: int | None,
acoustid_fingerprint: str | None,
musicbrainz_id: str | None,
metadata_status: str,
metadata_error: str | None = None,
) -> Track:
"""Persist auto-enrichment results. Nullable fields are filled only when
a non-``None`` value is supplied (re-enrich never erases prior data);
``title``/``artist_id``/``metadata_status`` are always written, and the
run's outcome (``metadata_error`` + completion time) is always stamped.
Callers must not invoke this for ``metadata_status == 'manual'`` tracks."""
...
async def mark_enrichment_failed(self, track_id: uuid.UUID, *, error: str) -> None:
"""Record that an enrichment run crashed unexpectedly: set ``failed`` +
the error reason. A no-op for ``manual`` or missing tracks."""
...
class AlbumRepository(Protocol): class AlbumRepository(Protocol):
async def get_or_create(
self,
*,
title: str,
artist_id: uuid.UUID,
year: int | None,
musicbrainz_id: str | None,
) -> Album: ...
async def get_or_create_remote(
self,
*,
title: str,
artist_id: uuid.UUID,
year: int | None,
musicbrainz_id: str | None,
source: str,
source_id: str,
) -> Album:
"""Resolve/create an album bound to a remote ``(source, source_id)``
(lazy materialization save-to-library)."""
...
async def set_cover_path(self, album_id: uuid.UUID, cover_path: str) -> None: ...
async def get_by_id(self, album_id: uuid.UUID) -> Album | None: ... async def get_by_id(self, album_id: uuid.UUID) -> Album | None: ...
async def get_many(self, ids: list[uuid.UUID]) -> list[Album]: ... async def get_many(self, ids: list[uuid.UUID]) -> list[Album]: ...
async def count(self, *, artist_id: uuid.UUID | None, q: str | None) -> int: ... async def count(self, *, artist_id: uuid.UUID | None, q: str | None) -> int: ...
@@ -145,7 +253,14 @@ class AlbumRepository(Protocol):
async def track_count_many(self, album_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ... async def track_count_many(self, album_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ...
# list must come after any method using list[...] in its signature (name shadowing) # list must come after any method using list[...] in its signature (name shadowing)
async def list( async def list(
self, *, artist_id: uuid.UUID | None, q: str | None, limit: int, offset: int self,
*,
artist_id: uuid.UUID | None,
q: str | None,
limit: int,
offset: int,
sort_by: str = "title",
order: str = "asc",
) -> list[Album]: ... ) -> list[Album]: ...
@@ -197,3 +312,136 @@ class HistoryRepository(Protocol):
self, *, user_id: uuid.UUID, limit: int, offset: int self, *, user_id: uuid.UUID, limit: int, offset: int
) -> list[PlayHistoryEntry]: ... ) -> list[PlayHistoryEntry]: ...
async def count(self, *, user_id: uuid.UUID) -> int: ... async def count(self, *, user_id: uuid.UUID) -> int: ...
class DownloadJobRepository(Protocol):
"""Persistence for download jobs (plan §6.1). Drives the §A5 download manager
and the worker's retry/backoff loop."""
async def add(
self,
*,
source: str,
source_id: str | None,
query: str | None,
requested_by: uuid.UUID | None,
) -> DownloadJob: ...
async def get_by_id(self, job_id: uuid.UUID) -> DownloadJob | None: ...
async def get_active_for_source(self, source: str, source_id: str) -> DownloadJob | None:
"""An unfinished (queued/downloading/enriching) job for the same item, if
any — used to dedup before enqueuing so a double-click can't queue twice."""
...
async def list(
self,
*,
requested_by: uuid.UUID | None,
status: str | None,
limit: int,
offset: int,
) -> list[DownloadJob]: ...
async def count(self, *, requested_by: uuid.UUID | None, status: str | None) -> int: ...
async def set_status(
self,
job_id: uuid.UUID,
*,
status: str,
error_message: str | None = None,
track_id: uuid.UUID | None = None,
) -> None: ...
async def set_progress(self, job_id: uuid.UUID, progress: float) -> None: ...
async def increment_retry(self, job_id: uuid.UUID) -> int:
"""Bump ``retry_count`` and return the new value."""
...
async def delete(self, job_id: uuid.UUID) -> None: ...
async def failure_rate(self, source: str, *, since: dt.datetime) -> float:
"""Fraction of jobs for ``source`` created since ``since`` that ended
``failed`` (0.0 when there are none) — drives the §A5 "source unhealthy"
banner."""
...
class SourceBackend(Protocol):
"""A registered source of tracks (mounted folder, YouTube, …).
``name`` is the stable identifier used in URLs and stored on ``track.source``.
"""
name: str
def info(self) -> SourceInfo: ...
def is_available(self) -> bool: ...
class IndexableSource(SourceBackend, Protocol):
"""A source that enumerates files already on disk (e.g. the local folder)."""
def scan(self) -> Iterator[SourceFile]: ...
class SearchableSource(SourceBackend, Protocol):
"""A source that can be searched by free text (e.g. YouTube Music).
Returns ``[]`` (never raises) on no results / the service being down — the
discover screen degrades to "nothing found" rather than erroring."""
async def search(self, query: str, *, limit: int) -> list[SearchResult]: ...
class FetchableSource(SourceBackend, Protocol):
"""A source that can download a previously-discovered item to local disk.
``fetch`` resolves a ``source_id`` (from a :class:`SearchResult`) into a file
and reports progress through ``on_progress``. It runs only in a worker (heavy
I/O) and raises on failure so the download task can retry with backoff."""
async def fetch(
self, source_id: str, *, on_progress: ProgressCallback | None = None
) -> DownloadResult: ...
async def get_metadata(self, source_id: str) -> RawMetadata | None: ...
# -- metadata enrichment (plan §6.2) -----------------------------------------
class AudioTagReader(Protocol):
"""Reads embedded tags from a local audio file. Returns ``None`` only when
the file can't be parsed at all — never raises (graceful degradation)."""
async def read(self, path: Path) -> AudioTags | None: ...
class AudioFingerprinter(Protocol):
"""Chromaprint (fpcalc) wrapper. ``is_available`` reflects whether the
binary is present; ``calculate`` returns ``None`` on any failure."""
def is_available(self) -> bool: ...
async def calculate(self, path: Path) -> Fingerprint | None: ...
class AcoustIdClient(Protocol):
"""AcoustID lookup. ``is_available`` is False without an API key (the whole
fingerprint path is then skipped). ``lookup`` returns the best match or
``None`` (no result / service down), never raising. ``lookup_all`` returns
the same candidates ranked by confidence (``[]`` on no result / unavailable
/ error), for the metadata editor's match picker."""
def is_available(self) -> bool: ...
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None: ...
async def lookup_all(self, fingerprint: Fingerprint) -> list[RecordingMatch]: ...
class CoverArtExtractor(Protocol):
"""Pulls embedded cover art out of a local audio file (offline, no network).
Returns ``None`` when the file has no picture or can't be parsed — never raises."""
async def extract(self, path: Path) -> CoverArt | None: ...
class CoverArtProvider(Protocol):
"""Fetches cover art from an external service (Cover Art Archive) by
MusicBrainz release-group id. ``is_available`` may gate it off; ``fetch``
returns ``None`` (not found / service down), never raising."""
def is_available(self) -> bool: ...
async def fetch_release_group(self, release_group_mbid: str) -> CoverArt | None: ...
+95
View File
@@ -0,0 +1,95 @@
"""Source-backend value objects — framework-free.
A *source* is a place tracks come from (a mounted folder, YouTube, an upload).
Backends are driven adapters (``app.infrastructure.sources``); these are the
shapes they speak in, and the ports they satisfy live in ``app.domain.ports``.
The first backend, ``local``, is *indexable*: it enumerates files already on
disk. Concrete metadata (artist/album/tags) is intentionally **not** resolved
here — a source yields a file plus a minimal title; enrichment (plan §6.2) fills
the rest later, so this stays a thin discovery layer (CLAUDE.md: no duplicated
business logic)."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# A source's ``kind`` describes which ports it satisfies, so the UI/admin can
# tell an indexed folder from a searchable fetch-source. A backend may be both.
KIND_INDEXABLE = "indexable" # enumerates files already on disk (local folder)
KIND_FETCH = "fetch" # searches + downloads from an external service (YTM, …)
@dataclass(frozen=True, slots=True)
class SourceInfo:
"""Describes a registered source for enumeration / health (UI, admin)."""
name: str
label: str
kind: str # KIND_INDEXABLE | KIND_FETCH
available: bool
@dataclass(frozen=True, slots=True)
class SourceFile:
"""A single importable file discovered by an indexable source.
``source_id`` is stable per source (the local backend uses the path relative
to its root) so re-scans are idempotent — already-imported files are skipped.
"""
source_id: str
path: Path
suggested_title: str
file_format: str
file_size: int
@dataclass(frozen=True, slots=True)
class SearchResult:
"""One hit from a searchable source (plan §5), shown on the discover screen.
``source_id`` is the stable handle the same backend later resolves in
``fetch`` — it must round-trip a download request without re-searching.
``raw`` carries the backend's untouched payload for debugging / future use.
"""
source: str
source_id: str
title: str
artist: str | None
album: str | None
duration_seconds: int | None
thumbnail_url: str | None
raw: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RawMetadata:
"""Metadata a fetch-source can offer about an item *before* enrichment.
Best-effort and source-shaped — the canonical metadata still comes from the
enrichment pipeline (plan §6.2). Used to seed a more useful provisional
title than a bare id while a download is queued."""
title: str | None
artist: str | None
album: str | None
year: int | None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DownloadResult:
"""A file a fetch-source produced on local disk (plan §5).
``path`` is a temp file the caller owns: it is stored into managed storage
and then removed (same lifecycle as an upload). ``source_id`` is echoed back
because some backends only learn the canonical id during the download."""
source_id: str
path: Path
file_format: str
file_size: int
bitrate: int | None
suggested_title: str
+11 -1
View File
@@ -2,7 +2,7 @@
import uuid import uuid
from sqlalchemy import ForeignKey, Integer, String from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.db.base import Base from app.infrastructure.db.base import Base
@@ -11,6 +11,12 @@ from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMi
class AlbumModel(UUIDPrimaryKeyMixin, TimestampMixin, Base): class AlbumModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "albums" __tablename__ = "albums"
__table_args__ = (
# Binds a remote (browsable) album to its local row for re-browse/save
# dedup. Multiple NULLs are allowed by Postgres, so locally-created
# albums (source/source_id both NULL) never collide on this.
UniqueConstraint("source", "source_id", name="uq_albums_source_source_id"),
)
title: Mapped[str] = mapped_column(String(1024), index=True, nullable=False) title: Mapped[str] = mapped_column(String(1024), index=True, nullable=False)
artist_id: Mapped[uuid.UUID] = mapped_column( artist_id: Mapped[uuid.UUID] = mapped_column(
@@ -21,3 +27,7 @@ class AlbumModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
year: Mapped[int | None] = mapped_column(Integer, nullable=True) year: Mapped[int | None] = mapped_column(Integer, nullable=True)
cover_path: Mapped[str | None] = mapped_column(String(1024), nullable=True) cover_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
musicbrainz_id: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True) musicbrainz_id: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True)
# -- remote identity (lazy materialization) --------------------------
source: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
+11 -1
View File
@@ -1,6 +1,6 @@
"""ORM model for artists.""" """ORM model for artists."""
from sqlalchemy import String from sqlalchemy import String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.db.base import Base from app.infrastructure.db.base import Base
@@ -9,6 +9,16 @@ from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMi
class ArtistModel(UUIDPrimaryKeyMixin, TimestampMixin, Base): class ArtistModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "artists" __tablename__ = "artists"
__table_args__ = (
# Binds a remote (browsable) artist to its local row for re-browse/save
# dedup. Multiple NULLs are allowed by Postgres, so locally-created
# artists (source/source_id both NULL) never collide on this.
UniqueConstraint("source", "source_id", name="uq_artists_source_source_id"),
)
name: Mapped[str] = mapped_column(String(512), index=True, nullable=False) name: Mapped[str] = mapped_column(String(512), index=True, nullable=False)
musicbrainz_id: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True) musicbrainz_id: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True)
# -- remote identity (lazy materialization) --------------------------
source: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
@@ -35,3 +35,9 @@ class DownloadJobModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
progress: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) progress: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Set once the download finishes and the track is imported — lets the UI
# link a completed job to its library track.
track_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("tracks.id", ondelete="SET NULL"),
nullable=True,
)
+9
View File
@@ -64,3 +64,12 @@ class LyricsStatus(enum.StrEnum):
FOUND = "found" FOUND = "found"
NOT_FOUND = "not_found" NOT_FOUND = "not_found"
PENDING = "pending" PENDING = "pending"
class TrackAvailability(enum.StrEnum):
"""Whether a track's audio is on local storage or still a remote placeholder
(plan: lazy materialization). ``remote`` tracks have ``storage_uri = NULL``
until ``TrackRepository.materialize`` fills it in."""
LOCAL = "local"
REMOTE = "remote"
+25 -5
View File
@@ -6,13 +6,14 @@
imports/downloads stay idempotent (plan §4, §6.1). imports/downloads stay idempotent (plan §4, §6.1).
""" """
import datetime as dt
import uuid import uuid
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.db.base import Base from app.infrastructure.db.base import Base
from app.infrastructure.db.models.enums import MetadataStatus, StoragePolicy from app.infrastructure.db.models.enums import MetadataStatus, StoragePolicy, TrackAvailability
from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin from app.infrastructure.db.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
@@ -40,11 +41,20 @@ class TrackModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
year: Mapped[int | None] = mapped_column(Integer, nullable=True) year: Mapped[int | None] = mapped_column(Integer, nullable=True)
# -- file (original, stored as-is) ----------------------------------- # -- file (original, stored as-is) -----------------------------------
storage_uri: Mapped[str] = mapped_column(String(2048), nullable=False) # NULL on a remote placeholder (not yet materialized) — see ``availability``.
file_format: Mapped[str] = mapped_column(String(32), nullable=False) storage_uri: Mapped[str | None] = mapped_column(String(2048), nullable=True)
file_size: Mapped[int] = mapped_column(Integer, nullable=False) file_format: Mapped[str | None] = mapped_column(String(32), nullable=True)
file_size: Mapped[int | None] = mapped_column(Integer, nullable=True)
bitrate: Mapped[int | None] = mapped_column(Integer, nullable=True) bitrate: Mapped[int | None] = mapped_column(Integer, nullable=True)
# ``remote`` = placeholder with no local audio yet; materialize() flips this
# to ``local`` once the file is downloaded and ``storage_uri`` is filled in.
availability: Mapped[str] = mapped_column(
String(16),
nullable=False,
default=TrackAvailability.LOCAL.value,
)
# -- dedup / external ids -------------------------------------------- # -- dedup / external ids --------------------------------------------
acoustid_fingerprint: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) acoustid_fingerprint: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
musicbrainz_id: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True) musicbrainz_id: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True)
@@ -63,6 +73,16 @@ class TrackModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
nullable=False, nullable=False,
default=MetadataStatus.PENDING.value, default=MetadataStatus.PENDING.value,
) )
# Human-readable reason the last enrichment run set ``failed`` (no match, or
# an unexpected worker error). ``None`` once a run succeeds. Surfaced in the
# UI so a stuck/failed track is diagnosable, not silent.
metadata_error: Mapped[str | None] = mapped_column(String(2048), nullable=True)
# When the last enrichment run finished (success or failure). ``None`` while
# still ``pending`` — lets the UI distinguish "queued/running" from "done".
enriched_at: Mapped[dt.datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
added_by: Mapped[uuid.UUID | None] = mapped_column( added_by: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), ForeignKey("users.id", ondelete="SET NULL"),
+3
View File
@@ -18,6 +18,9 @@ class UserModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
# Admin is a single flag in Phase 1 — no role system (plan §3.5). # Admin is a single flag in Phase 1 — no role system (plan §3.5).
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Recoverable Subsonic app-password, Fernet-encrypted at rest. NULL until the
# user generates one. Never the argon2 login password — see core.security.
subsonic_password_enc: Mapped[str | None] = mapped_column(String(255), nullable=True)
class RefreshTokenModel(UUIDPrimaryKeyMixin, Base): class RefreshTokenModel(UUIDPrimaryKeyMixin, Base):
@@ -2,6 +2,9 @@
from app.infrastructure.db.repositories.album_repository import SqlAlchemyAlbumRepository from app.infrastructure.db.repositories.album_repository import SqlAlchemyAlbumRepository
from app.infrastructure.db.repositories.artist_repository import SqlAlchemyArtistRepository from app.infrastructure.db.repositories.artist_repository import SqlAlchemyArtistRepository
from app.infrastructure.db.repositories.download_job_repository import (
SqlAlchemyDownloadJobRepository,
)
from app.infrastructure.db.repositories.history_repository import SqlAlchemyHistoryRepository from app.infrastructure.db.repositories.history_repository import SqlAlchemyHistoryRepository
from app.infrastructure.db.repositories.like_repository import SqlAlchemyLikeRepository from app.infrastructure.db.repositories.like_repository import SqlAlchemyLikeRepository
from app.infrastructure.db.repositories.playlist_repository import SqlAlchemyPlaylistRepository from app.infrastructure.db.repositories.playlist_repository import SqlAlchemyPlaylistRepository
@@ -14,6 +17,7 @@ from app.infrastructure.db.repositories.user_repository import SqlAlchemyUserRep
__all__ = [ __all__ = [
"SqlAlchemyAlbumRepository", "SqlAlchemyAlbumRepository",
"SqlAlchemyArtistRepository", "SqlAlchemyArtistRepository",
"SqlAlchemyDownloadJobRepository",
"SqlAlchemyHistoryRepository", "SqlAlchemyHistoryRepository",
"SqlAlchemyLikeRepository", "SqlAlchemyLikeRepository",
"SqlAlchemyPlaylistRepository", "SqlAlchemyPlaylistRepository",
@@ -18,6 +18,8 @@ def _to_entity(row: AlbumModel) -> Album:
year=row.year, year=row.year,
cover_path=row.cover_path, cover_path=row.cover_path,
musicbrainz_id=row.musicbrainz_id, musicbrainz_id=row.musicbrainz_id,
source=row.source,
source_id=row.source_id,
created_at=row.created_at, created_at=row.created_at,
updated_at=row.updated_at, updated_at=row.updated_at,
) )
@@ -27,6 +29,100 @@ class SqlAlchemyAlbumRepository:
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self._session = session self._session = session
async def get_or_create(
self,
*,
title: str,
artist_id: uuid.UUID,
year: int | None,
musicbrainz_id: str | None,
) -> Album:
"""Resolve an album by ``(title, artist_id)``, creating it if absent.
Backfills ``year``/``musicbrainz_id`` onto an existing row when it lacks
them and enrichment now has values (gap-fill, never overwrite)."""
row = (
await self._session.execute(
select(AlbumModel).where(
AlbumModel.title == title,
AlbumModel.artist_id == artist_id,
)
)
).scalar_one_or_none()
if row is None:
row = AlbumModel(
title=title,
artist_id=artist_id,
year=year,
musicbrainz_id=musicbrainz_id,
)
self._session.add(row)
else:
if row.year is None and year is not None:
row.year = year
if row.musicbrainz_id is None and musicbrainz_id is not None:
row.musicbrainz_id = musicbrainz_id
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def get_or_create_remote(
self,
*,
title: str,
artist_id: uuid.UUID,
year: int | None,
musicbrainz_id: str | None,
source: str,
source_id: str,
) -> Album:
"""Resolve an album by ``(source, source_id)`` first (re-browse/save
dedup), falling back to ``(title, artist_id)`` and gap-filling the
remote ids onto an existing row, else creating a new remote-bound row."""
row = (
await self._session.execute(
select(AlbumModel).where(
AlbumModel.source == source,
AlbumModel.source_id == source_id,
)
)
).scalar_one_or_none()
if row is None:
row = (
await self._session.execute(
select(AlbumModel).where(
AlbumModel.title == title,
AlbumModel.artist_id == artist_id,
)
)
).scalar_one_or_none()
if row is None:
row = AlbumModel(
title=title,
artist_id=artist_id,
year=year,
musicbrainz_id=musicbrainz_id,
source=source,
source_id=source_id,
)
self._session.add(row)
else:
if row.year is None and year is not None:
row.year = year
if row.musicbrainz_id is None and musicbrainz_id is not None:
row.musicbrainz_id = musicbrainz_id
if row.source is None and row.source_id is None:
row.source = source
row.source_id = source_id
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def set_cover_path(self, album_id: uuid.UUID, cover_path: str) -> None:
row = await self._session.get(AlbumModel, album_id)
if row is not None:
row.cover_path = cover_path
await self._session.flush()
async def get_by_id(self, album_id: uuid.UUID) -> Album | None: async def get_by_id(self, album_id: uuid.UUID) -> Album | None:
row = await self._session.get(AlbumModel, album_id) row = await self._session.get(AlbumModel, album_id)
return _to_entity(row) if row is not None else None return _to_entity(row) if row is not None else None
@@ -76,12 +172,20 @@ class SqlAlchemyAlbumRepository:
q: str | None, q: str | None,
limit: int, limit: int,
offset: int, offset: int,
sort_by: str = "title",
order: str = "asc",
) -> list[Album]: ) -> list[Album]:
stmt = select(AlbumModel) stmt = select(AlbumModel)
if artist_id is not None: if artist_id is not None:
stmt = stmt.where(AlbumModel.artist_id == artist_id) stmt = stmt.where(AlbumModel.artist_id == artist_id)
if q: if q:
stmt = stmt.where(AlbumModel.title.ilike(f"%{q}%")) stmt = stmt.where(AlbumModel.title.ilike(f"%{q}%"))
stmt = stmt.order_by(AlbumModel.title).limit(limit).offset(offset)
if order == "random":
stmt = stmt.order_by(func.random())
else:
col = AlbumModel.created_at if sort_by == "created" else AlbumModel.title
stmt = stmt.order_by(col.desc() if order == "desc" else col.asc())
stmt = stmt.limit(limit).offset(offset)
rows = (await self._session.execute(stmt)).scalars().all() rows = (await self._session.execute(stmt)).scalars().all()
return [_to_entity(r) for r in rows] return [_to_entity(r) for r in rows]
@@ -15,6 +15,8 @@ def _to_entity(row: ArtistModel) -> Artist:
return Artist( return Artist(
id=row.id, id=row.id,
name=row.name, name=row.name,
source=row.source,
source_id=row.source_id,
created_at=row.created_at, created_at=row.created_at,
updated_at=row.updated_at, updated_at=row.updated_at,
) )
@@ -35,6 +37,32 @@ class SqlAlchemyArtistRepository:
await self._session.refresh(row) await self._session.refresh(row)
return _to_entity(row) return _to_entity(row)
async def get_or_create_remote(self, *, name: str, source: str, source_id: str) -> Artist:
"""Resolve an artist by ``(source, source_id)`` first (re-browse/save
dedup), falling back to ``name`` and gap-filling the remote ids onto an
existing row, else creating a new remote-bound row."""
row = (
await self._session.execute(
select(ArtistModel).where(
ArtistModel.source == source,
ArtistModel.source_id == source_id,
)
)
).scalar_one_or_none()
if row is None:
row = (
await self._session.execute(select(ArtistModel).where(ArtistModel.name == name))
).scalar_one_or_none()
if row is None:
row = ArtistModel(name=name, source=source, source_id=source_id)
self._session.add(row)
elif row.source is None and row.source_id is None:
row.source = source
row.source_id = source_id
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def get_by_id(self, artist_id: uuid.UUID) -> Artist | None: async def get_by_id(self, artist_id: uuid.UUID) -> Artist | None:
row = await self._session.get(ArtistModel, artist_id) row = await self._session.get(ArtistModel, artist_id)
return _to_entity(row) if row is not None else None return _to_entity(row) if row is not None else None
@@ -0,0 +1,164 @@
"""Download job repository — adapter over ``AsyncSession`` (plan §6.1)."""
import datetime as dt
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.download import DownloadJob
from app.infrastructure.db.models.download_job import DownloadJobModel
from app.infrastructure.db.models.enums import DownloadStatus
# Jobs that are not yet finished — used to dedup an in-flight download.
_ACTIVE_STATUSES = (
DownloadStatus.QUEUED.value,
DownloadStatus.DOWNLOADING.value,
DownloadStatus.ENRICHING.value,
)
def _to_entity(row: DownloadJobModel) -> DownloadJob:
return DownloadJob(
id=row.id,
source=row.source,
source_id=row.source_id,
query=row.query,
requested_by=row.requested_by,
status=row.status,
progress=row.progress,
error_message=row.error_message,
retry_count=row.retry_count,
track_id=row.track_id,
created_at=row.created_at,
updated_at=row.updated_at,
)
class SqlAlchemyDownloadJobRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def add(
self,
*,
source: str,
source_id: str | None,
query: str | None,
requested_by: uuid.UUID | None,
) -> DownloadJob:
row = DownloadJobModel(
source=source,
source_id=source_id,
query=query,
requested_by=requested_by,
status=DownloadStatus.QUEUED.value,
progress=0.0,
retry_count=0,
)
self._session.add(row)
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def get_by_id(self, job_id: uuid.UUID) -> DownloadJob | None:
row = await self._session.get(DownloadJobModel, job_id)
return _to_entity(row) if row is not None else None
async def get_active_for_source(self, source: str, source_id: str) -> DownloadJob | None:
row = (
await self._session.execute(
select(DownloadJobModel)
.where(
DownloadJobModel.source == source,
DownloadJobModel.source_id == source_id,
DownloadJobModel.status.in_(_ACTIVE_STATUSES),
)
.order_by(DownloadJobModel.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
return _to_entity(row) if row is not None else None
async def list(
self,
*,
requested_by: uuid.UUID | None,
status: str | None,
limit: int,
offset: int,
) -> list[DownloadJob]:
stmt = select(DownloadJobModel)
if requested_by is not None:
stmt = stmt.where(DownloadJobModel.requested_by == requested_by)
if status is not None:
stmt = stmt.where(DownloadJobModel.status == status)
stmt = stmt.order_by(DownloadJobModel.created_at.desc()).limit(limit).offset(offset)
rows = (await self._session.execute(stmt)).scalars().all()
return [_to_entity(r) for r in rows]
async def count(self, *, requested_by: uuid.UUID | None, status: str | None) -> int:
stmt = select(func.count()).select_from(DownloadJobModel)
if requested_by is not None:
stmt = stmt.where(DownloadJobModel.requested_by == requested_by)
if status is not None:
stmt = stmt.where(DownloadJobModel.status == status)
return (await self._session.execute(stmt)).scalar_one()
async def set_status(
self,
job_id: uuid.UUID,
*,
status: str,
error_message: str | None = None,
track_id: uuid.UUID | None = None,
) -> None:
row = await self._session.get(DownloadJobModel, job_id)
if row is None:
return
row.status = status
# ``error_message`` is always written: a successful transition clears a
# stale reason from an earlier failed attempt.
row.error_message = error_message
if track_id is not None:
row.track_id = track_id
if status == DownloadStatus.DONE.value:
row.progress = 1.0
await self._session.flush()
async def set_progress(self, job_id: uuid.UUID, progress: float) -> None:
row = await self._session.get(DownloadJobModel, job_id)
if row is None:
return
row.progress = max(0.0, min(1.0, progress))
await self._session.flush()
async def increment_retry(self, job_id: uuid.UUID) -> int:
row = await self._session.get(DownloadJobModel, job_id)
if row is None:
return 0
row.retry_count += 1
await self._session.flush()
return row.retry_count
async def delete(self, job_id: uuid.UUID) -> None:
row = await self._session.get(DownloadJobModel, job_id)
if row is not None:
await self._session.delete(row)
await self._session.flush()
async def failure_rate(self, source: str, *, since: dt.datetime) -> float:
total, failed = (
await self._session.execute(
select(
func.count(),
func.count().filter(DownloadJobModel.status == DownloadStatus.FAILED.value),
)
.select_from(DownloadJobModel)
.where(
DownloadJobModel.source == source,
DownloadJobModel.created_at >= since,
)
)
).one()
return (failed / total) if total else 0.0
@@ -38,7 +38,11 @@ def _track_to_entity(row: TrackModel) -> Track:
duration_seconds=row.duration_seconds, duration_seconds=row.duration_seconds,
genre=row.genre, genre=row.genre,
year=row.year, year=row.year,
track_number=row.track_number,
metadata_status=row.metadata_status, metadata_status=row.metadata_status,
metadata_error=row.metadata_error,
enriched_at=row.enriched_at,
availability=row.availability,
created_at=row.created_at, created_at=row.created_at,
updated_at=row.updated_at, updated_at=row.updated_at,
) )
@@ -37,7 +37,11 @@ def _track_to_entity(row: TrackModel) -> Track:
duration_seconds=row.duration_seconds, duration_seconds=row.duration_seconds,
genre=row.genre, genre=row.genre,
year=row.year, year=row.year,
track_number=row.track_number,
metadata_status=row.metadata_status, metadata_status=row.metadata_status,
metadata_error=row.metadata_error,
enriched_at=row.enriched_at,
availability=row.availability,
created_at=row.created_at, created_at=row.created_at,
updated_at=row.updated_at, updated_at=row.updated_at,
) )
@@ -1,13 +1,16 @@
"""Track repository — adapter over ``AsyncSession``.""" """Track repository — adapter over ``AsyncSession``."""
import datetime as dt
import uuid import uuid
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.storage import FormatBreakdown, LibraryStats
from app.domain.entities.track import Track from app.domain.entities.track import Track
from app.domain.errors import NotFoundError from app.domain.errors import NotFoundError
from app.infrastructure.db.models.artist import ArtistModel from app.infrastructure.db.models.artist import ArtistModel
from app.infrastructure.db.models.enums import TrackAvailability
from app.infrastructure.db.models.track import TrackModel from app.infrastructure.db.models.track import TrackModel
@@ -25,7 +28,11 @@ def _to_entity(row: TrackModel) -> Track:
duration_seconds=row.duration_seconds, duration_seconds=row.duration_seconds,
genre=row.genre, genre=row.genre,
year=row.year, year=row.year,
track_number=row.track_number,
metadata_status=row.metadata_status, metadata_status=row.metadata_status,
metadata_error=row.metadata_error,
enriched_at=row.enriched_at,
availability=row.availability,
created_at=row.created_at, created_at=row.created_at,
updated_at=row.updated_at, updated_at=row.updated_at,
) )
@@ -56,13 +63,14 @@ class SqlAlchemyTrackRepository:
id: uuid.UUID, id: uuid.UUID,
title: str, title: str,
artist_id: uuid.UUID, artist_id: uuid.UUID,
storage_uri: str, storage_uri: str | None,
file_format: str, file_format: str | None,
file_size: int, file_size: int | None,
source: str, source: str,
source_id: str, source_id: str,
metadata_status: str, metadata_status: str,
added_by: uuid.UUID | None, added_by: uuid.UUID | None,
availability: str = TrackAvailability.LOCAL.value,
) -> Track: ) -> Track:
row = TrackModel( row = TrackModel(
id=id, id=id,
@@ -75,24 +83,124 @@ class SqlAlchemyTrackRepository:
source_id=source_id, source_id=source_id,
metadata_status=metadata_status, metadata_status=metadata_status,
added_by=added_by, added_by=added_by,
availability=availability,
) )
self._session.add(row) self._session.add(row)
await self._session.flush() await self._session.flush()
await self._session.refresh(row) await self._session.refresh(row)
return _to_entity(row) return _to_entity(row)
async def materialize(
self,
track_id: uuid.UUID,
*,
storage_uri: str,
file_format: str,
file_size: int,
bitrate: int | None,
) -> Track:
"""Fill in a remote placeholder's audio fields after a download (lazy
materialization). ``track.id`` is unchanged, so likes/playlists/queue
entries that already reference it keep working."""
row = await self._session.get(TrackModel, track_id)
if row is None:
raise NotFoundError(f"Track {track_id} not found.")
row.storage_uri = storage_uri
row.file_format = file_format
row.file_size = file_size
if bitrate is not None:
row.bitrate = bitrate
row.availability = TrackAvailability.LOCAL.value
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def delete(self, track_id: uuid.UUID) -> None: async def delete(self, track_id: uuid.UUID) -> None:
row = await self._session.get(TrackModel, track_id) row = await self._session.get(TrackModel, track_id)
if row is not None: if row is not None:
await self._session.delete(row) await self._session.delete(row)
await self._session.flush() await self._session.flush()
async def genres(self) -> list[tuple[str, int]]:
"""Distinct non-null genres with their song counts, most common first.
Defined before ``list`` — the method named ``list`` shadows the builtin
in later annotations within the class body."""
rows = (
await self._session.execute(
select(TrackModel.genre, func.count(TrackModel.id).label("cnt"))
.where(TrackModel.genre.is_not(None))
.group_by(TrackModel.genre)
.order_by(func.count(TrackModel.id).desc())
)
).all()
return [(row.genre, row.cnt) for row in rows]
async def library_stats(self) -> LibraryStats:
"""One-shot aggregate over the whole catalogue (no pagination). Defined
before ``list`` for the same shadowing reason as ``genres``."""
totals = (
await self._session.execute(
select(
func.count(TrackModel.id),
func.coalesce(func.sum(TrackModel.file_size), 0),
func.coalesce(func.sum(TrackModel.duration_seconds), 0),
func.coalesce(func.max(TrackModel.file_size), 0),
func.min(TrackModel.created_at),
func.max(TrackModel.created_at),
)
)
).one()
fmt_rows = (
await self._session.execute(
select(
TrackModel.file_format,
func.count(TrackModel.id),
func.coalesce(func.sum(TrackModel.file_size), 0),
)
.where(TrackModel.file_format.is_not(None))
.group_by(TrackModel.file_format)
.order_by(func.sum(TrackModel.file_size).desc())
)
).all()
status_rows = (
await self._session.execute(
select(TrackModel.metadata_status, func.count(TrackModel.id)).group_by(
TrackModel.metadata_status
)
)
).all()
source_rows = (
await self._session.execute(
select(TrackModel.source, func.count(TrackModel.id)).group_by(TrackModel.source)
)
).all()
return LibraryStats(
total_tracks=totals[0],
total_size=totals[1],
total_duration_seconds=totals[2],
largest_track_size=totals[3],
earliest_added=totals[4],
latest_added=totals[5],
by_format=[
FormatBreakdown(file_format=fmt, track_count=cnt, total_size=size)
for fmt, cnt, size in fmt_rows
],
by_metadata_status={status: cnt for status, cnt in status_rows},
by_source={source: cnt for source, cnt in source_rows},
)
async def list( async def list(
self, self,
*, *,
artist_id: uuid.UUID | None, artist_id: uuid.UUID | None,
album_id: uuid.UUID | None, album_id: uuid.UUID | None,
q: str | None, q: str | None,
source: str | None = None,
sort_by: str = "created_at", sort_by: str = "created_at",
order: str = "desc", order: str = "desc",
limit: int = 50, limit: int = 50,
@@ -103,6 +211,8 @@ class SqlAlchemyTrackRepository:
stmt = stmt.where(TrackModel.artist_id == artist_id) stmt = stmt.where(TrackModel.artist_id == artist_id)
if album_id is not None: if album_id is not None:
stmt = stmt.where(TrackModel.album_id == album_id) stmt = stmt.where(TrackModel.album_id == album_id)
if source is not None:
stmt = stmt.where(TrackModel.source == source)
if q: if q:
stmt = stmt.where(TrackModel.title.ilike(f"%{q}%")) stmt = stmt.where(TrackModel.title.ilike(f"%{q}%"))
@@ -127,12 +237,15 @@ class SqlAlchemyTrackRepository:
artist_id: uuid.UUID | None, artist_id: uuid.UUID | None,
album_id: uuid.UUID | None, album_id: uuid.UUID | None,
q: str | None, q: str | None,
source: str | None = None,
) -> int: ) -> int:
stmt = select(func.count()).select_from(TrackModel) stmt = select(func.count()).select_from(TrackModel)
if artist_id is not None: if artist_id is not None:
stmt = stmt.where(TrackModel.artist_id == artist_id) stmt = stmt.where(TrackModel.artist_id == artist_id)
if album_id is not None: if album_id is not None:
stmt = stmt.where(TrackModel.album_id == album_id) stmt = stmt.where(TrackModel.album_id == album_id)
if source is not None:
stmt = stmt.where(TrackModel.source == source)
if q: if q:
stmt = stmt.where(TrackModel.title.ilike(f"%{q}%")) stmt = stmt.where(TrackModel.title.ilike(f"%{q}%"))
return (await self._session.execute(stmt)).scalar_one() return (await self._session.execute(stmt)).scalar_one()
@@ -144,6 +257,9 @@ class SqlAlchemyTrackRepository:
title: str | None, title: str | None,
genre: str | None, genre: str | None,
year: int | None, year: int | None,
artist_id: uuid.UUID | None = None,
album_id: uuid.UUID | None = None,
track_number: int | None = None,
) -> Track: ) -> Track:
row = await self._session.get(TrackModel, track_id) row = await self._session.get(TrackModel, track_id)
if row is None: if row is None:
@@ -154,7 +270,75 @@ class SqlAlchemyTrackRepository:
row.genre = genre row.genre = genre
if year is not None: if year is not None:
row.year = year row.year = year
if artist_id is not None:
row.artist_id = artist_id
if album_id is not None:
row.album_id = album_id
if track_number is not None:
row.track_number = track_number
row.metadata_status = "manual" row.metadata_status = "manual"
await self._session.flush() await self._session.flush()
await self._session.refresh(row) await self._session.refresh(row)
return _to_entity(row) return _to_entity(row)
async def apply_enrichment(
self,
track_id: uuid.UUID,
*,
title: str,
artist_id: uuid.UUID,
album_id: uuid.UUID | None,
genre: str | None,
year: int | None,
track_number: int | None,
duration_seconds: int | None,
bitrate: int | None,
acoustid_fingerprint: str | None,
musicbrainz_id: str | None,
metadata_status: str,
metadata_error: str | None = None,
) -> Track:
row = await self._session.get(TrackModel, track_id)
if row is None:
raise NotFoundError(f"Track {track_id} not found.")
# Identity + status are authoritative for an enrichment run.
row.title = title
row.artist_id = artist_id
row.metadata_status = metadata_status
# A finished run always stamps outcome: clear/set the reason and mark the
# completion time so the UI can tell "still pending" from "done/failed".
row.metadata_error = metadata_error
row.enriched_at = dt.datetime.now(dt.UTC)
# Nullable extras: fill gaps only — never erase data a prior run found.
if album_id is not None:
row.album_id = album_id
if genre is not None:
row.genre = genre
if year is not None:
row.year = year
if track_number is not None:
row.track_number = track_number
if duration_seconds is not None:
row.duration_seconds = duration_seconds
if bitrate is not None:
row.bitrate = bitrate
if acoustid_fingerprint is not None:
row.acoustid_fingerprint = acoustid_fingerprint
if musicbrainz_id is not None:
row.musicbrainz_id = musicbrainz_id
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def mark_enrichment_failed(self, track_id: uuid.UUID, *, error: str) -> None:
"""Record that an enrichment run crashed (unexpected exception). Runs in
its own session so the failure is persisted even though the run's own
transaction rolled back. Never overwrites ``manual`` (a no-op then), and
a missing track is a clean no-op."""
row = await self._session.get(TrackModel, track_id)
if row is None or row.metadata_status == "manual":
return
row.metadata_status = "failed"
row.metadata_error = error
row.enriched_at = dt.datetime.now(dt.UTC)
await self._session.flush()
@@ -10,7 +10,7 @@ import uuid
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities import Credentials, User from app.domain.entities import Credentials, SubsonicCredentials, User
from app.domain.errors import NotFoundError from app.domain.errors import NotFoundError
from app.infrastructure.db.models import UserModel from app.infrastructure.db.models import UserModel
@@ -91,3 +91,22 @@ class SqlAlchemyUserRepository:
return ( return (
await self._session.execute(select(func.count()).select_from(UserModel)) await self._session.execute(select(func.count()).select_from(UserModel))
).scalar_one() ).scalar_one()
async def get_subsonic_credentials_by_username(
self, username: str
) -> SubsonicCredentials | None:
row = (
await self._session.execute(select(UserModel).where(UserModel.username == username))
).scalar_one_or_none()
if row is None:
return None
return SubsonicCredentials(user=_to_entity(row), password_enc=row.subsonic_password_enc)
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None:
row = await self._get_row(user_id)
return row.subsonic_password_enc
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None:
row = await self._get_row(user_id)
row.subsonic_password_enc = password_enc
await self._session.flush()
+1
View File
@@ -0,0 +1 @@
"""Metadata-enrichment adapters: tag reader, fingerprinter, AcoustID client."""
+161
View File
@@ -0,0 +1,161 @@
"""AcoustIdHttpClient — identifies a recording from its Chromaprint fingerprint.
One ``/v2/lookup`` call with ``meta=recordings+releasegroups`` returns the
AcoustID id, the MusicBrainz recording id, and canonical title/artist/album —
metadata that itself originates from MusicBrainz, so a separate MB call is not
needed for Phase 1 (plan §6.2 steps 2-3 collapsed into one request).
Graceful degradation: no API key → ``is_available()`` is False and the whole
fingerprint path is skipped; any network/parse error → ``lookup`` returns
``None``. A small inter-call delay keeps us within AcoustID's rate limit.
"""
import asyncio
import time
import httpx
from app.core.logging import get_logger
from app.domain.entities.metadata import Fingerprint, RecordingMatch
log = get_logger(__name__)
_DEFAULT_URL = "https://api.acoustid.org/v2/lookup"
_TIMEOUT_SECONDS = 10.0
_MIN_INTERVAL_SECONDS = 0.34 # AcoustID allows ~3 req/s; stay polite
class AcoustIdHttpClient:
"""Implements :class:`app.domain.ports.AcoustIdClient`."""
_throttle_lock = asyncio.Lock()
_last_call_monotonic = 0.0
def __init__(
self,
*,
api_key: str | None,
user_agent: str,
api_url: str = _DEFAULT_URL,
) -> None:
self._api_key = api_key
self._user_agent = user_agent
self._api_url = api_url
def is_available(self) -> bool:
return bool(self._api_key)
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None:
payload = await self._lookup_raw(fingerprint)
if payload is None:
return None
return _parse_best_match(payload)
async def lookup_all(self, fingerprint: Fingerprint) -> list[RecordingMatch]:
payload = await self._lookup_raw(fingerprint)
if payload is None:
return []
return _parse_matches(payload)
async def _lookup_raw(self, fingerprint: Fingerprint) -> object | None:
if not self._api_key:
return None
try:
await self._throttle()
async with httpx.AsyncClient(
timeout=_TIMEOUT_SECONDS,
headers={"User-Agent": self._user_agent},
) as client:
resp = await client.get(
self._api_url,
params={
"client": self._api_key,
"duration": str(fingerprint.duration_seconds),
"fingerprint": fingerprint.fingerprint,
"meta": "recordings releasegroups",
"format": "json",
},
)
resp.raise_for_status()
return resp.json() # type: ignore[no-any-return]
except httpx.HTTPError, ValueError:
log.warning("acoustid_lookup_failed")
return None
@classmethod
async def _throttle(cls) -> None:
async with cls._throttle_lock:
elapsed = time.monotonic() - cls._last_call_monotonic
wait = _MIN_INTERVAL_SECONDS - elapsed
if wait > 0:
await asyncio.sleep(wait)
cls._last_call_monotonic = time.monotonic()
_MAX_MATCHES = 5
def _parse_best_match(payload: object) -> RecordingMatch | None:
matches = _parse_matches(payload)
return matches[0] if matches else None
def _parse_matches(payload: object) -> list[RecordingMatch]:
if not isinstance(payload, dict) or payload.get("status") != "ok":
return []
results = payload.get("results")
if not isinstance(results, list) or not results:
return []
# Results are returned best-score-first, but sort defensively and cap the
# number of candidates surfaced to the editor.
candidates = [r for r in results if isinstance(r, dict)]
candidates.sort(key=lambda r: r.get("score", 0.0), reverse=True)
matches: list[RecordingMatch] = []
for result in candidates[:_MAX_MATCHES]:
match = _parse_one(result)
if match is not None:
matches.append(match)
return matches
def _parse_one(result: dict[str, object]) -> RecordingMatch | None:
acoustid = result.get("id")
if not isinstance(acoustid, str):
return None
score = float(result.get("score", 0.0)) # type: ignore[arg-type]
recording_mbid: str | None = None
release_group_mbid: str | None = None
title: str | None = None
artist: str | None = None
album: str | None = None
recordings = result.get("recordings")
if isinstance(recordings, list) and recordings and isinstance(recordings[0], dict):
rec = recordings[0]
recording_mbid = rec.get("id") if isinstance(rec.get("id"), str) else None
title = rec.get("title") if isinstance(rec.get("title"), str) else None
artists = rec.get("artists")
if isinstance(artists, list) and artists and isinstance(artists[0], dict):
name = artists[0].get("name")
artist = name if isinstance(name, str) else None
groups = rec.get("releasegroups")
if isinstance(groups, list) and groups and isinstance(groups[0], dict):
group = groups[0]
gtitle = group.get("title")
album = gtitle if isinstance(gtitle, str) else None
gid = group.get("id")
release_group_mbid = gid if isinstance(gid, str) else None
return RecordingMatch(
acoustid=acoustid,
score=score,
recording_mbid=recording_mbid,
release_group_mbid=release_group_mbid,
title=title,
artist=artist,
album=album,
year=None,
)
@@ -0,0 +1,111 @@
"""MutagenCoverExtractor — pulls embedded cover art from a local audio file.
The offline-first cover source (mirrors the tag pre-pass): a well-tagged file
often already carries front-cover artwork (ID3 ``APIC``, FLAC/OGG picture
blocks, MP4 ``covr``). We read it without any network call. Parsing is blocking,
so it runs in a worker thread. Any failure degrades to ``None`` — never raises.
mutagen ships no type stubs, so its objects are handled as ``Any`` and accessed
defensively (``getattr``) — the format zoo doesn't fit one static shape anyway.
"""
import base64
from pathlib import Path
from typing import Any
import anyio
from mutagen import File as MutagenFile # type: ignore[attr-defined]
from mutagen.flac import Picture
from mutagen.mp4 import MP4Cover
from app.core.logging import get_logger
from app.domain.entities.cover import CoverArt
log = get_logger(__name__)
# MP4 cover format flag → MIME (mutagen exposes an int, not a content type).
_MP4_FORMATS: dict[int, str] = {
MP4Cover.FORMAT_JPEG: "image/jpeg",
MP4Cover.FORMAT_PNG: "image/png",
}
_FRONT_COVER = 3 # APIC/Picture "type" value for the front cover
class MutagenCoverExtractor:
"""Implements :class:`app.domain.ports.CoverArtExtractor`."""
async def extract(self, path: Path) -> CoverArt | None:
try:
return await anyio.to_thread.run_sync(self._extract_sync, path)
except Exception:
log.warning("cover_extract_failed", path=str(path))
return None
def _extract_sync(self, path: Path) -> CoverArt | None:
audio: Any = MutagenFile(str(path))
if audio is None:
return None
# FLAC / OGG-FLAC: typed picture blocks on the file object.
pictures = getattr(audio, "pictures", None)
if pictures:
cover = _from_picture(_front_or_first(pictures))
if cover is not None:
return cover
tags = audio.tags
if tags is None:
return None
# MP3 / anything with ID3 frames: APIC frames keyed as "APIC:...".
apics = [frame for frame in tags.values() if frame.__class__.__name__ == "APIC"]
if apics:
cover = _from_picture(_front_or_first(apics))
if cover is not None:
return cover
get = getattr(tags, "get", None)
if get is None:
return None
# MP4 / M4A: "covr" atom holds a list of MP4Cover (a bytes subclass).
covr = get("covr")
if covr:
mp4_cover = covr[0]
content_type = _MP4_FORMATS.get(getattr(mp4_cover, "imageformat", -1), "image/jpeg")
return CoverArt(data=bytes(mp4_cover), content_type=content_type)
# OGG Vorbis: base64 picture block in METADATA_BLOCK_PICTURE.
block = get("metadata_block_picture")
if block:
cover = _from_picture(_decode_vorbis_picture(block[0]))
if cover is not None:
return cover
return None
def _from_picture(picture: Any) -> CoverArt | None:
"""Build a :class:`CoverArt` from a mutagen picture/APIC frame, or ``None``."""
if picture is None:
return None
data = getattr(picture, "data", None)
if not data:
return None
mime = getattr(picture, "mime", None) or "image/jpeg"
return CoverArt(data=bytes(data), content_type=str(mime))
def _front_or_first(pictures: list[Any]) -> Any:
"""Prefer the front-cover picture (type 3), else the first available."""
for pic in pictures:
if getattr(pic, "type", None) == _FRONT_COVER:
return pic
return pictures[0] if pictures else None
def _decode_vorbis_picture(encoded: str) -> Any:
try:
return Picture(base64.b64decode(encoded)) # type: ignore[no-untyped-call]
except Exception:
return None
+83
View File
@@ -0,0 +1,83 @@
"""CoverArtArchiveClient — fetches front cover art from the Cover Art Archive.
The network fallback when a file carries no embedded artwork: given a
MusicBrainz **release-group** id (supplied by the AcoustID lookup), request the
front image from ``coverartarchive.org``. The CAA redirects to the Internet
Archive, so redirects are followed. ``thumbnail`` 500px keeps payloads small.
Graceful degradation (CLAUDE.md): no release-group id → never called; any
network/HTTP error (incl. 404 "no cover") → returns ``None``, never raises. A
small inter-call delay respects the shared MusicBrainz/CAA infrastructure.
"""
import asyncio
import time
import httpx
from app.core.logging import get_logger
from app.domain.entities.cover import CoverArt
log = get_logger(__name__)
_DEFAULT_BASE_URL = "https://coverartarchive.org"
_TIMEOUT_SECONDS = 15.0
_MIN_INTERVAL_SECONDS = 1.0 # CAA piggybacks on MusicBrainz infra; stay polite
_MAX_BYTES = 10 * 1024 * 1024 # ignore absurdly large images
class CoverArtArchiveClient:
"""Implements :class:`app.domain.ports.CoverArtProvider`."""
_throttle_lock = asyncio.Lock()
_last_call_monotonic = 0.0
def __init__(
self,
*,
user_agent: str,
enabled: bool = True,
base_url: str = _DEFAULT_BASE_URL,
) -> None:
self._user_agent = user_agent
self._enabled = enabled
self._base_url = base_url.rstrip("/")
def is_available(self) -> bool:
return self._enabled
async def fetch_release_group(self, release_group_mbid: str) -> CoverArt | None:
if not self._enabled or not release_group_mbid:
return None
url = f"{self._base_url}/release-group/{release_group_mbid}/front-500"
try:
await self._throttle()
async with httpx.AsyncClient(
timeout=_TIMEOUT_SECONDS,
follow_redirects=True,
headers={"User-Agent": self._user_agent},
) as client:
resp = await client.get(url)
if resp.status_code == 404:
return None # no cover for this release group — normal, not an error
resp.raise_for_status()
except httpx.HTTPError:
log.warning("coverart_fetch_failed", release_group=release_group_mbid)
return None
data = resp.content
if not data or len(data) > _MAX_BYTES:
return None
content_type = resp.headers.get("content-type", "image/jpeg").split(";")[0].strip()
if not content_type.startswith("image/"):
return None
return CoverArt(data=data, content_type=content_type)
@classmethod
async def _throttle(cls) -> None:
async with cls._throttle_lock:
elapsed = time.monotonic() - cls._last_call_monotonic
wait = _MIN_INTERVAL_SECONDS - elapsed
if wait > 0:
await asyncio.sleep(wait)
cls._last_call_monotonic = time.monotonic()
@@ -0,0 +1,62 @@
"""FpcalcFingerprinter — Chromaprint fingerprint via the ``fpcalc`` binary.
``fpcalc -json <file>`` emits ``{"duration": float, "fingerprint": str}``. The
binary ships in the Docker image (``libchromaprint-tools``). Any failure (binary
missing, bad file, timeout) degrades to ``None`` — the pipeline then falls back
to tag-only metadata (plan §6.2: one external dependency must never crash it).
"""
import asyncio
import json
import shutil
from pathlib import Path
from app.core.logging import get_logger
from app.domain.entities.metadata import Fingerprint
log = get_logger(__name__)
_TIMEOUT_SECONDS = 30
class FpcalcFingerprinter:
"""Implements :class:`app.domain.ports.AudioFingerprinter`."""
def __init__(self, binary: str = "fpcalc") -> None:
self._binary = binary
def is_available(self) -> bool:
return shutil.which(self._binary) is not None
async def calculate(self, path: Path) -> Fingerprint | None:
if not self.is_available():
return None
try:
proc = await asyncio.create_subprocess_exec(
self._binary,
"-json",
str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
async with asyncio.timeout(_TIMEOUT_SECONDS):
stdout, _stderr = await proc.communicate()
except TimeoutError, OSError:
log.warning("fpcalc_failed", path=str(path))
return None
if proc.returncode != 0:
log.warning("fpcalc_nonzero", path=str(path), returncode=proc.returncode)
return None
try:
data = json.loads(stdout)
fingerprint = str(data["fingerprint"])
duration = round(float(data["duration"]))
except json.JSONDecodeError, KeyError, ValueError:
log.warning("fpcalc_bad_output", path=str(path))
return None
if not fingerprint or duration <= 0:
return None
return Fingerprint(fingerprint=fingerprint, duration_seconds=duration)
+88
View File
@@ -0,0 +1,88 @@
"""MutagenTagReader — reads embedded tags from a local audio file.
The offline first pass of enrichment (plan §6.2): well-tagged files get correct
artist/album/title without any network call. mutagen's ``easy=True`` mode
normalises tag keys across ID3 / Vorbis / MP4, so one code path covers all the
formats the library accepts. Parsing is blocking, so it runs in a worker thread.
"""
import re
from pathlib import Path
import anyio
from mutagen import File as MutagenFile # type: ignore[attr-defined]
from app.core.logging import get_logger
from app.domain.entities.metadata import AudioTags
log = get_logger(__name__)
_YEAR_RE = re.compile(r"(\d{4})")
def _first(value: object) -> str | None:
"""EasyXxx tags expose values as lists; take the first non-empty string."""
if isinstance(value, list):
value = value[0] if value else None
if value is None:
return None
text = str(value).strip()
return text or None
def _parse_year(value: object) -> int | None:
text = _first(value)
if text is None:
return None
m = _YEAR_RE.search(text)
return int(m.group(1)) if m else None
def _parse_track_number(value: object) -> int | None:
text = _first(value)
if text is None:
return None
# "3" or "3/12" → 3
head = text.split("/", 1)[0].strip()
return int(head) if head.isdigit() else None
class MutagenTagReader:
"""Implements :class:`app.domain.ports.AudioTagReader`."""
async def read(self, path: Path) -> AudioTags | None:
try:
return await anyio.to_thread.run_sync(self._read_sync, path)
except Exception:
log.warning("tag_read_failed", path=str(path))
return None
def _read_sync(self, path: Path) -> AudioTags | None:
audio = MutagenFile(str(path), easy=True)
if audio is None:
return None # unrecognised container
tags = audio.tags or {}
info = getattr(audio, "info", None)
duration = None
bitrate = None
if info is not None:
length = getattr(info, "length", None)
if length:
duration = round(float(length))
raw_bitrate = getattr(info, "bitrate", None)
if raw_bitrate:
bitrate = int(raw_bitrate) // 1000 # bits/s → kbps for display
return AudioTags(
title=_first(tags.get("title")),
artist=_first(tags.get("artist")),
album=_first(tags.get("album")),
album_artist=_first(tags.get("albumartist")),
genre=_first(tags.get("genre")),
year=_parse_year(tags.get("date") or tags.get("year")),
track_number=_parse_track_number(tags.get("tracknumber")),
duration_seconds=duration,
bitrate=bitrate,
)
+1
View File
@@ -0,0 +1 @@
"""Source backends — driven adapters that discover/fetch tracks."""
@@ -0,0 +1,60 @@
"""``local`` source — indexes audio files from a mounted folder.
Walks a configured root directory and yields each audio file as a
:class:`SourceFile`. It does **not** parse tags or resolve artist/album — that's
enrichment's job (plan §6.2); this stays a thin discovery layer. ``source_id``
is the path relative to the root, so re-scans are idempotent.
"""
import os
from collections.abc import Iterator
from pathlib import Path
from app.domain.sources import SourceFile, SourceInfo
from app.infrastructure.db.models.enums import TrackSource
# Extensions we treat as audio. Mirrors the formats StreamingService serves.
_AUDIO_EXTENSIONS = frozenset(
{"mp3", "flac", "m4a", "aac", "ogg", "opus", "wav", "wma", "aiff", "aif", "alac"}
)
class LocalFolderSource:
"""Implements :class:`app.domain.ports.IndexableSource`."""
name = TrackSource.LOCAL.value
def __init__(self, root: Path) -> None:
self._root = root
def info(self) -> SourceInfo:
return SourceInfo(
name=self.name,
label="Local folder",
kind="indexable",
available=self.is_available(),
)
def is_available(self) -> bool:
return self._root.is_dir()
def scan(self) -> Iterator[SourceFile]:
if not self.is_available():
return
for dirpath, _dirnames, filenames in os.walk(self._root):
for filename in sorted(filenames):
ext = Path(filename).suffix.lower().lstrip(".")
if ext not in _AUDIO_EXTENSIONS:
continue
path = Path(dirpath) / filename
try:
size = path.stat().st_size
except OSError:
continue # vanished/unreadable between walk and stat → skip
yield SourceFile(
source_id=path.relative_to(self._root).as_posix(),
path=path,
suggested_title=path.stem or "Unknown",
file_format=ext,
file_size=size,
)
+66
View File
@@ -0,0 +1,66 @@
"""Source registry — selection + enumeration of configured backends.
Built from settings at the composition root. Only sources that are configured
are registered (e.g. ``local`` appears only when ``LOCAL_MEDIA_IMPORT_PATH`` is
set; ``youtube`` only when ``YOUTUBE_ENABLED``), so enumeration reflects what the
instance can actually use.
"""
from typing import cast
from app.core.config import Settings
from app.domain.errors import NotFoundError, ValidationError
from app.domain.ports import FetchableSource, IndexableSource, SearchableSource, SourceBackend
from app.domain.sources import SourceInfo
from app.infrastructure.sources.local_folder import LocalFolderSource
from app.infrastructure.sources.youtube import YouTubeMusicSource
class SourceRegistry:
def __init__(self, backends: list[SourceBackend]) -> None:
self._by_name = {backend.name: backend for backend in backends}
def get(self, name: str) -> SourceBackend:
backend = self._by_name.get(name)
if backend is None:
raise NotFoundError(f"Source {name!r} is not configured.")
return backend
def indexable(self, name: str) -> IndexableSource:
backend = self.get(name)
if not hasattr(backend, "scan"):
raise ValidationError(f"Source {name!r} cannot be indexed.")
return cast(IndexableSource, backend)
def searchable(self, name: str) -> SearchableSource:
backend = self.get(name)
if not hasattr(backend, "search"):
raise ValidationError(f"Source {name!r} cannot be searched.")
return cast(SearchableSource, backend)
def fetchable(self, name: str) -> FetchableSource:
backend = self.get(name)
if not hasattr(backend, "fetch"):
raise ValidationError(f"Source {name!r} cannot download.")
return cast(FetchableSource, backend)
def searchables(self) -> list[SearchableSource]:
"""Every registered source that supports search (for cross-source search)."""
return [cast(SearchableSource, b) for b in self._by_name.values() if hasattr(b, "search")]
def infos(self) -> list[SourceInfo]:
return [backend.info() for backend in self._by_name.values()]
def build_source_registry(settings: Settings) -> SourceRegistry:
backends: list[SourceBackend] = []
if settings.local_media_import_path is not None:
backends.append(LocalFolderSource(settings.local_media_import_path))
if settings.youtube_enabled:
backends.append(
YouTubeMusicSource(
cookies_path=settings.youtube_cookies_path,
tmp_dir=settings.upload_tmp_dir,
)
)
return SourceRegistry(backends)
+207
View File
@@ -0,0 +1,207 @@
"""``youtube`` source — YouTube Music search + download (plan §5).
A *fetch* source: it searches YouTube Music (via ``ytmusicapi``, which returns
clean song/artist/album/duration rows) and downloads the chosen item with
``yt-dlp``. The two libraries are synchronous, so every call is bounced to a
worker thread (``anyio.to_thread``); the sync yt-dlp progress hook bridges back
to the async progress callback via ``anyio.from_thread``.
Both libraries are optional dependencies — if either is missing the source is
simply *unavailable* (it never crashes import or the registry; graceful
degradation per CLAUDE.md). The audio stream is stored **as-is** (YouTube serves
lossy Opus/AAC; re-encoding would be lossy→lossy, plan §6.6).
``source_id`` is the YouTube ``videoId`` — stable, so a re-download of the same
id is idempotent and dedups against an existing track.
"""
import functools
import tempfile
from collections.abc import Callable
from pathlib import Path
from typing import Any
import anyio
from app.core.logging import get_logger
from app.domain.ports import ProgressCallback
from app.domain.sources import (
KIND_FETCH,
DownloadResult,
RawMetadata,
SearchResult,
SourceInfo,
)
from app.infrastructure.db.models.enums import TrackSource
log = get_logger(__name__)
# Functions a caller may inject for testing (defaults do the real library work).
SearchFn = Callable[[str, int], list[dict[str, Any]]]
# (video_id, tmp_dir, progress_hook, cookies_path) -> normalized download dict
DownloadFn = Callable[[str, Path, Callable[[dict[str, Any]], None], Path | None], dict[str, Any]]
def _libs_available() -> bool:
try:
import yt_dlp # noqa: F401
import ytmusicapi # noqa: F401
except ImportError:
return False
return True
def _watch_url(video_id: str) -> str:
return f"https://music.youtube.com/watch?v={video_id}"
class YouTubeMusicSource:
"""Implements :class:`app.domain.ports.SearchableSource` and
:class:`~app.domain.ports.FetchableSource`."""
name = TrackSource.YOUTUBE.value
def __init__(
self,
*,
cookies_path: Path | None = None,
tmp_dir: Path | None = None,
search_fn: SearchFn | None = None,
download_fn: DownloadFn | None = None,
) -> None:
self._cookies_path = cookies_path
self._tmp_dir = tmp_dir
self._search_fn = search_fn or _default_search
self._download_fn = download_fn or _default_download
# Only the real library path needs the deps; an injected fn is self-contained.
self._injected = search_fn is not None or download_fn is not None
def info(self) -> SourceInfo:
return SourceInfo(
name=self.name,
label="YouTube Music",
kind=KIND_FETCH,
available=self.is_available(),
)
def is_available(self) -> bool:
return True if self._injected else _libs_available()
async def search(self, query: str, *, limit: int) -> list[SearchResult]:
query = query.strip()
if not query:
return []
try:
rows = await anyio.to_thread.run_sync(functools.partial(self._search_fn, query, limit))
except Exception:
# No results / service down → degrade to empty (plan §5, CLAUDE.md).
log.warning("ytm_search_failed", query=query)
return []
return [r for r in (self._to_result(row) for row in rows) if r is not None]
async def fetch(
self, source_id: str, *, on_progress: ProgressCallback | None = None
) -> DownloadResult:
tmp_dir = self._tmp_dir or Path(tempfile.gettempdir())
def hook(d: dict[str, Any]) -> None:
if on_progress is None or d.get("status") != "downloading":
return
total = d.get("total_bytes") or d.get("total_bytes_estimate")
done = d.get("downloaded_bytes")
if not total or done is None:
return
# Cap below 1.0 — the job only reaches 1.0 once stored + imported.
frac = min(done / total, 0.99)
# Bridge sync hook (worker thread) → async callback (event loop).
anyio.from_thread.run(on_progress, frac)
def _run() -> dict[str, Any]:
return self._download_fn(source_id, tmp_dir, hook, self._cookies_path)
info = await anyio.to_thread.run_sync(_run)
path = Path(info["filepath"])
stat = await anyio.Path(path).stat()
return DownloadResult(
source_id=source_id,
path=path,
file_format=info["file_format"],
file_size=stat.st_size,
bitrate=info.get("bitrate"),
suggested_title=info.get("title") or source_id,
)
async def get_metadata(self, source_id: str) -> RawMetadata | None:
# The search result already carries a usable title/artist, and the
# canonical metadata comes from enrichment (§6.2). A dedicated lookup is
# an optional refinement — skipped for now (returns None gracefully).
return None
def _to_result(self, row: dict[str, Any]) -> SearchResult | None:
video_id = row.get("videoId")
if not video_id:
return None # non-playable row (e.g. a video without audio id)
artists = row.get("artists") or []
artist = ", ".join(a["name"] for a in artists if a.get("name")) or None
album = (row.get("album") or {}).get("name") if isinstance(row.get("album"), dict) else None
thumbnails = row.get("thumbnails") or []
thumbnail = thumbnails[-1].get("url") if thumbnails else None
return SearchResult(
source=self.name,
source_id=str(video_id),
title=row.get("title") or "Unknown",
artist=artist,
album=album,
duration_seconds=row.get("duration_seconds"),
thumbnail_url=thumbnail,
raw=row,
)
def _default_search(query: str, limit: int) -> list[dict[str, Any]]:
"""Real ytmusicapi search (songs only). Runs in a worker thread."""
from ytmusicapi import YTMusic
yt = YTMusic() # unauthenticated: public search needs no login
results: list[dict[str, Any]] = yt.search(query, filter="songs", limit=limit)
return results[:limit]
def _default_download(
video_id: str,
tmp_dir: Path,
progress_hook: Callable[[dict[str, Any]], None],
cookies_path: Path | None,
) -> dict[str, Any]:
"""Real yt-dlp download of the best audio stream. Runs in a worker thread.
Stores the original stream (no transcode — plan §6.3/§6.6). Returns a
normalized dict the adapter maps to :class:`DownloadResult`.
"""
from yt_dlp import YoutubeDL
opts: dict[str, Any] = {
"format": "bestaudio/best",
"outtmpl": str(tmp_dir / "%(id)s.%(ext)s"),
"quiet": True,
"no_warnings": True,
"noprogress": True,
"progress_hooks": [progress_hook],
}
# Use cookies only when the file is actually present: the path can be set
# unconditionally (e.g. a mounted volume that may be empty) and downloads
# still work without it — cookies just unlock age/region-restricted items.
if cookies_path is not None and cookies_path.is_file():
opts["cookiefile"] = str(cookies_path)
with YoutubeDL(opts) as ydl:
info = ydl.extract_info(_watch_url(video_id), download=True)
filepath = Path(ydl.prepare_filename(info))
abr = info.get("abr")
return {
"filepath": filepath,
"file_format": filepath.suffix.lstrip(".").lower() or "m4a",
"bitrate": int(abr) if abr else None,
"title": info.get("title"),
}
+10 -1
View File
@@ -8,7 +8,7 @@ from pathlib import Path
import anyio import anyio
from app.domain.entities.storage import ObjectStat from app.domain.entities.storage import DiskUsage, ObjectStat
from app.domain.errors import StorageError from app.domain.errors import StorageError
_EXT_CONTENT_TYPE: dict[str, str] = { _EXT_CONTENT_TYPE: dict[str, str] = {
@@ -78,6 +78,15 @@ class LocalFileStorage:
async def delete(self, key: str) -> None: async def delete(self, key: str) -> None:
(self._media_path / key).unlink(missing_ok=True) (self._media_path / key).unlink(missing_ok=True)
async def disk_usage(self) -> DiskUsage | None:
# The media root may not exist yet on a fresh instance — walk up to the
# nearest existing ancestor so we still report the underlying volume.
path = self._media_path
while not path.exists() and path != path.parent:
path = path.parent
usage = await anyio.to_thread.run_sync(shutil.disk_usage, str(path))
return DiskUsage(total=usage.total, used=usage.used, free=usage.free)
def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]: def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]:
return self._as_local_path_cm(key) return self._as_local_path_cm(key)
+5 -3
View File
@@ -84,9 +84,7 @@ class S3FileStorage:
async def _stream() -> AsyncGenerator[bytes]: async def _stream() -> AsyncGenerator[bytes]:
async with self._client() as s3: async with self._client() as s3:
try: try:
resp = await s3.get_object( resp = await s3.get_object(Bucket=_bucket, Key=_key, Range=range_header)
Bucket=_bucket, Key=_key, Range=range_header
)
except ClientError as exc: except ClientError as exc:
raise StorageError(str(exc)) from exc raise StorageError(str(exc)) from exc
body = resp["Body"] body = resp["Body"]
@@ -129,6 +127,10 @@ class S3FileStorage:
except ClientError as exc: except ClientError as exc:
raise StorageError(str(exc)) from exc raise StorageError(str(exc)) from exc
async def disk_usage(self) -> None:
# Object stores have no fixed-capacity volume to report.
return None
def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]: def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]:
return self._as_local_path_cm(key) return self._as_local_path_cm(key)
+2 -2
View File
@@ -10,7 +10,7 @@ from app.api.health import router as health_router
from app.api.middleware import CorrelationIdMiddleware from app.api.middleware import CorrelationIdMiddleware
from app.api.rest import subsonic_router from app.api.rest import subsonic_router
from app.api.v1 import api_v1_router from app.api.v1 import api_v1_router
from app.core.config import get_settings from app.core.config import app_version, get_settings
from app.core.logging import configure_logging, get_logger from app.core.logging import configure_logging, get_logger
from app.infrastructure.cache import close_redis from app.infrastructure.cache import close_redis
from app.infrastructure.db import dispose_engine from app.infrastructure.db import dispose_engine
@@ -34,7 +34,7 @@ def create_app() -> FastAPI:
app = FastAPI( app = FastAPI(
title="mcma-backend", title="mcma-backend",
version="0.1.0", version=app_version(),
summary="Self-hosted, offline-first music service.", summary="Self-hosted, offline-first music service.",
lifespan=lifespan, lifespan=lifespan,
) )
+10 -6
View File
@@ -1,7 +1,6 @@
"""arq worker settings — the queue runtime. Task functions register here. """arq worker settings — the queue runtime. Task functions register here.
Run with: ``arq app.workers.arq_worker.WorkerSettings``. Run with: ``arq app.workers.arq_worker.WorkerSettings``.
Tasks (download, enrich, transcode) are appended to ``functions`` in later steps.
""" """
from typing import Any, ClassVar from typing import Any, ClassVar
@@ -10,6 +9,10 @@ from arq.connections import RedisSettings
from app.core.config import get_settings from app.core.config import get_settings
from app.core.logging import configure_logging, get_logger from app.core.logging import configure_logging, get_logger
from app.workers.tasks.download_task import download_track
from app.workers.tasks.enrich_task import enrich_track
from app.workers.tasks.import_task import scan_local_folder
from app.workers.tasks.materialize_task import materialize_track
log = get_logger("worker") log = get_logger("worker")
@@ -24,12 +27,13 @@ async def shutdown(_ctx: dict[str, Any]) -> None:
log.info("worker_shutdown") log.info("worker_shutdown")
async def _noop(_ctx: dict[str, Any]) -> None:
pass
class WorkerSettings: class WorkerSettings:
functions: ClassVar[list[Any]] = [_noop] # populated as tasks are implemented functions: ClassVar[list[Any]] = [
scan_local_folder,
enrich_track,
download_track,
materialize_track,
]
on_startup = startup on_startup = startup
on_shutdown = shutdown on_shutdown = shutdown
max_jobs = get_settings().max_parallel_downloads max_jobs = get_settings().max_parallel_downloads
+74
View File
@@ -0,0 +1,74 @@
"""Enqueue helper — submit a job to the arq queue from the request cycle.
A short-lived pool per call keeps things simple (enqueues are rare, admin-driven
actions). Redis being down degrades to a clean 503 rather than a crash
(graceful degradation)."""
import uuid
from typing import Any
from arq import create_pool
from arq.connections import RedisSettings
from app.core.config import get_settings
from app.core.logging import get_logger
from app.domain.errors import DependencyUnavailableError
log = get_logger("worker.queue")
async def enqueue(function: str, **kwargs: Any) -> str:
"""Enqueue ``function`` by name, returning the job id. Raises
:class:`DependencyUnavailableError` if the queue can't be reached."""
settings = get_settings()
try:
pool = await create_pool(RedisSettings.from_dsn(str(settings.redis_url)))
except Exception as exc:
raise DependencyUnavailableError("Task queue (Redis) is unavailable.") from exc
try:
job = await pool.enqueue_job(function, **kwargs)
finally:
await pool.aclose()
if job is None:
raise DependencyUnavailableError("Could not enqueue job.")
return str(job.job_id)
async def enqueue_download(job_id: uuid.UUID) -> None:
"""Best-effort enqueue of a download job for the worker.
The job row is already persisted as ``queued``, so this is a follow-up, not a
barrier: if the queue is unreachable we log and move on (graceful
degradation) — the job stays ``queued`` and can be retried later. Deferred a
few seconds so the request's DB transaction commits before the worker reads
the row (same reason as :func:`enqueue_enrich`)."""
try:
await enqueue("download_track", job_id=str(job_id), _defer_by=3)
except DependencyUnavailableError:
log.warning("download_enqueue_failed", job_id=str(job_id))
async def enqueue_materialize(job_id: uuid.UUID) -> None:
"""Best-effort enqueue of a materialize job for the worker (plan: Model C
lazy materialization). Same deferred-commit reasoning as
:func:`enqueue_download` — the job row stays ``queued`` and can be retried
if the queue is unreachable."""
try:
await enqueue("materialize_track", job_id=str(job_id), _defer_by=3)
except DependencyUnavailableError:
log.warning("materialize_enqueue_failed", job_id=str(job_id))
async def enqueue_enrich(track_id: uuid.UUID) -> None:
"""Best-effort enqueue of metadata enrichment for a freshly stored track.
The track is already persisted, so enrichment is a follow-up, not a barrier:
if the queue is unreachable we log and move on (graceful degradation). The
track stays ``metadata_status=pending`` and can be re-enriched later.
Deferred a few seconds so the caller's DB transaction is committed before the
worker looks the track up (the upload request commits only after it returns)."""
try:
await enqueue("enrich_track", track_id=str(track_id), _defer_by=5)
except DependencyUnavailableError:
log.warning("enrich_enqueue_failed", track_id=str(track_id))
+1
View File
@@ -0,0 +1 @@
"""arq task functions. Registered in ``app.workers.arq_worker.WorkerSettings``."""
+151
View File
@@ -0,0 +1,151 @@
"""arq task: download one queued job through a fetch source (plan §6.1).
Flow: load job → ``downloading`` → ``backend.fetch`` (progress streamed to the
job row) → ``enriching`` → store file + minimal track → ``done`` → enqueue
enrichment. yt-dlp fails often, so a failed fetch retries with exponential
backoff (``download_max_retries``); only after the last try is the job marked
``failed`` with a reason for the §A5 download manager.
Heavy I/O belongs off the request cycle (CLAUDE.md); the HTTP endpoint only
enqueues. The job row tolerates being deleted mid-flight (cancellation) — status
writes against a missing row are no-ops.
"""
import uuid
from typing import Any
from arq import Retry
from app.application.download_service import DownloadService
from app.core.config import get_settings
from app.core.logging import correlation_id, get_logger
from app.domain.entities.download import DownloadJob
from app.domain.errors import NotFoundError, ValidationError
from app.domain.ports import FetchableSource, ProgressCallback
from app.domain.sources import DownloadResult
from app.infrastructure.db import session_scope
from app.infrastructure.db.repositories import (
SqlAlchemyArtistRepository,
SqlAlchemyDownloadJobRepository,
SqlAlchemyTrackRepository,
)
from app.infrastructure.sources.registry import build_source_registry
from app.infrastructure.storage.provider import get_file_storage
from app.workers.queue import enqueue_enrich
log = get_logger("worker.download")
# Exponential backoff between retries: 30s, 60s, 120s … capped.
_BACKOFF_BASE_SECONDS = 30
_BACKOFF_MAX_SECONDS = 600
# Only write progress when it advances by at least this much (avoid hammering
# the DB on every yt-dlp chunk).
_PROGRESS_STEP = 0.01
async def download_track(_ctx: dict[str, Any], *, job_id: str) -> dict[str, Any]:
correlation_id.set(f"dl:{job_id}")
jid = uuid.UUID(job_id)
settings = get_settings()
job = await _load_job(jid)
if job is None:
log.info("download_job_missing", job_id=job_id) # cancelled before pickup
return {"job_id": job_id, "status": "missing"}
registry = build_source_registry(settings)
try:
backend = registry.fetchable(job.source)
except (NotFoundError, ValidationError) as exc:
await _mark_failed(jid, f"Source unavailable: {exc}")
return {"job_id": job_id, "status": "failed"}
if job.source_id is None:
await _mark_failed(jid, "Job has no source_id to download.")
return {"job_id": job_id, "status": "failed"}
await _set_status(jid, "downloading")
try:
result = await _run_fetch(backend, job.source_id, jid)
except Exception as exc:
return await _handle_failure(jid, exc, settings.download_max_retries, job_id)
try:
track_id = await _import_result(jid, job, result)
except Exception as exc:
log.exception("download_import_failed", job_id=job_id)
await _mark_failed(jid, f"Import failed: {type(exc).__name__}: {exc}")
return {"job_id": job_id, "status": "failed"}
await enqueue_enrich(track_id)
log.info("download_complete", job_id=job_id, track_id=str(track_id))
return {"job_id": job_id, "status": "done", "track_id": str(track_id)}
async def _run_fetch(
backend: FetchableSource, source_id: str, jid: uuid.UUID
) -> DownloadResult:
"""Fetch the file, streaming progress into the job row. A single session is
held for the download so progress writes don't churn connections; each
throttled update is committed so API pollers see it."""
async with session_scope() as session:
repo = SqlAlchemyDownloadJobRepository(session)
last = 0.0
async def on_progress(frac: float) -> None:
nonlocal last
if frac - last < _PROGRESS_STEP:
return
last = frac
await repo.set_progress(jid, frac)
await session.commit()
cb: ProgressCallback = on_progress
return await backend.fetch(source_id, on_progress=cb)
async def _import_result(jid: uuid.UUID, job: DownloadJob, result: DownloadResult) -> uuid.UUID:
async with session_scope() as session:
repo = SqlAlchemyDownloadJobRepository(session)
await repo.set_status(jid, status="enriching")
service = DownloadService(
jobs=repo,
tracks=SqlAlchemyTrackRepository(session),
artists=SqlAlchemyArtistRepository(session),
storage=get_file_storage(),
)
track_id = await service.store_result(
source=job.source, result=result, requested_by=job.requested_by
)
await repo.set_status(jid, status="done", track_id=track_id)
return track_id
async def _handle_failure(
jid: uuid.UUID, exc: Exception, max_retries: int, job_id: str
) -> dict[str, Any]:
async with session_scope() as session:
tries = await SqlAlchemyDownloadJobRepository(session).increment_retry(jid)
if tries <= max_retries:
backoff = min(_BACKOFF_BASE_SECONDS * 2 ** (tries - 1), _BACKOFF_MAX_SECONDS)
log.warning("download_retry", job_id=job_id, attempt=tries, defer=backoff)
raise Retry(defer=backoff) from exc
log.exception("download_failed", job_id=job_id)
await _mark_failed(jid, f"Download failed after {tries} attempts: {type(exc).__name__}: {exc}")
return {"job_id": job_id, "status": "failed"}
async def _load_job(jid: uuid.UUID) -> DownloadJob | None:
async with session_scope() as session:
return await SqlAlchemyDownloadJobRepository(session).get_by_id(jid)
async def _set_status(jid: uuid.UUID, status: str) -> None:
async with session_scope() as session:
await SqlAlchemyDownloadJobRepository(session).set_status(jid, status=status)
async def _mark_failed(jid: uuid.UUID, error: str) -> None:
async with session_scope() as session:
await SqlAlchemyDownloadJobRepository(session).set_status(
jid, status="failed", error_message=error
)
+76
View File
@@ -0,0 +1,76 @@
"""arq task: enrich one track's metadata (plan §6.2, §1D).
Wires the §6.2 pipeline adapters to :class:`MetadataEnrichmentService` and runs
it in the worker's own transactional session. Enqueued (deferred) after upload
and after a local-folder import. Idempotent and best-effort — a missing track or
a ``manual`` one is a clean no-op.
"""
import uuid
from typing import Any
from app.application.metadata_service import MetadataEnrichmentService
from app.core.config import get_settings
from app.core.logging import get_logger
from app.infrastructure.db import session_scope
from app.infrastructure.db.repositories import (
SqlAlchemyAlbumRepository,
SqlAlchemyArtistRepository,
SqlAlchemyTrackRepository,
)
from app.infrastructure.metadata.acoustid import AcoustIdHttpClient
from app.infrastructure.metadata.cover_extractor import MutagenCoverExtractor
from app.infrastructure.metadata.coverart import CoverArtArchiveClient
from app.infrastructure.metadata.fingerprint import FpcalcFingerprinter
from app.infrastructure.metadata.tags import MutagenTagReader
from app.infrastructure.storage.provider import get_file_storage
log = get_logger("worker.enrich")
async def enrich_track(_ctx: dict[str, Any], *, track_id: str) -> dict[str, Any]:
settings = get_settings()
api_key = settings.acoustid_api_key.get_secret_value() if settings.acoustid_api_key else None
acoustid = AcoustIdHttpClient(
api_key=api_key,
user_agent=settings.musicbrainz_user_agent,
api_url=settings.acoustid_api_url,
)
cover_provider = CoverArtArchiveClient(
user_agent=settings.musicbrainz_user_agent,
enabled=settings.coverart_enabled,
base_url=settings.coverart_base_url,
)
tid = uuid.UUID(track_id)
try:
async with session_scope() as session:
service = MetadataEnrichmentService(
tracks=SqlAlchemyTrackRepository(session),
artists=SqlAlchemyArtistRepository(session),
albums=SqlAlchemyAlbumRepository(session),
storage=get_file_storage(),
tag_reader=MutagenTagReader(),
fingerprinter=FpcalcFingerprinter(settings.fpcalc_path),
acoustid=acoustid,
cover_extractor=MutagenCoverExtractor(),
cover_provider=cover_provider,
acoustid_trust_score=settings.acoustid_trust_score,
)
result = await service.enrich(tid)
except Exception as exc:
# The run's own transaction rolled back, leaving the track stuck at
# ``pending``. Record the failure in a fresh session so the UI shows a
# ``failed`` status with a reason instead of a silent, endless spinner.
log.exception("enrich_failed", track_id=track_id)
async with session_scope() as session:
await SqlAlchemyTrackRepository(session).mark_enrichment_failed(
tid, error=f"Enrichment crashed: {type(exc).__name__}: {exc}"
)
return {"track_id": track_id, "status": "failed", "mbid": None}
return {
"track_id": str(result.track_id),
"status": result.status,
"mbid": result.matched_mbid,
}
+52
View File
@@ -0,0 +1,52 @@
"""arq task: scan an indexable source and import its files into the library.
Heavy work (directory walk + file copies) belongs off the request cycle
(CLAUDE.md). The HTTP endpoint enqueues this; the worker runs it with its own
transactional session.
"""
import uuid
from typing import Any
from app.application.import_service import LibraryImportService
from app.core.config import get_settings
from app.core.logging import get_logger
from app.infrastructure.db import session_scope
from app.infrastructure.db.repositories import (
SqlAlchemyArtistRepository,
SqlAlchemyTrackRepository,
)
from app.infrastructure.sources.registry import build_source_registry
from app.infrastructure.storage.provider import get_file_storage
from app.workers.queue import enqueue_enrich
log = get_logger("worker.import")
async def scan_local_folder(
_ctx: dict[str, Any], *, source: str = "local", added_by: str | None = None
) -> dict[str, Any]:
registry = build_source_registry(get_settings())
backend = registry.indexable(source)
actor = uuid.UUID(added_by) if added_by else None
async with session_scope() as session:
service = LibraryImportService(
tracks=SqlAlchemyTrackRepository(session),
artists=SqlAlchemyArtistRepository(session),
storage=get_file_storage(),
)
summary = await service.scan_and_import(backend, added_by=actor)
# Enqueue enrichment only after the import transaction has committed above,
# so the enrich worker is guaranteed to see the new rows.
for track_id in summary.imported_ids:
await enqueue_enrich(track_id)
return {
"source": summary.source,
"seen": summary.seen,
"imported": summary.imported,
"skipped": summary.skipped,
"failed": summary.failed,
}
+101
View File
@@ -0,0 +1,101 @@
"""arq task: materialize a remote placeholder track (plan: Model C).
Counterpart to ``download_task`` for tracks that were *saved* from a remote
browse hit without audio (``availability="remote"``, ``storage_uri=NULL``).
The job's ``track_id`` already points at the existing placeholder row — on
success the file is stored and ``TrackRepository.materialize`` fills the row
in place (the track's ``id`` never changes), then enrichment is enqueued as
usual.
Shares its fetch/retry/failure machinery with ``download_task`` — only the
"what happens on success" step differs (fill in an existing row vs. create a
new one).
"""
import contextlib
import uuid
from typing import Any
import anyio
from app.core.config import get_settings
from app.core.logging import correlation_id, get_logger
from app.domain.errors import NotFoundError, ValidationError
from app.domain.sources import DownloadResult
from app.infrastructure.db import session_scope
from app.infrastructure.db.repositories import (
SqlAlchemyDownloadJobRepository,
SqlAlchemyTrackRepository,
)
from app.infrastructure.sources.registry import build_source_registry
from app.infrastructure.storage.provider import get_file_storage
from app.workers.queue import enqueue_enrich
from app.workers.tasks.download_task import _handle_failure, _load_job, _mark_failed, _run_fetch
log = get_logger("worker.materialize")
async def materialize_track(_ctx: dict[str, Any], *, job_id: str) -> dict[str, Any]:
correlation_id.set(f"mat:{job_id}")
jid = uuid.UUID(job_id)
settings = get_settings()
job = await _load_job(jid)
if job is None:
log.info("materialize_job_missing", job_id=job_id) # cancelled before pickup
return {"job_id": job_id, "status": "missing"}
if job.track_id is None or job.source_id is None:
await _mark_failed(jid, "Materialize job missing track_id/source_id.")
return {"job_id": job_id, "status": "failed"}
registry = build_source_registry(settings)
try:
backend = registry.fetchable(job.source)
except (NotFoundError, ValidationError) as exc:
await _mark_failed(jid, f"Source unavailable: {exc}")
return {"job_id": job_id, "status": "failed"}
await _set_status(jid, "downloading")
try:
result = await _run_fetch(backend, job.source_id, jid)
except Exception as exc:
return await _handle_failure(jid, exc, settings.download_max_retries, job_id)
try:
await _materialize_result(jid, job.track_id, result)
except Exception as exc:
log.exception("materialize_finalize_failed", job_id=job_id)
await _mark_failed(jid, f"Materialize failed: {type(exc).__name__}: {exc}")
return {"job_id": job_id, "status": "failed"}
await enqueue_enrich(job.track_id)
log.info("materialize_complete", job_id=job_id, track_id=str(job.track_id))
return {"job_id": job_id, "status": "done", "track_id": str(job.track_id)}
async def _materialize_result(jid: uuid.UUID, track_id: uuid.UUID, result: DownloadResult) -> None:
"""Store the downloaded file and fill in the placeholder track in place."""
key = f"tracks/{str(track_id)[:2]}/{track_id}.{result.file_format}"
storage = get_file_storage()
try:
await storage.save_file(key, result.path)
async with session_scope() as session:
job_repo = SqlAlchemyDownloadJobRepository(session)
await job_repo.set_status(jid, status="enriching")
tracks = SqlAlchemyTrackRepository(session)
await tracks.materialize(
track_id,
storage_uri=key,
file_format=result.file_format,
file_size=result.file_size,
bitrate=result.bitrate,
)
await job_repo.set_status(jid, status="done", track_id=track_id)
finally:
with contextlib.suppress(Exception):
await anyio.Path(result.path).unlink(missing_ok=True)
async def _set_status(jid: uuid.UUID, status: str) -> None:
async with session_scope() as session:
await SqlAlchemyDownloadJobRepository(session).set_status(jid, status=status)
+16
View File
@@ -21,8 +21,15 @@ dependencies = [
# auth # auth
"pyjwt>=2.10", "pyjwt>=2.10",
"pwdlib[argon2]>=0.2.1", "pwdlib[argon2]>=0.2.1",
# symmetric encryption for the recoverable Subsonic app-password (Fernet)
"cryptography>=44.0",
# outbound http (ML client, MusicBrainz, AcoustID) # outbound http (ML client, MusicBrainz, AcoustID)
"httpx>=0.28", "httpx>=0.28",
# embedded audio tag reading (enrichment tag pre-pass)
"mutagen>=1.47",
# youtube source: search (ytmusicapi) + download (yt-dlp)
"ytmusicapi>=1.8",
"yt-dlp>=2024.12.13",
# S3-compatible object storage # S3-compatible object storage
"aioboto3>=13.0", "aioboto3>=13.0",
# logging # logging
@@ -71,6 +78,11 @@ select = [
] ]
ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic
[tool.ruff.lint.per-file-ignores]
# Subsonic query params are camelCase by spec (artistCount, songId, …); the
# handler arg names must match the wire names exactly.
"app/api/rest/*" = ["N803"]
[tool.mypy] [tool.mypy]
python_version = "3.14" python_version = "3.14"
strict = true strict = true
@@ -86,6 +98,10 @@ ignore_missing_imports = true
module = ["aioboto3.*", "aiobotocore.*", "botocore.*"] module = ["aioboto3.*", "aiobotocore.*", "botocore.*"]
ignore_missing_imports = true ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["ytmusicapi.*", "yt_dlp.*"]
ignore_missing_imports = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
+77
View File
@@ -3,10 +3,28 @@
The ASGI app is driven in-process via httpx + asgi-lifespan (no network, no The ASGI app is driven in-process via httpx + asgi-lifespan (no network, no
running server). DB/Redis-backed integration fixtures arrive with the data running server). DB/Redis-backed integration fixtures arrive with the data
layer (plan §11 step 2). layer (plan §11 step 2).
DB safety
---------
Integration fixtures call ``Base.metadata.drop_all`` / ``create_all`` on
``get_engine()``. That engine is built from ``DATABASE_URL``, which in normal
runs points at the *developer's* database — ``localhost:5432/mcma`` for host
``pytest`` and ``db:5432/mcma`` for ``make test-api`` (which execs ``pytest``
inside the api container). Running the suite there silently wipes real data:
``drop_all`` removes every ORM table while leaving Alembic's ``alembic_version``
(it lives outside ``Base.metadata``) — the exact "tables keep disappearing,
version survives" symptom.
To make that impossible, this module redirects every test to a dedicated
``<db>_test`` database *before settings/engine load*, and creates it on demand.
The real dev database is never opened by the test suite.
""" """
import asyncio
import os import os
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
import pytest import pytest
from asgi_lifespan import LifespanManager from asgi_lifespan import LifespanManager
@@ -17,6 +35,65 @@ os.environ.setdefault("ENVIRONMENT", "test")
os.environ.setdefault("JWT_SECRET", "test-secret") os.environ.setdefault("JWT_SECRET", "test-secret")
def _base_database_url() -> str:
"""Resolve the DB URL the app *would* use, mirroring pydantic-settings
precedence: real env var → ``.env`` file → the app's compiled-in default."""
if env := os.environ.get("DATABASE_URL"):
return env
dotenv = Path(__file__).resolve().parents[1] / ".env"
if dotenv.exists():
for raw in dotenv.read_text().splitlines():
line = raw.strip()
if line.startswith("DATABASE_URL=") and not line.startswith("#"):
return line.split("=", 1)[1].strip().strip("\"'")
return "postgresql+asyncpg://mcma:mcma@localhost:5432/mcma"
def _with_database(url: str, name: str) -> str:
"""Return ``url`` with its database name swapped for ``name``."""
return urlunsplit(urlsplit(url)._replace(path=f"/{name}"))
_BASE_DB_URL = _base_database_url()
_BASE_DB_NAME = urlsplit(_BASE_DB_URL).path.lstrip("/")
# Idempotent: if we're already pointed at a *_test DB, keep it as-is.
_TEST_DB_NAME = _BASE_DB_NAME if _BASE_DB_NAME.endswith("_test") else f"{_BASE_DB_NAME}_test"
_TEST_DB_URL = _with_database(_BASE_DB_URL, _TEST_DB_NAME)
# Redirect the whole suite to the test DB before anything reads settings.
os.environ["DATABASE_URL"] = _TEST_DB_URL
async def _create_test_db_if_missing() -> None:
"""Create ``<db>_test`` if the server is reachable. Best-effort: if Postgres
is down the integration fixtures skip on their own reachability probe, so a
failure here must stay silent rather than break unit-only runs."""
import asyncpg # type: ignore[import-untyped] # driver behind postgresql+asyncpg
# asyncpg wants a plain libpq DSN (no SQLAlchemy "+asyncpg" suffix), against
# the always-present ``postgres`` maintenance database.
dsn = _with_database(_TEST_DB_URL, "postgres").replace("+asyncpg", "")
try:
async with asyncio.timeout(5):
conn = await asyncpg.connect(dsn)
except Exception:
return
try:
exists = await conn.fetchval("SELECT 1 FROM pg_database WHERE datname = $1", _TEST_DB_NAME)
if not exists:
# CREATE DATABASE can't run inside a transaction; asyncpg's implicit
# autocommit on a bare connection handles that.
await conn.execute(f'CREATE DATABASE "{_TEST_DB_NAME}"')
finally:
await conn.close()
@pytest.fixture(scope="session", autouse=True)
def _ensure_test_database() -> None:
"""Guarantee the dedicated test database exists once per session."""
asyncio.run(_create_test_db_if_missing())
@pytest.fixture @pytest.fixture
async def client() -> AsyncIterator[AsyncClient]: async def client() -> AsyncIterator[AsyncClient]:
from app.main import create_app from app.main import create_app
+18 -1
View File
@@ -4,13 +4,14 @@ import datetime as dt
import uuid import uuid
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from app.domain.entities import Credentials, User from app.domain.entities import Credentials, SubsonicCredentials, User
@dataclass @dataclass
class _Stored: class _Stored:
user: User user: User
password_hash: str password_hash: str
subsonic_password_enc: str | None = None
class InMemoryUserRepository: class InMemoryUserRepository:
@@ -61,6 +62,22 @@ class InMemoryUserRepository:
async def count(self) -> int: async def count(self) -> int:
return len(self._by_id) return len(self._by_id)
async def get_subsonic_credentials_by_username(
self, username: str
) -> SubsonicCredentials | None:
for stored in self._by_id.values():
if stored.user.username == username:
return SubsonicCredentials(
user=stored.user, password_enc=stored.subsonic_password_enc
)
return None
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None:
return self._by_id[user_id].subsonic_password_enc
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None:
self._by_id[user_id].subsonic_password_enc = password_enc
@dataclass @dataclass
class _Token: class _Token:
+20
View File
@@ -0,0 +1,20 @@
# Test fixtures
## `scarlet_fire_otis_mcdonald.mp3`
"Scarlet Fire" by **Otis McDonald** — a royalty-free / license-free track
(YouTube Audio Library; distributed via Pro-Sound.org). Used as a real-world
audio fixture for the enrichment pipeline.
What makes it a good fixture: its **embedded ID3 tags are junk**
(`title=Sound_13958`, `artist=Music Track`, `album=Музыка`, `genre=Hip Hop & Rap`)
while AcoustID identifies it with very high confidence as *Scarlet Fire /
Otis McDonald*. So it exercises both:
- the offline tag reader (deterministic, always runs), and
- the "trust a high-confidence AcoustID match over junk tags" path
(`acoustid_trust_score`), which only runs when `fpcalc` + an AcoustID API key
+ network are available — see `tests/test_real_file_enrichment.py`.
Because it's license-free, it may also seed a built-in demo track for fresh
instances.
Binary file not shown.
+76
View File
@@ -0,0 +1,76 @@
"""Unit tests for the AcoustID response parser — pure, no network."""
from app.infrastructure.metadata.acoustid import _parse_best_match
def _payload_with_results(results: list[object]) -> dict[str, object]:
return {"status": "ok", "results": results}
def test_parses_full_recording() -> None:
payload = _payload_with_results(
[
{
"id": "acoustid-1",
"score": 0.97,
"recordings": [
{
"id": "mb-rec-1",
"title": "One More Time",
"artists": [{"id": "a1", "name": "Daft Punk"}],
"releasegroups": [{"id": "rg1", "title": "Discovery"}],
}
],
}
]
)
match = _parse_best_match(payload)
assert match is not None
assert match.acoustid == "acoustid-1"
assert match.recording_mbid == "mb-rec-1"
assert match.title == "One More Time"
assert match.artist == "Daft Punk"
assert match.album == "Discovery"
assert match.release_group_mbid == "rg1"
assert match.score == 0.97
def test_picks_highest_score() -> None:
payload = _payload_with_results(
[
{"id": "low", "score": 0.40, "recordings": [{"id": "r-low", "title": "Low"}]},
{"id": "high", "score": 0.92, "recordings": [{"id": "r-high", "title": "High"}]},
]
)
match = _parse_best_match(payload)
assert match is not None
assert match.acoustid == "high"
assert match.title == "High"
def test_result_without_recordings_still_returns_id() -> None:
payload = _payload_with_results([{"id": "acoustid-only", "score": 0.5}])
match = _parse_best_match(payload)
assert match is not None
assert match.acoustid == "acoustid-only"
assert match.recording_mbid is None
assert match.title is None
def test_error_status_returns_none() -> None:
assert _parse_best_match({"status": "error", "error": {"message": "bad"}}) is None
def test_empty_results_returns_none() -> None:
assert _parse_best_match(_payload_with_results([])) is None
def test_non_dict_payload_returns_none() -> None:
assert _parse_best_match("nonsense") is None
assert _parse_best_match(None) is None
+47
View File
@@ -152,6 +152,53 @@ async def test_admin_create_duplicate_conflicts(api: AsyncClient) -> None:
assert dup.status_code == 409 assert dup.status_code == 409
async def test_register_creates_user_and_logs_in(api: AsyncClient) -> None:
resp = await api.post(
"/api/v1/auth/register",
json={"username": "frank", "password": "frankpass1"},
)
assert resp.status_code == 201, resp.text
access = resp.json()["access_token"]
assert resp.json()["refresh_token"]
# The returned access token is immediately usable; account is a regular user.
me = await api.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
assert me.status_code == 200
assert me.json()["username"] == "frank"
assert me.json()["is_superuser"] is False
async def test_register_duplicate_conflicts(api: AsyncClient) -> None:
payload = {"username": "grace", "password": "gracepass1"}
first = await api.post("/api/v1/auth/register", json=payload)
assert first.status_code == 201
dup = await api.post("/api/v1/auth/register", json=payload)
assert dup.status_code == 409
async def test_register_short_password_rejected(api: AsyncClient) -> None:
resp = await api.post(
"/api/v1/auth/register",
json={"username": "heidi", "password": "short"},
)
assert resp.status_code == 422
async def test_register_disabled_forbidden(api: AsyncClient) -> None:
from app.core.config import get_settings
settings = get_settings()
settings.allow_registration = False
try:
resp = await api.post(
"/api/v1/auth/register",
json={"username": "ivan", "password": "ivanpass12"},
)
assert resp.status_code == 403
finally:
settings.allow_registration = True
async def test_deactivated_user_cannot_login(api: AsyncClient) -> None: async def test_deactivated_user_cannot_login(api: AsyncClient) -> None:
admin_access, _ = await _login(api, "admin", "adminpass1") admin_access, _ = await _login(api, "admin", "adminpass1")
headers = {"Authorization": f"Bearer {admin_access}"} headers = {"Authorization": f"Bearer {admin_access}"}
+194
View File
@@ -0,0 +1,194 @@
"""Integration tests for the native cover-art endpoints.
Seeds an album with a stored cover, then exercises the ``/api/v1`` album/track
cover endpoints (token auth, 404 when absent). Requires a reachable Postgres;
skips otherwise.
"""
import asyncio
import os
import uuid
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from app.core.config import get_settings
from app.infrastructure.db import Base, dispose_engine, get_engine, session_scope
from app.infrastructure.db.repositories import (
SqlAlchemyAlbumRepository,
SqlAlchemyArtistRepository,
SqlAlchemyRefreshTokenRepository,
SqlAlchemyTrackRepository,
SqlAlchemyUserRepository,
)
from app.infrastructure.storage.provider import get_file_storage
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
pytestmark = pytest.mark.asyncio
# A minimal valid 1x1 PNG.
_PNG_BYTES = bytes.fromhex(
"89504e470d0a1a0a0000000d4948445200000001000000010802000000907753"
"de0000000c4944415408d763f8cfc0f01f0005000155a2b4f60000000049454e44ae426082"
)
_db_reachable_cache: bool | None = None
async def _db_reachable() -> bool:
global _db_reachable_cache
if _db_reachable_cache is not None:
return _db_reachable_cache
from sqlalchemy import text
try:
async with asyncio.timeout(3):
async with get_engine().connect() as conn:
await conn.execute(text("SELECT 1"))
_db_reachable_cache = True
except Exception:
_db_reachable_cache = False
return _db_reachable_cache
async def _seed_album_with_cover(*, with_cover: bool) -> tuple[uuid.UUID, uuid.UUID]:
"""Create an artist + album (+ optional cover file) + track. Returns
``(album_id, track_id)``."""
async with session_scope() as session:
artist = await SqlAlchemyArtistRepository(session).get_or_create("Coverart Artist")
album = await SqlAlchemyAlbumRepository(session).get_or_create(
title="Coverart Album", artist_id=artist.id, year=2020, musicbrainz_id=None
)
track = await SqlAlchemyTrackRepository(session).add(
id=uuid.uuid4(),
title="Coverart Track",
artist_id=artist.id,
storage_uri="tracks/zz/cover-track.mp3",
file_format="mp3",
file_size=10,
source="upload",
source_id="cover-src",
metadata_status="enriched",
added_by=None,
)
# Link the track to the album (add() doesn't take album_id).
await SqlAlchemyTrackRepository(session).apply_enrichment(
track.id,
title="Coverart Track",
artist_id=artist.id,
album_id=album.id,
genre=None,
year=2020,
track_number=1,
duration_seconds=1,
bitrate=None,
acoustid_fingerprint=None,
musicbrainz_id=None,
metadata_status="enriched",
)
if with_cover:
key = f"covers/{album.id}.png"
import tempfile
with tempfile.NamedTemporaryFile(suffix=".png") as tmp:
tmp.write(_PNG_BYTES)
tmp.flush()
await get_file_storage().save_file(key, Path(tmp.name))
await SqlAlchemyAlbumRepository(session).set_cover_path(album.id, key)
return album.id, track.id
@pytest.fixture
async def api(tmp_path: Path) -> AsyncIterator[AsyncClient]:
if not await _db_reachable():
pytest.skip("Postgres not reachable — integration test skipped.")
os.environ["MEDIA_PATH"] = str(tmp_path)
get_settings.cache_clear()
import app.infrastructure.storage.provider as _storage_provider
_storage_provider._storage = None
try:
async with get_engine().begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
from app.application.user_service import UserService
from app.core.security import Argon2PasswordHasher
async with session_scope() as session:
await UserService(
users=SqlAlchemyUserRepository(session),
refresh_tokens=SqlAlchemyRefreshTokenRepository(session),
hasher=Argon2PasswordHasher(),
).create_user(username="testuser", password="testpass1", is_superuser=False)
from app.main import create_app
app = create_app()
async with LifespanManager(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
async with get_engine().begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await dispose_engine()
finally:
_storage_provider._storage = None
os.environ.pop("MEDIA_PATH", None)
get_settings.cache_clear()
async def _login(api: AsyncClient) -> str:
resp = await api.post(
"/api/v1/auth/login", json={"username": "testuser", "password": "testpass1"}
)
assert resp.status_code == 200
return str(resp.json()["access_token"])
async def test_album_cover_served(api: AsyncClient) -> None:
token = await _login(api)
album_id, _ = await _seed_album_with_cover(with_cover=True)
resp = await api.get(f"/api/v1/albums/{album_id}/cover?token={token}")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
assert resp.content == _PNG_BYTES
async def test_track_cover_served_from_album(api: AsyncClient) -> None:
token = await _login(api)
_, track_id = await _seed_album_with_cover(with_cover=True)
resp = await api.get(f"/api/v1/tracks/{track_id}/cover?token={token}")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
assert resp.content == _PNG_BYTES
async def test_album_without_cover_is_404(api: AsyncClient) -> None:
token = await _login(api)
album_id, _ = await _seed_album_with_cover(with_cover=False)
resp = await api.get(f"/api/v1/albums/{album_id}/cover?token={token}")
assert resp.status_code == 404
async def test_cover_requires_auth(api: AsyncClient) -> None:
album_id, _ = await _seed_album_with_cover(with_cover=True)
resp = await api.get(f"/api/v1/albums/{album_id}/cover")
assert resp.status_code == 401
async def test_album_appears_with_has_cover_flag(api: AsyncClient) -> None:
token = await _login(api)
album_id, _ = await _seed_album_with_cover(with_cover=True)
resp = await api.get(f"/api/v1/albums/{album_id}", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert resp.json()["has_cover"] is True
+222
View File
@@ -0,0 +1,222 @@
"""Unit tests for DownloadService — DB-free, in-memory fakes."""
import datetime as dt
import uuid
from pathlib import Path
import pytest
from app.application.download_service import DownloadService
from app.domain.entities import Artist, Track
from app.domain.entities.download import DownloadJob
from app.domain.sources import DownloadResult
pytestmark = pytest.mark.asyncio
class FakeArtistRepo:
async def get_or_create(self, name: str) -> Artist:
now = dt.datetime.now(dt.UTC)
return Artist(
id=uuid.uuid4(),
name=name,
source=None,
source_id=None,
created_at=now,
updated_at=now,
)
class FakeTrackRepo:
def __init__(self) -> None:
self.by_source: dict[tuple[str, str], Track] = {}
self.added: list[Track] = []
async def get_by_source(self, source: str, source_id: str) -> Track | None:
return self.by_source.get((source, source_id))
async def add(self, **kw: object) -> Track:
now = dt.datetime.now(dt.UTC)
track = Track(
id=kw["id"], # type: ignore[arg-type]
title=str(kw["title"]),
artist_id=kw["artist_id"], # type: ignore[arg-type]
album_id=None,
storage_uri=str(kw["storage_uri"]),
file_format=str(kw["file_format"]),
file_size=int(kw["file_size"]), # type: ignore[call-overload]
source=str(kw["source"]),
source_id=str(kw["source_id"]),
duration_seconds=None,
genre=None,
year=None,
track_number=None,
metadata_status=str(kw["metadata_status"]),
metadata_error=None,
enriched_at=None,
availability="local",
created_at=now,
updated_at=now,
)
self.by_source[(track.source, track.source_id)] = track
self.added.append(track)
return track
class FakeStorage:
def __init__(self) -> None:
self.saved: dict[str, Path] = {}
self.deleted: list[str] = []
async def save_file(self, key: str, src_path: Path) -> int:
self.saved[key] = src_path
return 1
async def delete(self, key: str) -> None:
self.deleted.append(key)
class FakeJobRepo:
def __init__(self) -> None:
self.jobs: dict[uuid.UUID, DownloadJob] = {}
self.active: dict[tuple[str, str], DownloadJob] = {}
def _make(self, **kw: object) -> DownloadJob:
now = dt.datetime.now(dt.UTC)
return DownloadJob(
id=uuid.uuid4(),
source=str(kw["source"]),
source_id=kw.get("source_id"), # type: ignore[arg-type]
query=kw.get("query"), # type: ignore[arg-type]
requested_by=kw.get("requested_by"), # type: ignore[arg-type]
status="queued",
progress=0.0,
error_message=None,
retry_count=0,
track_id=None,
created_at=now,
updated_at=now,
)
async def add(self, **kw: object) -> DownloadJob:
job = self._make(**kw)
self.jobs[job.id] = job
return job
async def get_by_id(self, job_id: uuid.UUID) -> DownloadJob | None:
return self.jobs.get(job_id)
async def get_active_for_source(self, source: str, source_id: str) -> DownloadJob | None:
return self.active.get((source, source_id))
async def set_status(self, job_id: uuid.UUID, **kw: object) -> None: ...
async def delete(self, job_id: uuid.UUID) -> None:
self.jobs.pop(job_id, None)
def _service(
*, jobs: FakeJobRepo, tracks: FakeTrackRepo, storage: FakeStorage, enqueued: list[uuid.UUID]
) -> DownloadService:
async def enqueue_download(job_id: uuid.UUID) -> None:
enqueued.append(job_id)
return DownloadService(
jobs=jobs, # type: ignore[arg-type]
tracks=tracks, # type: ignore[arg-type]
artists=FakeArtistRepo(), # type: ignore[arg-type]
storage=storage, # type: ignore[arg-type]
enqueue_download=enqueue_download,
)
def _track(source: str, source_id: str) -> Track:
now = dt.datetime.now(dt.UTC)
return Track(
id=uuid.uuid4(),
title="t",
artist_id=uuid.uuid4(),
album_id=None,
storage_uri="k",
file_format="mp3",
file_size=1,
source=source,
source_id=source_id,
duration_seconds=None,
genre=None,
year=None,
track_number=None,
metadata_status="pending",
metadata_error=None,
enriched_at=None,
availability="local",
created_at=now,
updated_at=now,
)
async def test_request_dedups_against_library() -> None:
jobs, tracks, storage, enq = FakeJobRepo(), FakeTrackRepo(), FakeStorage(), []
tracks.by_source[("youtube", "abc")] = _track("youtube", "abc")
svc = _service(jobs=jobs, tracks=tracks, storage=storage, enqueued=enq)
result = await svc.request(source="youtube", source_id="abc", query=None, requested_by=None)
assert result.already_in_library is True
assert result.track_id is not None
assert result.job is None
assert enq == [] # nothing enqueued
async def test_request_returns_existing_active_job() -> None:
jobs, tracks, storage, enq = FakeJobRepo(), FakeTrackRepo(), FakeStorage(), []
existing = await jobs.add(source="youtube", source_id="abc", query=None, requested_by=None)
jobs.active[("youtube", "abc")] = existing
svc = _service(jobs=jobs, tracks=tracks, storage=storage, enqueued=enq)
result = await svc.request(source="youtube", source_id="abc", query=None, requested_by=None)
assert result.already_in_library is False
assert result.job is not None
assert result.job.id == existing.id
assert enq == [] # not re-enqueued
async def test_request_creates_and_enqueues_new_job() -> None:
jobs, tracks, storage, enq = FakeJobRepo(), FakeTrackRepo(), FakeStorage(), []
svc = _service(jobs=jobs, tracks=tracks, storage=storage, enqueued=enq)
result = await svc.request(
source="youtube", source_id="abc", query="bohemian", requested_by=None
)
assert result.already_in_library is False
assert result.job is not None
assert enq == [result.job.id]
async def test_store_result_imports_and_cleans_temp(tmp_path: Path) -> None:
jobs, tracks, storage, enq = FakeJobRepo(), FakeTrackRepo(), FakeStorage(), []
svc = _service(jobs=jobs, tracks=tracks, storage=storage, enqueued=enq)
audio = tmp_path / "abc.webm"
audio.write_bytes(b"audio" * 20)
result = DownloadResult(
source_id="abc",
path=audio,
file_format="m4a",
file_size=100,
bitrate=160,
suggested_title="Bohemian Rhapsody",
)
track_id = await svc.store_result(source="youtube", result=result, requested_by=None)
assert len(tracks.added) == 1
stored = tracks.added[0]
assert stored.id == track_id
assert stored.source == "youtube"
assert stored.source_id == "abc"
assert stored.metadata_status == "pending"
assert stored.title == "Bohemian Rhapsody"
assert len(storage.saved) == 1
assert not audio.exists() # temp file removed
+360
View File
@@ -0,0 +1,360 @@
"""Integration tests for downloads + external search.
Requires a reachable Postgres; skips otherwise. The download worker task is
invoked directly (no Redis needed) against a fake fetch source, so the full
DB + storage import path is covered without touching the network.
"""
import asyncio
import os
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
import pytest
from app.core.config import get_settings
from app.domain.sources import KIND_FETCH, DownloadResult, SearchResult, SourceInfo
from app.infrastructure.db import Base, dispose_engine, get_engine, session_scope
from app.infrastructure.db.repositories import (
SqlAlchemyRefreshTokenRepository,
SqlAlchemyUserRepository,
)
from app.infrastructure.sources.registry import SourceRegistry
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
pytestmark = pytest.mark.asyncio
_db_reachable_cache: bool | None = None
async def _db_reachable() -> bool:
global _db_reachable_cache
if _db_reachable_cache is not None:
return _db_reachable_cache
from sqlalchemy import text
try:
async with asyncio.timeout(3):
async with get_engine().connect() as conn:
await conn.execute(text("SELECT 1"))
_db_reachable_cache = True
except Exception:
_db_reachable_cache = False
return _db_reachable_cache
class FakeFetchSource:
"""A searchable + fetchable source that writes a local file (no network)."""
name = "youtube"
def __init__(self, tmp_dir: Path) -> None:
self._tmp_dir = tmp_dir
def info(self) -> SourceInfo:
return SourceInfo(name=self.name, label="YouTube Music", kind=KIND_FETCH, available=True)
def is_available(self) -> bool:
return True
async def search(self, query: str, *, limit: int) -> list[SearchResult]:
return [
SearchResult(
source=self.name,
source_id="vid-1",
title=f"{query} song",
artist="Some Artist",
album="Some Album",
duration_seconds=200,
thumbnail_url="http://img/large.jpg",
)
]
async def fetch(self, source_id: str, *, on_progress: Any = None) -> DownloadResult:
path = self._tmp_dir / f"{source_id}.m4a"
path.write_bytes(b"downloaded audio bytes" * 8)
if on_progress is not None:
await on_progress(0.5)
return DownloadResult(
source_id=source_id,
path=path,
file_format="webm",
file_size=path.stat().st_size,
bitrate=160,
suggested_title=f"Title for {source_id}",
)
async def get_metadata(self, source_id: str) -> None:
return None
@pytest.fixture
async def api(tmp_path: Path) -> AsyncIterator[AsyncClient]:
if not await _db_reachable():
pytest.skip("Postgres not reachable — integration test skipped.")
media = tmp_path / "media"
media.mkdir()
os.environ["MEDIA_PATH"] = str(media)
get_settings.cache_clear()
import app.infrastructure.storage.provider as _storage_provider
_storage_provider._storage = None
try:
async with get_engine().begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
from app.application.user_service import UserService
from app.core.security import Argon2PasswordHasher
async with session_scope() as session:
await UserService(
users=SqlAlchemyUserRepository(session),
refresh_tokens=SqlAlchemyRefreshTokenRepository(session),
hasher=Argon2PasswordHasher(),
).create_user(username="admin", password="adminpass1", is_superuser=True)
from app.api.deps import get_source_registry
from app.main import create_app
app = create_app()
# Inject a fake fetch source so search/download never hit the network.
fake_registry = SourceRegistry([FakeFetchSource(tmp_path / "dl")]) # type: ignore[list-item]
(tmp_path / "dl").mkdir()
app.dependency_overrides[get_source_registry] = lambda: fake_registry
async with LifespanManager(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
async with get_engine().begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await dispose_engine()
finally:
_storage_provider._storage = None
os.environ.pop("MEDIA_PATH", None)
get_settings.cache_clear()
async def _login(api: AsyncClient) -> str:
resp = await api.post(
"/api/v1/auth/login", json={"username": "admin", "password": "adminpass1"}
)
assert resp.status_code == 200
return str(resp.json()["access_token"])
async def test_search_aggregates_fetch_sources(api: AsyncClient) -> None:
token = await _login(api)
headers = {"Authorization": f"Bearer {token}"}
resp = await api.get("/api/v1/search", params={"q": "queen"}, headers=headers)
assert resp.status_code == 200
body = resp.json()
assert body["searched_sources"] == ["youtube"]
assert len(body["results"]) == 1
hit = body["results"][0]
assert hit["source"] == "youtube"
assert hit["source_id"] == "vid-1"
assert hit["title"] == "queen song"
async def test_search_reports_library_status(api: AsyncClient) -> None:
"""Remote browse (plan: Model C) — a fresh hit isn't in the library; after
saving it as a placeholder, the same search reports it as such."""
token = await _login(api)
headers = {"Authorization": f"Bearer {token}"}
resp = await api.get("/api/v1/search", params={"q": "queen"}, headers=headers)
hit = resp.json()["results"][0]
assert hit["in_library"] is False
assert hit["track_id"] is None
assert hit["availability"] is None
save = await api.post(
"/api/v1/tracks/remote",
json={
"source": hit["source"],
"source_id": hit["source_id"],
"title": hit["title"],
"artist": hit["artist"],
},
headers=headers,
)
assert save.status_code == 201
saved = save.json()
assert saved["availability"] == "remote"
assert saved["file_format"] is None
resp2 = await api.get("/api/v1/search", params={"q": "queen"}, headers=headers)
hit2 = resp2.json()["results"][0]
assert hit2["in_library"] is True
assert hit2["track_id"] == saved["id"]
assert hit2["availability"] == "remote"
async def test_save_remote_is_idempotent(api: AsyncClient) -> None:
token = await _login(api)
headers = {"Authorization": f"Bearer {token}"}
payload = {"source": "youtube", "source_id": "vid-idem", "title": "A", "artist": "Artist"}
first = await api.post("/api/v1/tracks/remote", json=payload, headers=headers)
second = await api.post("/api/v1/tracks/remote", json=payload, headers=headers)
assert first.status_code == 201
assert second.status_code == 201
assert first.json()["id"] == second.json()["id"]
async def test_materialize_flow(
api: AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Save a placeholder, materialize it on demand, and confirm it streams
afterwards (plan: Model C lazy materialization)."""
token = await _login(api)
headers = {"Authorization": f"Bearer {token}"}
save = await api.post(
"/api/v1/tracks/remote",
json={
"source": "youtube",
"source_id": "vid-mat-1",
"title": "Materialize Me",
"artist": "Artist",
},
headers=headers,
)
track_id = save.json()["id"]
assert save.json()["availability"] == "remote"
# Streaming a placeholder before materialization fails (no audio yet).
stream_before = await api.get(f"/api/v1/stream/{track_id}", headers=headers)
assert stream_before.status_code == 404
materialize = await api.post(f"/api/v1/tracks/{track_id}/materialize", headers=headers)
assert materialize.status_code == 200
body = materialize.json()
assert body["job"] is not None
job_id = body["job"]["id"]
assert body["job"]["track_id"] == track_id
# A second materialize request reuses the same in-flight job.
again = await api.post(f"/api/v1/tracks/{track_id}/materialize", headers=headers)
assert again.json()["job"]["id"] == job_id
# Run the worker task directly (bypasses Redis) with the fake fetch source.
import app.workers.tasks.materialize_task as mat_task
worker_dir = tmp_path / "worker-mat"
worker_dir.mkdir()
fake = SourceRegistry([FakeFetchSource(worker_dir)]) # type: ignore[list-item]
monkeypatch.setattr(mat_task, "build_source_registry", lambda _settings: fake)
result = await mat_task.materialize_track({}, job_id=job_id)
assert result["status"] == "done"
assert result["track_id"] == track_id
got = await api.get(f"/api/v1/tracks/{track_id}", headers=headers)
assert got.json()["availability"] == "local"
assert got.json()["file_format"] == "webm"
# Streaming now works.
stream_after = await api.get(f"/api/v1/stream/{track_id}", headers=headers)
assert stream_after.status_code == 200
# Materializing an already-local track is a no-op.
noop = await api.post(f"/api/v1/tracks/{track_id}/materialize", headers=headers)
assert noop.json()["job"] is None
async def test_source_scoped_search(api: AsyncClient) -> None:
token = await _login(api)
headers = {"Authorization": f"Bearer {token}"}
resp = await api.get("/api/v1/sources/youtube/search", params={"q": "abba"}, headers=headers)
assert resp.status_code == 200
assert resp.json()["results"][0]["title"] == "abba song"
async def test_download_create_list_and_complete(
api: AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
token = await _login(api)
headers = {"Authorization": f"Bearer {token}"}
# Request a download — Redis is absent, so enqueue degrades but the job persists.
create = await api.post(
"/api/v1/downloads",
json={"source": "youtube", "source_id": "vid-1", "query": "queen"},
headers=headers,
)
assert create.status_code == 202
body = create.json()
assert body["already_in_library"] is False
job_id = body["job"]["id"]
assert body["job"]["status"] == "queued"
# It shows up in the listing.
listing = await api.get("/api/v1/downloads", headers=headers)
assert listing.status_code == 200
assert any(j["id"] == job_id for j in listing.json()["items"])
# A duplicate request returns the same in-flight job, not a new one.
dup = await api.post(
"/api/v1/downloads",
json={"source": "youtube", "source_id": "vid-1"},
headers=headers,
)
assert dup.json()["job"]["id"] == job_id
# Run the worker task directly (bypasses Redis) with the fake fetch source.
import app.workers.tasks.download_task as dl_task
worker_dl = tmp_path / "worker-dl"
worker_dl.mkdir()
fake = SourceRegistry([FakeFetchSource(worker_dl)]) # type: ignore[list-item]
monkeypatch.setattr(dl_task, "build_source_registry", lambda _settings: fake)
result = await dl_task.download_track({}, job_id=job_id)
assert result["status"] == "done"
track_id = result["track_id"]
# The job is now done and linked to the imported track.
got = await api.get(f"/api/v1/downloads/{job_id}", headers=headers)
assert got.json()["status"] == "done"
assert got.json()["track_id"] == track_id
# The imported track streams back.
stream = await api.get(f"/api/v1/stream/{track_id}", headers=headers)
assert stream.status_code == 200
assert len(stream.content) > 0
# A new request for the same item now dedups against the library.
again = await api.post(
"/api/v1/downloads",
json={"source": "youtube", "source_id": "vid-1"},
headers=headers,
)
assert again.json()["already_in_library"] is True
assert again.json()["track_id"] == track_id
async def test_cancel_download(api: AsyncClient) -> None:
token = await _login(api)
headers = {"Authorization": f"Bearer {token}"}
create = await api.post(
"/api/v1/downloads",
json={"source": "youtube", "source_id": "vid-cancel"},
headers=headers,
)
job_id = create.json()["job"]["id"]
cancel = await api.delete(f"/api/v1/downloads/{job_id}", headers=headers)
assert cancel.status_code == 204
got = await api.get(f"/api/v1/downloads/{job_id}", headers=headers)
assert got.status_code == 404
+172
View File
@@ -0,0 +1,172 @@
"""Unit tests for LibraryImportService — DB-free, in-memory fakes."""
import datetime as dt
import uuid
from collections.abc import Iterator
from pathlib import Path
import pytest
from app.application.import_service import LibraryImportService
from app.domain.entities import Artist, Track
from app.domain.sources import SourceFile, SourceInfo
pytestmark = pytest.mark.asyncio
class FakeArtistRepo:
def __init__(self) -> None:
self._by_name: dict[str, Artist] = {}
async def get_or_create(self, name: str) -> Artist:
if name not in self._by_name:
now = dt.datetime.now(dt.UTC)
self._by_name[name] = Artist(
id=uuid.uuid4(),
name=name,
source=None,
source_id=None,
created_at=now,
updated_at=now,
)
return self._by_name[name]
class FakeTrackRepo:
def __init__(self, *, fail_on: set[str] | None = None) -> None:
self.by_source: dict[tuple[str, str], Track] = {}
self.added: list[Track] = []
self._fail_on = fail_on or set()
async def get_by_source(self, source: str, source_id: str) -> Track | None:
return self.by_source.get((source, source_id))
async def add(self, **kw: object) -> Track:
source_id = str(kw["source_id"])
if source_id in self._fail_on:
raise RuntimeError("simulated add failure")
now = dt.datetime.now(dt.UTC)
track = Track(
id=uuid.UUID(str(kw["id"])) if not isinstance(kw["id"], uuid.UUID) else kw["id"],
title=str(kw["title"]),
artist_id=kw["artist_id"], # type: ignore[arg-type]
album_id=None,
storage_uri=str(kw["storage_uri"]),
file_format=str(kw["file_format"]),
file_size=int(kw["file_size"]), # type: ignore[call-overload]
source=str(kw["source"]),
source_id=source_id,
duration_seconds=None,
genre=None,
year=None,
track_number=None,
metadata_status=str(kw["metadata_status"]),
metadata_error=None,
enriched_at=None,
availability="local",
created_at=now,
updated_at=now,
)
self.by_source[(track.source, track.source_id)] = track
self.added.append(track)
return track
class FakeStorage:
def __init__(self) -> None:
self.saved: dict[str, Path] = {}
self.deleted: list[str] = []
async def save_file(self, key: str, src_path: Path) -> int:
self.saved[key] = src_path
return 1
async def delete(self, key: str) -> None:
self.deleted.append(key)
class FakeSource:
name = "local"
def __init__(self, files: list[SourceFile]) -> None:
self._files = files
def info(self) -> SourceInfo:
return SourceInfo(name=self.name, label="Local", kind="indexable", available=True)
def is_available(self) -> bool:
return True
def scan(self) -> Iterator[SourceFile]:
yield from self._files
def _file(source_id: str) -> SourceFile:
return SourceFile(
source_id=source_id,
path=Path("/music") / source_id,
suggested_title=Path(source_id).stem,
file_format="mp3",
file_size=123,
)
def _service(tracks: FakeTrackRepo, storage: FakeStorage) -> LibraryImportService:
return LibraryImportService(tracks=tracks, artists=FakeArtistRepo(), storage=storage) # type: ignore[arg-type]
async def test_imports_new_files() -> None:
tracks, storage = FakeTrackRepo(), FakeStorage()
source = FakeSource([_file("a.mp3"), _file("b/c.mp3")])
summary = await _service(tracks, storage).scan_and_import(source, added_by=None) # type: ignore[arg-type]
assert (summary.seen, summary.imported, summary.skipped, summary.failed) == (2, 2, 0, 0)
assert len(tracks.added) == 2
assert len(storage.saved) == 2
assert all(t.metadata_status == "pending" for t in tracks.added)
assert all(t.source == "local" for t in tracks.added)
async def test_dedup_skips_already_imported() -> None:
tracks, storage = FakeTrackRepo(), FakeStorage()
now = dt.datetime.now(dt.UTC)
tracks.by_source[("local", "a.mp3")] = Track(
id=uuid.uuid4(),
title="a",
artist_id=uuid.uuid4(),
album_id=None,
storage_uri="k",
file_format="mp3",
file_size=1,
source="local",
source_id="a.mp3",
duration_seconds=None,
genre=None,
year=None,
track_number=None,
metadata_status="pending",
metadata_error=None,
enriched_at=None,
availability="local",
created_at=now,
updated_at=now,
)
source = FakeSource([_file("a.mp3"), _file("new.mp3")])
summary = await _service(tracks, storage).scan_and_import(source, added_by=None) # type: ignore[arg-type]
assert (summary.imported, summary.skipped) == (1, 1)
assert len(storage.saved) == 1 # only the new file copied
async def test_per_file_failure_is_isolated_and_rolls_back_storage() -> None:
tracks = FakeTrackRepo(fail_on={"bad.mp3"})
storage = FakeStorage()
source = FakeSource([_file("good.mp3"), _file("bad.mp3")])
summary = await _service(tracks, storage).scan_and_import(source, added_by=None) # type: ignore[arg-type]
assert (summary.seen, summary.imported, summary.failed) == (2, 1, 1)
# The failed import's copied file was cleaned up; the good one stays.
assert len(storage.deleted) == 1
assert len(tracks.added) == 1

Some files were not shown because too many files have changed in this diff Show More