Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb7827d09c | |||
| c5a473fddf | |||
| 9a78cf5261 | |||
| d16c6085c9 | |||
| ed77acf0fe | |||
| 591a938e71 | |||
| 313af3a070 | |||
| 8271de34eb | |||
| 048854f92a | |||
| c47242aa3a | |||
| a263272935 | |||
| df580578f6 | |||
| fb5ce3c708 | |||
| e45e578f54 | |||
| 58b98ab5ed | |||
| 78007461e1 | |||
| ea880edd57 | |||
| fa23568214 | |||
| 636820afb8 | |||
| 63c7d05eca | |||
| 73d7da440f | |||
| 30cb8901f2 | |||
| 0bb752f582 | |||
| c7e078d758 | |||
| 356cd00772 | |||
| 14c1bc16e0 | |||
| c72d19599a | |||
| 48e3418c7f |
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "/Users/senko/.local/bin/graphify hook-guard search"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Read|Glob",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "/Users/senko/.local/bin/graphify hook-guard read"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -16,6 +16,9 @@ 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.
|
# subsonic — key that encrypts per-user Subsonic app-passwords at rest.
|
||||||
# GENERATE a strong secret for prod (`openssl rand -hex 32`); rotating it
|
# GENERATE a strong secret for prod (`openssl rand -hex 32`); rotating it
|
||||||
@@ -27,8 +30,13 @@ 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
|
||||||
|
|||||||
@@ -74,3 +74,13 @@ These exist for future sync + ML and are easy to violate by accident:
|
|||||||
## Python 3.14 note
|
## Python 3.14 note
|
||||||
|
|
||||||
The project targets Python 3.14, which makes annotations lazy by default (PEP 649). `from __future__ import annotations` is therefore intentionally **absent** — do not add it back. All pins (pyproject `requires-python`, ruff `target-version`, mypy `python_version`, Dockerfile, `.python-version`) are on 3.14.
|
The project targets Python 3.14, which makes annotations lazy by default (PEP 649). `from __future__ import annotations` is therefore intentionally **absent** — do not add it back. All pins (pyproject `requires-python`, ruff `target-version`, mypy `python_version`, Dockerfile, `.python-version`) are on 3.14.
|
||||||
|
|
||||||
|
## graphify
|
||||||
|
|
||||||
|
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||||
|
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||||
|
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||||
|
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||||
|
|||||||
@@ -71,6 +71,26 @@ 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`)
|
## Subsonic API (`/rest`)
|
||||||
|
|
||||||
A Subsonic-compatible API is mounted at `/rest`, so standard clients (Symfonium,
|
A Subsonic-compatible API is mounted at `/rest`, so standard clients (Symfonium,
|
||||||
|
|||||||
@@ -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")
|
||||||
+65
@@ -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 ###
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""user_settings: per-user preferences + scrobbling config
|
||||||
|
|
||||||
|
Revision ID: 20260728_user_settings
|
||||||
|
Revises: dc126696f5a6
|
||||||
|
Create Date: 2026-07-28 10:00:00.000000
|
||||||
|
|
||||||
|
Adds the ``user_settings`` table (1:1 with ``users``, PK = user_id): general
|
||||||
|
preferences (theme, stream quality) plus scrobbling config. The scrobbler
|
||||||
|
session key is stored Fernet-encrypted, never in plaintext.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "20260728_user_settings"
|
||||||
|
down_revision: str | None = "dc126696f5a6"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"user_settings",
|
||||||
|
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("theme", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("stream_quality", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("scrobble_enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("scrobble_provider", sa.String(length=16), nullable=True),
|
||||||
|
sa.Column("scrobble_username", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("scrobble_session_key_enc", sa.String(length=512), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"],
|
||||||
|
["users.id"],
|
||||||
|
name=op.f("fk_user_settings_user_id_users"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("user_id", name=op.f("pk_user_settings")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("user_settings")
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""sync: server-ingestion columns on the event logs
|
||||||
|
|
||||||
|
Revision ID: 20260728_sync_synced_at
|
||||||
|
Revises: 20260728_user_settings
|
||||||
|
Create Date: 2026-07-28 11:00:00.000000
|
||||||
|
|
||||||
|
Adds ``synced_at`` to ``likes`` and ``play_history`` — the server-side ingestion
|
||||||
|
time used as the delta-sync ordering key (distinct from the event time
|
||||||
|
``created_at``/``played_at``, which a sync push preserves from the client even
|
||||||
|
when the event happened offline earlier). Existing rows are backfilled from
|
||||||
|
their event time so a first sync after upgrade behaves sensibly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "20260728_sync_synced_at"
|
||||||
|
down_revision: str | None = "20260728_user_settings"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"likes",
|
||||||
|
sa.Column(
|
||||||
|
"synced_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.execute("UPDATE likes SET synced_at = created_at")
|
||||||
|
op.create_index(op.f("ix_likes_synced_at"), "likes", ["synced_at"])
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"play_history",
|
||||||
|
sa.Column(
|
||||||
|
"synced_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.execute("UPDATE play_history SET synced_at = played_at")
|
||||||
|
op.create_index(op.f("ix_play_history_synced_at"), "play_history", ["synced_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_play_history_synced_at"), table_name="play_history")
|
||||||
|
op.drop_column("play_history", "synced_at")
|
||||||
|
op.drop_index(op.f("ix_likes_synced_at"), table_name="likes")
|
||||||
|
op.drop_column("likes", "synced_at")
|
||||||
@@ -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)
|
||||||
+140
@@ -6,19 +6,29 @@ bound to the request-scoped DB session; stateless adapters (hasher, token
|
|||||||
service) are process-cached.
|
service) are process-cached.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
from collections.abc import AsyncIterator
|
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, Query
|
from fastapi import Depends, Query
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy import func, select
|
||||||
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.lyrics_service import LyricsService
|
||||||
|
from app.application.metadata_service import MetadataEnrichmentService
|
||||||
|
from app.application.recommendation_service import RecommendationService
|
||||||
|
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.subsonic_auth_service import SubsonicAuthService
|
||||||
|
from app.application.sync_service import SyncService
|
||||||
|
from app.application.transcode_service import TranscodeService
|
||||||
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.application.user_settings_service import UserSettingsService
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.security import Argon2PasswordHasher, JwtTokenService, SubsonicPasswordCipher
|
from app.core.security import Argon2PasswordHasher, JwtTokenService, SubsonicPasswordCipher
|
||||||
from app.domain.entities import User
|
from app.domain.entities import User
|
||||||
@@ -28,14 +38,24 @@ 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,
|
||||||
|
SqlAlchemyLyricsRepository,
|
||||||
SqlAlchemyPlaylistRepository,
|
SqlAlchemyPlaylistRepository,
|
||||||
SqlAlchemyRefreshTokenRepository,
|
SqlAlchemyRefreshTokenRepository,
|
||||||
SqlAlchemyTrackRepository,
|
SqlAlchemyTrackRepository,
|
||||||
SqlAlchemyUserRepository,
|
SqlAlchemyUserRepository,
|
||||||
|
SqlAlchemyUserSettingsRepository,
|
||||||
)
|
)
|
||||||
|
from app.infrastructure.metadata.acoustid import AcoustIdHttpClient
|
||||||
|
from app.infrastructure.metadata.fingerprint import FpcalcFingerprinter
|
||||||
|
from app.infrastructure.metadata.lrclib import LrclibHttpClient
|
||||||
|
from app.infrastructure.metadata.tags import MutagenTagReader
|
||||||
|
from app.infrastructure.ml.recommender import NullRecommender
|
||||||
|
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]:
|
||||||
@@ -70,6 +90,14 @@ def get_subsonic_cipher() -> SubsonicCipher:
|
|||||||
return SubsonicPasswordCipher(get_settings().subsonic_secret_key.get_secret_value())
|
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(
|
||||||
@@ -95,9 +123,17 @@ def get_subsonic_auth_service(session: SessionDep) -> SubsonicAuthService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_settings_service(session: SessionDep) -> UserSettingsService:
|
||||||
|
return UserSettingsService(
|
||||||
|
settings=SqlAlchemyUserSettingsRepository(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)]
|
SubsonicAuthServiceDep = Annotated[SubsonicAuthService, Depends(get_subsonic_auth_service)]
|
||||||
|
UserSettingsServiceDep = Annotated[UserSettingsService, Depends(get_user_settings_service)]
|
||||||
|
|
||||||
|
|
||||||
# -- file storage (process-cached) ---------------------------------------------
|
# -- file storage (process-cached) ---------------------------------------------
|
||||||
@@ -111,6 +147,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,8 +158,111 @@ 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_transcode_service(session: SessionDep) -> TranscodeService:
|
||||||
|
"""Request-side cache lookups for transcoded renditions (§6.6). Generation
|
||||||
|
itself runs in the ``transcode_track`` worker, never here."""
|
||||||
|
return TranscodeService(
|
||||||
|
tracks=SqlAlchemyTrackRepository(session),
|
||||||
|
cache_root=get_settings().transcode_cache_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_recommendation_service(session: SessionDep) -> RecommendationService:
|
||||||
|
"""Radio + similarity (§6.5). ML is optional and no service/contract exists
|
||||||
|
yet, so we wire ``NullRecommender`` — the service then uses its metadata
|
||||||
|
fallback. Swap in ``RemoteRecommender(ml_service_url)`` once ML lands."""
|
||||||
|
return RecommendationService(
|
||||||
|
recommender=NullRecommender(),
|
||||||
|
tracks=SqlAlchemyTrackRepository(session),
|
||||||
|
artists=SqlAlchemyArtistRepository(session),
|
||||||
|
likes=SqlAlchemyLikeRepository(session),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_lyrics_service(session: SessionDep) -> LyricsService:
|
||||||
|
"""Wires the LRCLIB lyrics provider + cache repo (plan §6.7). LRCLIB is
|
||||||
|
keyless, so this is always available; failures degrade to ``not_found``."""
|
||||||
|
settings = get_settings()
|
||||||
|
return LyricsService(
|
||||||
|
lyrics=SqlAlchemyLyricsRepository(session),
|
||||||
|
tracks=SqlAlchemyTrackRepository(session),
|
||||||
|
artists=SqlAlchemyArtistRepository(session),
|
||||||
|
albums=SqlAlchemyAlbumRepository(session),
|
||||||
|
provider=LrclibHttpClient(user_agent=settings.musicbrainz_user_agent),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)]
|
||||||
|
LyricsServiceDep = Annotated[LyricsService, Depends(get_lyrics_service)]
|
||||||
|
TranscodeServiceDep = Annotated[TranscodeService, Depends(get_transcode_service)]
|
||||||
|
RecommendationServiceDep = Annotated[
|
||||||
|
RecommendationService, Depends(get_recommendation_service)
|
||||||
|
]
|
||||||
|
DownloadServiceDep = Annotated[DownloadService, Depends(get_download_service)]
|
||||||
|
RemoteLibraryServiceDep = Annotated[RemoteLibraryService, Depends(get_remote_library_service)]
|
||||||
|
|
||||||
|
|
||||||
|
async def _db_now(session: AsyncSession) -> dt.datetime:
|
||||||
|
"""The database clock — the sync cursor watermark (avoids app/DB skew)."""
|
||||||
|
return (await session.execute(select(func.now()))).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
def get_sync_service(session: SessionDep) -> SyncService:
|
||||||
|
return SyncService(
|
||||||
|
likes=SqlAlchemyLikeRepository(session),
|
||||||
|
history=SqlAlchemyHistoryRepository(session),
|
||||||
|
playlists=SqlAlchemyPlaylistRepository(session),
|
||||||
|
tracks=SqlAlchemyTrackRepository(session),
|
||||||
|
now=lambda: _db_now(session),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SyncServiceDep = Annotated[SyncService, Depends(get_sync_service)]
|
||||||
|
|
||||||
|
|
||||||
# -- library repository deps ---------------------------------------------------
|
# -- library repository deps ---------------------------------------------------
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.domain.errors import (
|
|||||||
DependencyUnavailableError,
|
DependencyUnavailableError,
|
||||||
DomainError,
|
DomainError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
|
NotSupportedError,
|
||||||
PermissionDeniedError,
|
PermissionDeniedError,
|
||||||
RangeNotSatisfiableError,
|
RangeNotSatisfiableError,
|
||||||
StorageError,
|
StorageError,
|
||||||
@@ -33,6 +34,7 @@ _STATUS_BY_ERROR: dict[type[DomainError], int] = {
|
|||||||
ValidationError: status.HTTP_422_UNPROCESSABLE_CONTENT,
|
ValidationError: status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
AuthenticationError: status.HTTP_401_UNAUTHORIZED,
|
AuthenticationError: status.HTTP_401_UNAUTHORIZED,
|
||||||
PermissionDeniedError: status.HTTP_403_FORBIDDEN,
|
PermissionDeniedError: status.HTTP_403_FORBIDDEN,
|
||||||
|
NotSupportedError: status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
DependencyUnavailableError: status.HTTP_503_SERVICE_UNAVAILABLE,
|
DependencyUnavailableError: status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
StorageError: status.HTTP_500_INTERNAL_SERVER_ERROR,
|
StorageError: status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-10
@@ -1,10 +1,11 @@
|
|||||||
"""Subsonic media endpoints: stream, download, cover art.
|
"""Subsonic media endpoints: stream, download, cover art, lyrics.
|
||||||
|
|
||||||
``stream`` and ``download`` reuse :class:`StreamingService` (honouring HTTP
|
``stream`` and ``download`` reuse :class:`StreamingService` (honouring HTTP
|
||||||
Range) — they return raw bytes, not the Subsonic envelope. Transcoding params
|
Range) — they return raw bytes, not the Subsonic envelope. Transcoding params
|
||||||
(``maxBitRate``/``format``) are accepted but ignored; the original file is served
|
(``maxBitRate``/``format``) are accepted but ignored; the original file is served
|
||||||
(no in-request ffmpeg — CLAUDE.md). ``getCoverArt`` returns a placeholder until
|
(no in-request ffmpeg — CLAUDE.md). ``getCoverArt`` serves the album cover (a
|
||||||
the cover pipeline lands (the ``/api/v1`` cover endpoints are still stubs).
|
placeholder when there's none). ``getLyricsBySongId`` adapts the native
|
||||||
|
``LyricsService`` into the OpenSubsonic structured-lyrics shape.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -13,9 +14,22 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Header, Query
|
from fastapi import APIRouter, Header, Query
|
||||||
from fastapi.responses import Response, StreamingResponse
|
from fastapi.responses import Response, StreamingResponse
|
||||||
|
|
||||||
from app.api.deps import StreamingServiceDep, SubsonicUser, TrackRepoDep
|
from app.api.covers import resolve_album_for_track, stream_cover
|
||||||
from app.api.rest.ids import decode_track, parse
|
from app.api.deps import (
|
||||||
from app.domain.errors import NotFoundError
|
AlbumRepoDep,
|
||||||
|
ArtistRepoDep,
|
||||||
|
FileStorageDep,
|
||||||
|
LyricsServiceDep,
|
||||||
|
StreamingServiceDep,
|
||||||
|
SubsonicFormat,
|
||||||
|
SubsonicUser,
|
||||||
|
TrackRepoDep,
|
||||||
|
)
|
||||||
|
from app.api.rest.envelope import subsonic_response
|
||||||
|
from app.api.rest.ids import IdKind, decode_track, parse
|
||||||
|
from app.api.rest.serializers import structured_lyrics
|
||||||
|
from app.domain.entities.album import Album
|
||||||
|
from app.domain.errors import NotFoundError, StorageError
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -57,7 +71,7 @@ async def download(
|
|||||||
if track is None:
|
if track is None:
|
||||||
raise NotFoundError("Song not found.")
|
raise NotFoundError("Song not found.")
|
||||||
result = await service.open_stream(track_id, None)
|
result = await service.open_stream(track_id, None)
|
||||||
filename = f"{track.title}.{track.file_format}"
|
filename = f"{track.title}.{track.file_format or 'bin'}"
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Length": str(result.content_length),
|
"Content-Length": str(result.content_length),
|
||||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
@@ -69,10 +83,57 @@ async def download(
|
|||||||
@router.api_route("/getCoverArt.view", methods=["GET", "POST"])
|
@router.api_route("/getCoverArt.view", methods=["GET", "POST"])
|
||||||
async def get_cover_art(
|
async def get_cover_art(
|
||||||
_user: SubsonicUser,
|
_user: SubsonicUser,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
storage: FileStorageDep,
|
||||||
id: Annotated[str, Query()],
|
id: Annotated[str, Query()],
|
||||||
size: Annotated[int | None, Query()] = None,
|
size: Annotated[int | None, Query()] = None,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
# Validate the id shape so clients get a clean error on garbage, then serve a
|
# Cover ids reuse the entity id: ``al-<uuid>`` (album) or ``tr-<uuid>``
|
||||||
# placeholder. TODO: stream real covers once the cover pipeline exists.
|
# (track → its album). Unlike the native API, Subsonic clients expect an
|
||||||
parse(id)
|
# 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")
|
return Response(content=_PLACEHOLDER_PNG, media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/getLyricsBySongId", methods=["GET", "POST"])
|
||||||
|
@router.api_route("/getLyricsBySongId.view", methods=["GET", "POST"])
|
||||||
|
async def get_lyrics_by_song_id(
|
||||||
|
_user: SubsonicUser,
|
||||||
|
fmt: SubsonicFormat,
|
||||||
|
lyrics_service: LyricsServiceDep,
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
id: Annotated[str, Query()],
|
||||||
|
) -> Response:
|
||||||
|
# OpenSubsonic structured lyrics over the native LyricsService (§6.7). A miss
|
||||||
|
# is a normal empty ``lyricsList`` — the service degrades to not_found rather
|
||||||
|
# than raising, so clients get 200 either way.
|
||||||
|
track_id = decode_track(id)
|
||||||
|
track = await track_repo.get_by_id(track_id)
|
||||||
|
if track is None:
|
||||||
|
raise NotFoundError("Song not found.")
|
||||||
|
lyrics = await lyrics_service.get_lyrics(track_id)
|
||||||
|
artist = await artist_repo.get_by_id(track.artist_id)
|
||||||
|
return subsonic_response(
|
||||||
|
structured_lyrics(
|
||||||
|
lyrics,
|
||||||
|
display_artist=artist.name if artist is not None else "",
|
||||||
|
display_title=track.title,
|
||||||
|
),
|
||||||
|
fmt=fmt,
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,10 +6,17 @@ JSON equivalents). No business logic — they only reshape and rename.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.api.rest.ids import encode_album, encode_artist, encode_track
|
from app.api.rest.ids import encode_album, encode_artist, encode_track
|
||||||
from app.domain.entities import Album, Artist, Track
|
from app.domain.entities import Album, Artist, Track
|
||||||
|
from app.domain.entities.lyrics import Lyrics
|
||||||
|
|
||||||
|
# One LRC timecode: ``[mm:ss.xx]`` / ``[mm:ss.xxx]`` (fraction optional). A line
|
||||||
|
# may carry several (the same words repeat at multiple times); metadata tags like
|
||||||
|
# ``[ar:..]`` don't match, so they're ignored.
|
||||||
|
_LRC_TAG_RE = re.compile(r"\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]")
|
||||||
|
|
||||||
# Suffix → MIME, for the ``contentType``/``suffix`` song attributes. A
|
# Suffix → MIME, for the ``contentType``/``suffix`` song attributes. A
|
||||||
# presentation detail (mirrors StreamingService's content-type negotiation).
|
# presentation detail (mirrors StreamingService's content-type negotiation).
|
||||||
@@ -80,8 +87,8 @@ def song_dict(
|
|||||||
"albumId": encode_album(track.album_id) if track.album_id 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),
|
"artistId": encode_artist(track.artist_id),
|
||||||
"coverArt": cover,
|
"coverArt": cover,
|
||||||
"size": track.file_size,
|
"size": track.file_size or 0,
|
||||||
"contentType": content_type_for(track.file_format),
|
"contentType": content_type_for(track.file_format or ""),
|
||||||
"suffix": track.file_format,
|
"suffix": track.file_format,
|
||||||
"duration": track.duration_seconds,
|
"duration": track.duration_seconds,
|
||||||
"year": track.year,
|
"year": track.year,
|
||||||
@@ -90,3 +97,52 @@ def song_dict(
|
|||||||
"type": "music",
|
"type": "music",
|
||||||
"isVideo": False,
|
"isVideo": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_lrc(synced: str) -> list[dict[str, Any]]:
|
||||||
|
"""LRC text → OpenSubsonic ``line`` dicts (``start`` in ms, ``value`` text),
|
||||||
|
ordered by time. Lines with no timecode (blank lines, metadata tags) drop out;
|
||||||
|
a timecode carrying several stamps yields one line per stamp."""
|
||||||
|
lines: list[tuple[int, str]] = []
|
||||||
|
for raw in synced.splitlines():
|
||||||
|
stamps = list(_LRC_TAG_RE.finditer(raw))
|
||||||
|
if not stamps:
|
||||||
|
continue
|
||||||
|
text = _LRC_TAG_RE.sub("", raw).strip()
|
||||||
|
for m in stamps:
|
||||||
|
minutes, seconds = int(m.group(1)), int(m.group(2))
|
||||||
|
# LRC fractions are centiseconds (2 digits) or ms (3); pad to ms.
|
||||||
|
ms = int((m.group(3) or "0").ljust(3, "0")[:3])
|
||||||
|
lines.append(((minutes * 60 + seconds) * 1000 + ms, text))
|
||||||
|
lines.sort(key=lambda pair: pair[0])
|
||||||
|
return [{"start": start, "value": text} for start, text in lines]
|
||||||
|
|
||||||
|
|
||||||
|
def structured_lyrics(
|
||||||
|
lyrics: Lyrics, *, display_artist: str, display_title: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""OpenSubsonic ``getLyricsBySongId`` payload. Prefers synced (LRC) lines and
|
||||||
|
falls back to plain text; an empty ``lyricsList`` when the track has none."""
|
||||||
|
lines: list[dict[str, Any]] = []
|
||||||
|
synced = False
|
||||||
|
if lyrics.synced:
|
||||||
|
lines = _parse_lrc(lyrics.synced)
|
||||||
|
synced = bool(lines)
|
||||||
|
if not lines and lyrics.plain:
|
||||||
|
lines = [{"value": line} for line in lyrics.plain.splitlines()]
|
||||||
|
if not lines:
|
||||||
|
return {"lyricsList": {}}
|
||||||
|
return {
|
||||||
|
"lyricsList": {
|
||||||
|
"structuredLyrics": [
|
||||||
|
{
|
||||||
|
"displayArtist": display_artist,
|
||||||
|
"displayTitle": display_title,
|
||||||
|
"lang": "xxx",
|
||||||
|
"offset": 0,
|
||||||
|
"synced": synced,
|
||||||
|
"line": lines,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Admin (instance-management) response schemas."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.api.health import CheckStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ServicesStatusOut(BaseModel):
|
||||||
|
"""Backing-dependency health for the admin dashboard (mirrors readiness)."""
|
||||||
|
|
||||||
|
database: CheckStatus
|
||||||
|
redis: CheckStatus
|
||||||
|
ml: CheckStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ReindexJob(BaseModel):
|
||||||
|
source: str
|
||||||
|
job_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReindexResponse(BaseModel):
|
||||||
|
"""The scan jobs enqueued by a re-index, one per indexable source."""
|
||||||
|
|
||||||
|
jobs: list[ReindexJob]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminSettingsOut(BaseModel):
|
||||||
|
"""Effective, non-secret instance configuration. Secrets and connection
|
||||||
|
strings are never exposed — only whether an optional integration is set up."""
|
||||||
|
|
||||||
|
environment: str
|
||||||
|
allow_registration: bool
|
||||||
|
storage_backend: str
|
||||||
|
media_path: str
|
||||||
|
youtube_enabled: bool
|
||||||
|
coverart_enabled: bool
|
||||||
|
ml_configured: bool
|
||||||
|
acoustid_configured: bool
|
||||||
|
local_import_configured: bool
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Lyrics response schema (§6.7 / Now Playing lyrics panel).
|
||||||
|
|
||||||
|
Returns the raw LRC (``synced``) and/or ``plain`` text; the client parses LRC
|
||||||
|
timestamps for synced highlighting. A miss is a normal 200 with
|
||||||
|
``status="not_found"`` and null text — not an error — so the panel can render a
|
||||||
|
"no lyrics" state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.domain.entities.lyrics import Lyrics
|
||||||
|
|
||||||
|
|
||||||
|
class LyricsOut(BaseModel):
|
||||||
|
track_id: uuid.UUID
|
||||||
|
status: str
|
||||||
|
source: str | None
|
||||||
|
synced: str | None
|
||||||
|
plain: str | None
|
||||||
|
synced_available: bool
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_entity(cls, lyrics: Lyrics) -> LyricsOut:
|
||||||
|
return cls(
|
||||||
|
track_id=lyrics.track_id,
|
||||||
|
status=lyrics.status,
|
||||||
|
source=lyrics.source,
|
||||||
|
synced=lyrics.synced,
|
||||||
|
plain=lyrics.plain,
|
||||||
|
synced_available=lyrics.synced is not None,
|
||||||
|
)
|
||||||
@@ -29,3 +29,7 @@ class PlaylistUpdate(BaseModel):
|
|||||||
class PlaylistAddTrack(BaseModel):
|
class PlaylistAddTrack(BaseModel):
|
||||||
track_id: uuid.UUID
|
track_id: uuid.UUID
|
||||||
position: float | None = None
|
position: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistReorder(BaseModel):
|
||||||
|
track_ids: list[uuid.UUID]
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Radio + similarity response schemas (§6.5)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.api.schemas.artist import ArtistOut
|
||||||
|
from app.api.schemas.track import TrackOut
|
||||||
|
|
||||||
|
|
||||||
|
class RadioRequest(BaseModel):
|
||||||
|
"""Start or continue a radio. ``seed_track_id`` seeds from a track;
|
||||||
|
``from_likes`` seeds from the caller's likes. ``exclude_ids`` are already-
|
||||||
|
queued tracks to skip (the client drives the infinite feed). ``exploration``
|
||||||
|
biases familiar↔new."""
|
||||||
|
|
||||||
|
seed_track_id: uuid.UUID | None = None
|
||||||
|
from_likes: bool = False
|
||||||
|
exploration: float = Field(default=0.25, ge=0.0, le=1.0)
|
||||||
|
count: int = Field(default=20, ge=1, le=50)
|
||||||
|
exclude_ids: list[uuid.UUID] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RadioTrackOut(BaseModel):
|
||||||
|
track: TrackOut
|
||||||
|
# Short code the client localizes: ml | similar | from_likes | discover.
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class RadioResponse(BaseModel):
|
||||||
|
# Where the picks came from: "ml" or "metadata" (fallback).
|
||||||
|
source: str
|
||||||
|
tracks: list[RadioTrackOut]
|
||||||
|
|
||||||
|
|
||||||
|
class SimilarTracksOut(BaseModel):
|
||||||
|
source: str
|
||||||
|
tracks: list[TrackOut]
|
||||||
|
|
||||||
|
|
||||||
|
class SimilarArtistsOut(BaseModel):
|
||||||
|
source: str
|
||||||
|
artists: list[ArtistOut]
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""User-settings request/response schemas.
|
||||||
|
|
||||||
|
Enums are enforced at the API boundary (Pydantic ``Literal`` → 422 on bad
|
||||||
|
input), so the service can trust the values it receives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, model_validator
|
||||||
|
|
||||||
|
Theme = Literal["system", "light", "dark"]
|
||||||
|
# Playback quality preference. ``original`` = no transcode; the lower tiers are
|
||||||
|
# consumed by the (upcoming) transcoding pipeline.
|
||||||
|
StreamQuality = Literal["original", "high", "medium", "low"]
|
||||||
|
ScrobbleProvider = Literal["lastfm", "listenbrainz"]
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsOut(BaseModel):
|
||||||
|
theme: Theme
|
||||||
|
stream_quality: StreamQuality
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsUpdate(BaseModel):
|
||||||
|
"""Partial update — omitted fields keep their current value."""
|
||||||
|
|
||||||
|
theme: Theme | None = None
|
||||||
|
stream_quality: StreamQuality | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobblingOut(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
provider: ScrobbleProvider | None
|
||||||
|
username: str | None
|
||||||
|
# Whether a session key is stored. The key itself is never returned.
|
||||||
|
configured: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ScrobblingUpdate(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
provider: ScrobbleProvider | None = None
|
||||||
|
username: str | None = None
|
||||||
|
# Write-only scrobbler session key / user token. Omit to keep the stored one.
|
||||||
|
session_key: str | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _provider_required_when_enabled(self) -> ScrobblingUpdate:
|
||||||
|
if self.enabled and self.provider is None:
|
||||||
|
raise ValueError("provider is required when scrobbling is enabled")
|
||||||
|
return self
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Storage / library statistics response schemas (§A6)."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.api.schemas.track import TrackOut
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateGroupOut(BaseModel):
|
||||||
|
"""Tracks sharing one acoustic fingerprint — candidates for de-duplication."""
|
||||||
|
|
||||||
|
fingerprint: str
|
||||||
|
tracks: list[TrackOut]
|
||||||
|
|
||||||
|
|
||||||
|
class CleanupEnqueuedOut(BaseModel):
|
||||||
|
"""Acknowledgement that a cleanup job was queued (it runs in the worker)."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
job_id: str
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Offline-first sync schemas (delta pull + idempotent push).
|
||||||
|
|
||||||
|
The client keeps an opaque ``cursor`` (a server-clock timestamp). It pulls
|
||||||
|
everything changed in the half-open window ``(since, cursor]`` and pushes the
|
||||||
|
append-only events it accumulated offline. Events carry a client-generated
|
||||||
|
``id`` so a replay is idempotent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.api.schemas.track import TrackOut
|
||||||
|
|
||||||
|
LikeValue = Literal["like", "dislike", "neutral"]
|
||||||
|
|
||||||
|
|
||||||
|
# -- pull (server -> client) --------------------------------------------------
|
||||||
|
class LikeEventOut(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
track_id: uuid.UUID
|
||||||
|
value: str
|
||||||
|
created_at: dt.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PlayEventOut(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
track_id: uuid.UUID
|
||||||
|
played_at: dt.datetime
|
||||||
|
play_duration_seconds: int | None
|
||||||
|
completed: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistSyncOut(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
version: int
|
||||||
|
updated_at: dt.datetime
|
||||||
|
track_ids: list[uuid.UUID]
|
||||||
|
|
||||||
|
|
||||||
|
class SyncChangesOut(BaseModel):
|
||||||
|
"""Everything that changed for the caller since their last cursor. Feed
|
||||||
|
``cursor`` back as ``?since=`` on the next pull."""
|
||||||
|
|
||||||
|
cursor: dt.datetime
|
||||||
|
likes: list[LikeEventOut]
|
||||||
|
plays: list[PlayEventOut]
|
||||||
|
playlists: list[PlaylistSyncOut]
|
||||||
|
tracks: list[TrackOut]
|
||||||
|
|
||||||
|
|
||||||
|
# -- push (client -> server) --------------------------------------------------
|
||||||
|
class LikeEventIn(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
track_id: uuid.UUID
|
||||||
|
value: LikeValue
|
||||||
|
created_at: dt.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PlayEventIn(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
track_id: uuid.UUID
|
||||||
|
played_at: dt.datetime
|
||||||
|
play_duration_seconds: int | None = None
|
||||||
|
completed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SyncPushIn(BaseModel):
|
||||||
|
likes: list[LikeEventIn] = Field(default_factory=list)
|
||||||
|
plays: list[PlayEventIn] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncPushOut(BaseModel):
|
||||||
|
"""How many events were newly stored (a replay reports 0) + a fresh cursor."""
|
||||||
|
|
||||||
|
cursor: dt.datetime
|
||||||
|
accepted_likes: int
|
||||||
|
accepted_plays: int
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Transcode/optimize response schemas (§6.6)."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizeEnqueuedOut(BaseModel):
|
||||||
|
"""Acknowledgement that a transcode job was queued (it runs in the worker)."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
job_id: str
|
||||||
|
quality: str
|
||||||
@@ -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)
|
||||||
|
|||||||
+71
-11
@@ -5,11 +5,18 @@ sign-up (plan §6.4).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Query, status
|
from fastapi import APIRouter, Query, status
|
||||||
|
|
||||||
from app.api.deps import SubsonicAuthServiceDep, SuperUser, UserServiceDep
|
from app.api.deps import SourceRegistryDep, SubsonicAuthServiceDep, SuperUser, UserServiceDep
|
||||||
|
from app.api.health import _check_db, _check_ml, _check_redis
|
||||||
|
from app.api.schemas.admin import (
|
||||||
|
AdminSettingsOut,
|
||||||
|
ReindexJob,
|
||||||
|
ReindexResponse,
|
||||||
|
ServicesStatusOut,
|
||||||
|
)
|
||||||
|
from app.api.schemas.source import SourceInfoOut
|
||||||
from app.api.schemas.subsonic import SubsonicPasswordResponse
|
from app.api.schemas.subsonic import SubsonicPasswordResponse
|
||||||
from app.api.schemas.user import (
|
from app.api.schemas.user import (
|
||||||
CreateUserRequest,
|
CreateUserRequest,
|
||||||
@@ -17,6 +24,9 @@ from app.api.schemas.user import (
|
|||||||
UpdateUserRequest,
|
UpdateUserRequest,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.domain.errors import DependencyUnavailableError, NotSupportedError
|
||||||
|
from app.workers.queue import enqueue
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
@@ -91,24 +101,74 @@ async def rotate_user_subsonic_password(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/services")
|
@router.get("/services")
|
||||||
async def list_services(_admin: SuperUser) -> Any: ...
|
async def list_services(_admin: SuperUser) -> ServicesStatusOut:
|
||||||
|
"""Backing-dependency health for the admin dashboard — same probes as the
|
||||||
|
readiness endpoint (DB + Redis required, ML optional)."""
|
||||||
|
database = await _check_db()
|
||||||
|
redis = await _check_redis()
|
||||||
|
ml = await _check_ml()
|
||||||
|
return ServicesStatusOut(database=database, redis=redis, ml=ml)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sources")
|
@router.get("/sources")
|
||||||
async def list_admin_sources(_admin: SuperUser) -> Any: ...
|
async def list_admin_sources(
|
||||||
|
_admin: SuperUser, registry: SourceRegistryDep
|
||||||
|
) -> list[SourceInfoOut]:
|
||||||
@router.patch("/sources/{source}")
|
"""Configured sources and their live availability (same view as
|
||||||
async def update_admin_source(source: str, _admin: SuperUser) -> Any: ...
|
``/sources``, admin-scoped)."""
|
||||||
|
return [SourceInfoOut.from_entity(info) for info in registry.infos()]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reindex")
|
@router.post("/reindex")
|
||||||
async def trigger_reindex(_admin: SuperUser) -> Any: ...
|
async def trigger_reindex(admin: SuperUser, registry: SourceRegistryDep) -> ReindexResponse:
|
||||||
|
"""Enqueue a full re-scan of every indexable source. The walk + file copies
|
||||||
|
run in the worker (never the request cycle); re-scans are idempotent."""
|
||||||
|
indexables = registry.indexables()
|
||||||
|
if not indexables:
|
||||||
|
raise DependencyUnavailableError("No indexable source is configured.")
|
||||||
|
jobs: list[ReindexJob] = []
|
||||||
|
for backend in indexables:
|
||||||
|
job_id = await enqueue("scan_local_folder", source=backend.name, added_by=str(admin.id))
|
||||||
|
jobs.append(ReindexJob(source=backend.name, job_id=job_id))
|
||||||
|
return ReindexResponse(jobs=jobs)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings")
|
@router.get("/settings")
|
||||||
async def get_admin_settings(_admin: SuperUser) -> Any: ...
|
async def get_admin_settings(_admin: SuperUser) -> AdminSettingsOut:
|
||||||
|
"""Effective, non-secret instance configuration. Reflects the environment the
|
||||||
|
process booted with; secrets/connection strings are never returned — only
|
||||||
|
whether each optional integration is configured."""
|
||||||
|
settings = get_settings()
|
||||||
|
return AdminSettingsOut(
|
||||||
|
environment=settings.environment,
|
||||||
|
allow_registration=settings.allow_registration,
|
||||||
|
storage_backend=settings.storage_backend,
|
||||||
|
media_path=str(settings.media_path),
|
||||||
|
youtube_enabled=settings.youtube_enabled,
|
||||||
|
coverart_enabled=settings.coverart_enabled,
|
||||||
|
ml_configured=settings.ml_service_url is not None,
|
||||||
|
acoustid_configured=settings.acoustid_api_key is not None,
|
||||||
|
local_import_configured=settings.local_media_import_path is not None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- runtime config mutation (intentionally unsupported) ----------------------
|
||||||
|
# The instance is env-configured (CLAUDE.md: nothing hardcoded, all from env) and
|
||||||
|
# get_settings() is a cached singleton, so config is not mutable at runtime.
|
||||||
|
# These endpoints answer 501 with a clear reason rather than silently no-op'ing;
|
||||||
|
# a persistent override layer that shadows env would be a deliberate future
|
||||||
|
# departure. Read the effective config via GET /admin/settings.
|
||||||
|
@router.patch("/sources/{source}")
|
||||||
|
async def update_admin_source(source: str, _admin: SuperUser) -> None:
|
||||||
|
raise NotSupportedError(
|
||||||
|
"Sources are configured via environment variables (e.g. YOUTUBE_ENABLED, "
|
||||||
|
"LOCAL_MEDIA_IMPORT_PATH); runtime changes are not supported."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/settings")
|
@router.patch("/settings")
|
||||||
async def update_admin_settings(_admin: SuperUser) -> Any: ...
|
async def update_admin_settings(_admin: SuperUser) -> None:
|
||||||
|
raise NotSupportedError(
|
||||||
|
"Instance settings are managed via environment configuration; "
|
||||||
|
"runtime changes are not supported."
|
||||||
|
)
|
||||||
|
|||||||
+22
-3
@@ -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)
|
||||||
|
|||||||
+29
-3
@@ -1,14 +1,20 @@
|
|||||||
"""Artist endpoints."""
|
"""Artist endpoints."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
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,
|
||||||
|
RecommendationServiceDep,
|
||||||
|
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.pagination import PagedResponse
|
from app.api.schemas.pagination import PagedResponse
|
||||||
|
from app.api.schemas.radio import SimilarArtistsOut
|
||||||
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
|
||||||
from app.api.v1.tracks import _build_track_out
|
from app.api.v1.tracks import _build_track_out
|
||||||
@@ -124,4 +130,24 @@ async def get_artist_tracks(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{artist_id}/similar")
|
@router.get("/{artist_id}/similar")
|
||||||
async def get_similar_artists(artist_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
async def get_similar_artists(
|
||||||
|
artist_id: uuid.UUID,
|
||||||
|
service: RecommendationServiceDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
_: CurrentUser,
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
) -> SimilarArtistsOut:
|
||||||
|
"""Artists similar to this one (§6.5). ML when configured, else a shared-
|
||||||
|
genre metadata heuristic."""
|
||||||
|
source, artists = await service.similar_artists(artist_id, limit=limit)
|
||||||
|
items = [
|
||||||
|
ArtistOut(
|
||||||
|
id=a.id,
|
||||||
|
name=a.name,
|
||||||
|
album_count=await artist_repo.album_count(a.id),
|
||||||
|
track_count=await artist_repo.track_count(a.id),
|
||||||
|
created_at=a.created_at,
|
||||||
|
)
|
||||||
|
for a in artists
|
||||||
|
]
|
||||||
|
return SimilarArtistsOut(source=source, artists=items)
|
||||||
|
|||||||
+25
-2
@@ -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
@@ -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: ...
|
|
||||||
|
|||||||
+49
-5
@@ -1,23 +1,32 @@
|
|||||||
"""Playlist endpoints."""
|
"""Playlist endpoints."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Query, Response
|
from fastapi import APIRouter, Query, Response
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.api.covers import stream_cover
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
AlbumRepoDep,
|
AlbumRepoDep,
|
||||||
ArtistRepoDep,
|
ArtistRepoDep,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
|
FileStorageDep,
|
||||||
PlaylistRepoDep,
|
PlaylistRepoDep,
|
||||||
|
StreamUser,
|
||||||
TrackRepoDep,
|
TrackRepoDep,
|
||||||
)
|
)
|
||||||
from app.api.schemas.pagination import PagedResponse
|
from app.api.schemas.pagination import PagedResponse
|
||||||
from app.api.schemas.playlist import PlaylistAddTrack, PlaylistCreate, PlaylistOut, PlaylistUpdate
|
from app.api.schemas.playlist import (
|
||||||
|
PlaylistAddTrack,
|
||||||
|
PlaylistCreate,
|
||||||
|
PlaylistOut,
|
||||||
|
PlaylistReorder,
|
||||||
|
PlaylistUpdate,
|
||||||
|
)
|
||||||
from app.api.schemas.track import TrackOut
|
from app.api.schemas.track import TrackOut
|
||||||
from app.api.v1.tracks import _build_track_out
|
from app.api.v1.tracks import _build_track_out
|
||||||
from app.domain.entities.playlist import Playlist
|
from app.domain.entities.playlist import Playlist
|
||||||
from app.domain.errors import NotFoundError, PermissionDeniedError
|
from app.domain.errors import NotFoundError, PermissionDeniedError, ValidationError
|
||||||
from app.infrastructure.db.repositories.playlist_repository import SqlAlchemyPlaylistRepository
|
from app.infrastructure.db.repositories.playlist_repository import SqlAlchemyPlaylistRepository
|
||||||
|
|
||||||
router = APIRouter(prefix="/playlists", tags=["playlists"])
|
router = APIRouter(prefix="/playlists", tags=["playlists"])
|
||||||
@@ -182,8 +191,43 @@ async def remove_playlist_track(
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{playlist_id}/tracks/reorder")
|
@router.put("/{playlist_id}/tracks/reorder")
|
||||||
async def reorder_playlist_tracks(playlist_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
async def reorder_playlist_tracks(
|
||||||
|
playlist_id: uuid.UUID,
|
||||||
|
body: PlaylistReorder,
|
||||||
|
playlist_repo: PlaylistRepoDep,
|
||||||
|
user: CurrentUser,
|
||||||
|
) -> PlaylistOut:
|
||||||
|
playlist = await playlist_repo.get_by_id(playlist_id)
|
||||||
|
if playlist is None:
|
||||||
|
raise NotFoundError(f"Playlist {playlist_id} not found.")
|
||||||
|
if playlist.owner_id != user.id:
|
||||||
|
raise PermissionDeniedError("You don't own this playlist.")
|
||||||
|
|
||||||
|
total = await playlist_repo.get_track_total(playlist_id)
|
||||||
|
current_tracks = (
|
||||||
|
await playlist_repo.get_tracks(playlist_id, limit=total, offset=0) if total else []
|
||||||
|
)
|
||||||
|
current_ids = {t.id for t in current_tracks}
|
||||||
|
given_ids = body.track_ids
|
||||||
|
if len(given_ids) != len(set(given_ids)) or set(given_ids) != current_ids:
|
||||||
|
raise ValidationError("track_ids must be a permutation of the playlist's current tracks.")
|
||||||
|
|
||||||
|
await playlist_repo.reorder_tracks(playlist_id, given_ids)
|
||||||
|
updated = await playlist_repo.get_by_id(playlist_id)
|
||||||
|
assert updated is not None
|
||||||
|
items = await _build_playlist_out([updated], playlist_repo)
|
||||||
|
return items[0]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{playlist_id}/cover")
|
@router.get("/{playlist_id}/cover")
|
||||||
async def get_playlist_cover(playlist_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
async def get_playlist_cover(
|
||||||
|
playlist_id: uuid.UUID,
|
||||||
|
playlist_repo: PlaylistRepoDep,
|
||||||
|
storage: FileStorageDep,
|
||||||
|
_: StreamUser,
|
||||||
|
) -> StreamingResponse:
|
||||||
|
# ``<img>`` can't send a bearer header → StreamUser accepts ``?token=``.
|
||||||
|
cover_path = await playlist_repo.get_cover_path(playlist_id)
|
||||||
|
if not cover_path:
|
||||||
|
raise NotFoundError("Cover not found.")
|
||||||
|
return await stream_cover(storage, cover_path)
|
||||||
|
|||||||
+72
-4
@@ -1,15 +1,83 @@
|
|||||||
"""Radio / continuous-mix endpoints. Degrades gracefully when ML service is down."""
|
"""Radio / continuous-mix endpoints (§6.5).
|
||||||
|
|
||||||
from typing import Any
|
Stateless: the client passes the seed + already-queued ids and pulls more as the
|
||||||
|
queue drains (offline-first infinite feed). Degrades gracefully when no ML
|
||||||
|
service is configured — the recommendation service falls back to metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.deps import (
|
||||||
|
AlbumRepoDep,
|
||||||
|
ArtistRepoDep,
|
||||||
|
CurrentUser,
|
||||||
|
RecommendationServiceDep,
|
||||||
|
)
|
||||||
|
from app.api.schemas.radio import RadioRequest, RadioResponse, RadioTrackOut
|
||||||
|
from app.api.v1.tracks import _build_track_out
|
||||||
|
from app.application.recommendation_service import RadioPick
|
||||||
|
|
||||||
router = APIRouter(prefix="/radio", tags=["radio"])
|
router = APIRouter(prefix="/radio", tags=["radio"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _to_response(
|
||||||
|
source: str,
|
||||||
|
picks: list[RadioPick],
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
) -> RadioResponse:
|
||||||
|
tracks = [p.track for p in picks]
|
||||||
|
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})
|
||||||
|
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)}
|
||||||
|
outs = await _build_track_out(tracks, artists, albums)
|
||||||
|
return RadioResponse(
|
||||||
|
source=source,
|
||||||
|
tracks=[
|
||||||
|
RadioTrackOut(track=out, reason=pick.reason)
|
||||||
|
for out, pick in zip(outs, picks, strict=True)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_radio(
|
||||||
|
body: RadioRequest,
|
||||||
|
user: CurrentUser,
|
||||||
|
service: RecommendationServiceDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
) -> RadioResponse:
|
||||||
|
source, picks = await service.radio(
|
||||||
|
user_id=user.id,
|
||||||
|
seed_track_id=body.seed_track_id,
|
||||||
|
from_likes=body.from_likes,
|
||||||
|
exploration=body.exploration,
|
||||||
|
limit=body.count,
|
||||||
|
exclude_ids=body.exclude_ids,
|
||||||
|
)
|
||||||
|
return await _to_response(source, picks, artist_repo, album_repo)
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
async def start_radio() -> Any: ...
|
async def start_radio(
|
||||||
|
body: RadioRequest,
|
||||||
|
user: CurrentUser,
|
||||||
|
service: RecommendationServiceDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
) -> RadioResponse:
|
||||||
|
"""Start a radio from a seed track or the caller's likes."""
|
||||||
|
return await _run_radio(body, user, service, artist_repo, album_repo)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/next")
|
@router.post("/next")
|
||||||
async def next_radio_track() -> Any: ...
|
async def next_radio_track(
|
||||||
|
body: RadioRequest,
|
||||||
|
user: CurrentUser,
|
||||||
|
service: RecommendationServiceDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
) -> RadioResponse:
|
||||||
|
"""Fetch more tracks as the radio queue drains (pass ``exclude_ids``)."""
|
||||||
|
return await _run_radio(body, user, service, artist_repo, album_repo)
|
||||||
|
|||||||
+27
-4
@@ -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
@@ -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])
|
||||||
|
|||||||
+131
-8
@@ -1,27 +1,150 @@
|
|||||||
"""Storage analysis and cleanup endpoints."""
|
"""Storage analysis and cleanup endpoints."""
|
||||||
|
|
||||||
from typing import Any
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from app.api.deps import (
|
||||||
|
AlbumRepoDep,
|
||||||
|
ArtistRepoDep,
|
||||||
|
CurrentUser,
|
||||||
|
FileStorageDep,
|
||||||
|
SuperUser,
|
||||||
|
TrackRepoDep,
|
||||||
|
)
|
||||||
|
from app.api.schemas.pagination import PagedResponse
|
||||||
|
from app.api.schemas.storage import (
|
||||||
|
CleanupEnqueuedOut,
|
||||||
|
DiskUsageOut,
|
||||||
|
DuplicateGroupOut,
|
||||||
|
FormatBreakdownOut,
|
||||||
|
GenreCountOut,
|
||||||
|
StorageStatsOut,
|
||||||
|
)
|
||||||
|
from app.api.schemas.track import TrackOut
|
||||||
|
from app.api.v1.tracks import _build_track_out
|
||||||
|
from app.domain.entities.track import Track
|
||||||
|
from app.workers.queue import enqueue
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
async def _tracks_to_out(
|
||||||
|
tracks: list[Track], artist_repo: ArtistRepoDep, album_repo: AlbumRepoDep
|
||||||
|
) -> list[TrackOut]:
|
||||||
|
"""Hydrate a batch of tracks into ``TrackOut`` (artist/album names + cover
|
||||||
|
flag), resolving each referenced artist/album in a single query."""
|
||||||
|
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})
|
||||||
|
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)}
|
||||||
|
return await _build_track_out(tracks, artists, albums)
|
||||||
|
|
||||||
|
|
||||||
@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")
|
||||||
async def get_duplicates() -> Any: ...
|
async def get_duplicates(
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
_: CurrentUser,
|
||||||
|
) -> list[DuplicateGroupOut]:
|
||||||
|
"""Tracks sharing an acoustic fingerprint, grouped — the library's real
|
||||||
|
duplicates (``(source, source_id)`` is already unique). Cheap DB GROUP BY."""
|
||||||
|
groups = await track_repo.find_duplicate_groups()
|
||||||
|
all_tracks = [track for _, tracks in groups for track in tracks]
|
||||||
|
out = await _tracks_to_out(all_tracks, artist_repo, album_repo)
|
||||||
|
by_id = {item.id: item for item in out}
|
||||||
|
return [
|
||||||
|
DuplicateGroupOut(fingerprint=fingerprint, tracks=[by_id[t.id] for t in tracks])
|
||||||
|
for fingerprint, tracks in groups
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/broken")
|
@router.get("/broken")
|
||||||
async def get_broken_files() -> Any: ...
|
async def get_broken_files(
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
_: CurrentUser,
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> PagedResponse[TrackOut]:
|
||||||
|
"""Tracks whose last enrichment run failed (``metadata_status=failed``) —
|
||||||
|
each carries its ``metadata_error``. A file gone missing on disk is instead
|
||||||
|
reconciled by ``POST /storage/cleanup`` (that needs a filesystem scan)."""
|
||||||
|
tracks = await track_repo.list_by_metadata_status("failed", limit=limit, offset=offset)
|
||||||
|
total = await track_repo.count_by_metadata_status("failed")
|
||||||
|
items = await _tracks_to_out(tracks, artist_repo, album_repo)
|
||||||
|
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/missing-metadata")
|
@router.get("/missing-metadata")
|
||||||
async def get_missing_metadata() -> Any: ...
|
async def get_missing_metadata(
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
_: CurrentUser,
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> PagedResponse[TrackOut]:
|
||||||
|
"""Tracks still awaiting enrichment (``metadata_status=pending``) — imported
|
||||||
|
but never identified."""
|
||||||
|
tracks = await track_repo.list_by_metadata_status("pending", limit=limit, offset=offset)
|
||||||
|
total = await track_repo.count_by_metadata_status("pending")
|
||||||
|
items = await _tracks_to_out(tracks, artist_repo, album_repo)
|
||||||
|
return PagedResponse(items=items, total=total, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cleanup")
|
@router.post("/cleanup", status_code=202)
|
||||||
async def run_cleanup() -> Any: ...
|
async def run_cleanup(_: SuperUser) -> CleanupEnqueuedOut:
|
||||||
|
"""Admin: enqueue the storage reconciliation job. It scans the catalogue and
|
||||||
|
removes rows whose backing file has vanished (dangling references). Runs in
|
||||||
|
the worker — the filesystem scan must not block the request cycle."""
|
||||||
|
job_id = await enqueue("cleanup_storage")
|
||||||
|
return CleanupEnqueuedOut(status="enqueued", job_id=job_id)
|
||||||
|
|||||||
+65
-6
@@ -1,30 +1,54 @@
|
|||||||
"""Audio streaming endpoint — direct stream with Range support."""
|
"""Audio streaming — direct byte-range stream, transcoded quality, and HLS.
|
||||||
|
|
||||||
|
``GET /stream/{id}`` streams the master with Range support, or a cached Opus
|
||||||
|
rendition when ``?quality=`` is set (a cache miss falls back to the master and
|
||||||
|
warms the cache in the background — playback never waits on ffmpeg). ``/hls/*``
|
||||||
|
serves the cached HLS rendition (generated by the ``transcode_track`` worker).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Header
|
import anyio
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi import APIRouter, Header, Query, Response
|
||||||
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
|
|
||||||
from app.api.deps import StreamingServiceDep, StreamUser
|
from app.api.deps import StreamingServiceDep, StreamUser, TranscodeServiceDep
|
||||||
|
from app.domain.errors import NotFoundError
|
||||||
|
from app.workers.queue import enqueue_transcode_quiet
|
||||||
|
|
||||||
router = APIRouter(prefix="/stream", tags=["streaming"])
|
router = APIRouter(prefix="/stream", tags=["streaming"])
|
||||||
|
|
||||||
|
_HLS_PLAYLIST_TYPE = "application/vnd.apple.mpegurl"
|
||||||
|
_HLS_SEGMENT_TYPE = "video/mp2t"
|
||||||
|
_OPUS_TYPE = "audio/ogg"
|
||||||
|
_SEGMENT_LINE_RE = re.compile(r"^(seg_\d+\.ts)$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{track_id}")
|
@router.get("/{track_id}")
|
||||||
async def stream_track(
|
async def stream_track(
|
||||||
track_id: uuid.UUID,
|
track_id: uuid.UUID,
|
||||||
service: StreamingServiceDep,
|
service: StreamingServiceDep,
|
||||||
|
transcode: TranscodeServiceDep,
|
||||||
_user: StreamUser,
|
_user: StreamUser,
|
||||||
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
||||||
) -> StreamingResponse:
|
quality: Annotated[str | None, Query()] = None,
|
||||||
|
) -> Response:
|
||||||
|
# A quality rendition, if one is cached; otherwise fall back to the master
|
||||||
|
# and enqueue generation so the next play gets it (graceful degradation).
|
||||||
|
if quality and quality != "original":
|
||||||
|
cached = await transcode.resolve_quality_file(track_id, quality)
|
||||||
|
if cached is not None:
|
||||||
|
return FileResponse(cached, media_type=_OPUS_TYPE)
|
||||||
|
await enqueue_transcode_quiet(track_id, quality=quality, hls=False)
|
||||||
|
|
||||||
result = await service.open_stream(track_id, range_header)
|
result = await service.open_stream(track_id, range_header)
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Accept-Ranges": "bytes",
|
"Accept-Ranges": "bytes",
|
||||||
"Content-Length": str(result.content_length),
|
"Content-Length": str(result.content_length),
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.is_partial:
|
if result.is_partial:
|
||||||
headers["Content-Range"] = f"bytes {result.start}-{result.end}/{result.total_size}"
|
headers["Content-Range"] = f"bytes {result.start}-{result.end}/{result.total_size}"
|
||||||
status_code = 206
|
status_code = 206
|
||||||
@@ -37,3 +61,38 @@ async def stream_track(
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
media_type=result.content_type,
|
media_type=result.content_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_id}/hls/playlist.m3u8")
|
||||||
|
async def stream_hls_playlist(
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
transcode: TranscodeServiceDep,
|
||||||
|
_user: StreamUser,
|
||||||
|
token: Annotated[str | None, Query()] = None,
|
||||||
|
) -> Response:
|
||||||
|
"""Serve the cached HLS playlist. On a miss, kick off generation and 404 so
|
||||||
|
the client retries. Segment URLs are relative; when the request carried a
|
||||||
|
``?token=`` (players can't set an Authorization header), it's appended to
|
||||||
|
each segment line so the segment requests authenticate the same way."""
|
||||||
|
path = await transcode.hls_playlist(track_id)
|
||||||
|
if path is None:
|
||||||
|
await enqueue_transcode_quiet(track_id, hls=True)
|
||||||
|
raise NotFoundError("HLS rendition is being prepared; retry shortly.")
|
||||||
|
|
||||||
|
body = await anyio.to_thread.run_sync(path.read_text)
|
||||||
|
if token:
|
||||||
|
body = _SEGMENT_LINE_RE.sub(rf"\1?token={token}", body)
|
||||||
|
return Response(body, media_type=_HLS_PLAYLIST_TYPE)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_id}/hls/{segment}")
|
||||||
|
async def stream_hls_segment(
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
segment: str,
|
||||||
|
transcode: TranscodeServiceDep,
|
||||||
|
_user: StreamUser,
|
||||||
|
) -> FileResponse:
|
||||||
|
path = transcode.hls_segment(track_id, segment)
|
||||||
|
if path is None:
|
||||||
|
raise NotFoundError("Segment not found.")
|
||||||
|
return FileResponse(path, media_type=_HLS_SEGMENT_TYPE)
|
||||||
|
|||||||
+92
-4
@@ -1,15 +1,103 @@
|
|||||||
"""Client sync endpoints (offline-first event log)."""
|
"""Client sync endpoints (offline-first event log).
|
||||||
|
|
||||||
from typing import Any
|
``GET /sync/changes`` pulls everything the caller changed since their cursor;
|
||||||
|
``POST /sync/push`` uploads the like/play events a client accumulated offline
|
||||||
|
(idempotent — replays are no-ops). See :mod:`app.application.sync_service`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.deps import AlbumRepoDep, ArtistRepoDep, CurrentUser, SyncServiceDep
|
||||||
|
from app.api.schemas.sync import (
|
||||||
|
LikeEventOut,
|
||||||
|
PlayEventOut,
|
||||||
|
PlaylistSyncOut,
|
||||||
|
SyncChangesOut,
|
||||||
|
SyncPushIn,
|
||||||
|
SyncPushOut,
|
||||||
|
)
|
||||||
|
from app.api.v1.tracks import _build_track_out
|
||||||
|
from app.application.sync_service import LikeEvent, PlayEvent
|
||||||
|
|
||||||
router = APIRouter(prefix="/sync", tags=["sync"])
|
router = APIRouter(prefix="/sync", tags=["sync"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/changes")
|
@router.get("/changes")
|
||||||
async def get_changes() -> Any: ...
|
async def get_changes(
|
||||||
|
service: SyncServiceDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
user: CurrentUser,
|
||||||
|
since: dt.datetime | None = None,
|
||||||
|
) -> SyncChangesOut:
|
||||||
|
"""Delta since ``since`` (omit for a full snapshot). Persist ``cursor`` from
|
||||||
|
the response and pass it back as ``?since=`` next time."""
|
||||||
|
changes = await service.get_changes(user.id, since=since)
|
||||||
|
|
||||||
|
artist_ids = list({t.artist_id for t in changes.tracks})
|
||||||
|
album_ids = list({t.album_id for t in changes.tracks if t.album_id is not None})
|
||||||
|
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)}
|
||||||
|
tracks_out = await _build_track_out(changes.tracks, artists, albums)
|
||||||
|
|
||||||
|
return SyncChangesOut(
|
||||||
|
cursor=changes.cursor,
|
||||||
|
likes=[
|
||||||
|
LikeEventOut(
|
||||||
|
id=lk.id, track_id=lk.track_id, value=lk.value, created_at=lk.created_at
|
||||||
|
)
|
||||||
|
for lk in changes.likes
|
||||||
|
],
|
||||||
|
plays=[
|
||||||
|
PlayEventOut(
|
||||||
|
id=p.id,
|
||||||
|
track_id=p.track_id,
|
||||||
|
played_at=p.played_at,
|
||||||
|
play_duration_seconds=p.play_duration_seconds,
|
||||||
|
completed=p.completed,
|
||||||
|
)
|
||||||
|
for p in changes.plays
|
||||||
|
],
|
||||||
|
playlists=[
|
||||||
|
PlaylistSyncOut(
|
||||||
|
id=d.playlist.id,
|
||||||
|
name=d.playlist.name,
|
||||||
|
description=d.playlist.description,
|
||||||
|
version=d.playlist.version,
|
||||||
|
updated_at=d.playlist.updated_at,
|
||||||
|
track_ids=d.track_ids,
|
||||||
|
)
|
||||||
|
for d in changes.playlists
|
||||||
|
],
|
||||||
|
tracks=tracks_out,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/push")
|
@router.post("/push")
|
||||||
async def push_changes() -> Any: ...
|
async def push_changes(
|
||||||
|
body: SyncPushIn, service: SyncServiceDep, user: CurrentUser
|
||||||
|
) -> SyncPushOut:
|
||||||
|
result = await service.push(
|
||||||
|
user.id,
|
||||||
|
likes=[
|
||||||
|
LikeEvent(id=e.id, track_id=e.track_id, value=e.value, created_at=e.created_at)
|
||||||
|
for e in body.likes
|
||||||
|
],
|
||||||
|
plays=[
|
||||||
|
PlayEvent(
|
||||||
|
id=e.id,
|
||||||
|
track_id=e.track_id,
|
||||||
|
played_at=e.played_at,
|
||||||
|
play_duration_seconds=e.play_duration_seconds,
|
||||||
|
completed=e.completed,
|
||||||
|
)
|
||||||
|
for e in body.plays
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return SyncPushOut(
|
||||||
|
cursor=result.cursor,
|
||||||
|
accepted_likes=result.accepted_likes,
|
||||||
|
accepted_plays=result.accepted_plays,
|
||||||
|
)
|
||||||
|
|||||||
+255
-13
@@ -1,16 +1,45 @@
|
|||||||
"""Track endpoints."""
|
"""Track endpoints."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Annotated
|
||||||
|
|
||||||
|
import anyio
|
||||||
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,
|
||||||
|
LyricsServiceDep,
|
||||||
|
MetadataServiceDep,
|
||||||
|
RecommendationServiceDep,
|
||||||
|
RemoteLibraryServiceDep,
|
||||||
|
StreamUser,
|
||||||
|
TrackRepoDep,
|
||||||
|
)
|
||||||
|
from app.api.schemas.download import DownloadJobOut
|
||||||
|
from app.api.schemas.lyrics import LyricsOut
|
||||||
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.radio import SimilarTracksOut
|
||||||
|
from app.api.schemas.track import (
|
||||||
|
MaterializeResponse,
|
||||||
|
MetadataApply,
|
||||||
|
MetadataMatch,
|
||||||
|
MetadataMatchesOut,
|
||||||
|
RemoteTrackSave,
|
||||||
|
TrackOut,
|
||||||
|
TrackUpdate,
|
||||||
|
)
|
||||||
|
from app.api.schemas.transcode import OptimizeEnqueuedOut
|
||||||
|
from app.application.transcode_service import bitrate_for_quality, remove_track_cache
|
||||||
|
from app.core.config import get_settings
|
||||||
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, ValidationError
|
||||||
|
from app.workers.queue import enqueue, enqueue_transcode
|
||||||
|
|
||||||
router = APIRouter(prefix="/tracks", tags=["tracks"])
|
router = APIRouter(prefix="/tracks", tags=["tracks"])
|
||||||
|
|
||||||
@@ -31,8 +60,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 +84,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 +94,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 +111,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,29 +219,182 @@ 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)
|
||||||
|
# Drop any cached transcode renditions (Opus + HLS) so they don't dangle.
|
||||||
|
await anyio.to_thread.run_sync(
|
||||||
|
remove_track_cache, get_settings().transcode_cache_path, track_id
|
||||||
|
)
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{track_id}/similar")
|
@router.get("/{track_id}/similar")
|
||||||
async def get_similar_tracks(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
async def get_similar_tracks(
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
service: RecommendationServiceDep,
|
||||||
|
artist_repo: ArtistRepoDep,
|
||||||
|
album_repo: AlbumRepoDep,
|
||||||
|
_: CurrentUser,
|
||||||
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
|
) -> SimilarTracksOut:
|
||||||
|
"""Tracks similar to this one (§6.5). Uses ML when configured, else a
|
||||||
|
genre/artist metadata heuristic."""
|
||||||
|
source, tracks = await service.similar_tracks(track_id, limit=limit)
|
||||||
|
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})
|
||||||
|
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)}
|
||||||
|
outs = await _build_track_out(tracks, artists, albums)
|
||||||
|
return SimilarTracksOut(source=source, tracks=outs)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{track_id}/optimize")
|
@router.post("/{track_id}/optimize", status_code=202)
|
||||||
async def optimize_track(track_id: uuid.UUID, _: CurrentUser) -> Any: ...
|
async def optimize_track(
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
track_repo: TrackRepoDep,
|
||||||
|
_: CurrentUser,
|
||||||
|
quality: Annotated[str, Query()] = "high",
|
||||||
|
) -> OptimizeEnqueuedOut:
|
||||||
|
"""Enqueue transcoding of a track into a cached Opus rendition + HLS (§6.6).
|
||||||
|
Heavy ffmpeg work runs in the worker; this only queues it."""
|
||||||
|
if bitrate_for_quality(quality) is None:
|
||||||
|
raise ValidationError(
|
||||||
|
f"Unknown quality '{quality}'; expected one of high, medium, low."
|
||||||
|
)
|
||||||
|
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_transcode(track_id, quality=quality, hls=True)
|
||||||
|
return OptimizeEnqueuedOut(status="enqueued", job_id=job_id, quality=quality)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{track_id}/lyrics")
|
||||||
|
async def get_track_lyrics(
|
||||||
|
track_id: uuid.UUID, lyrics: LyricsServiceDep, _: CurrentUser
|
||||||
|
) -> LyricsOut:
|
||||||
|
"""Cached lyrics for the Now Playing panel (§6.7). A miss is a normal 200
|
||||||
|
with ``status="not_found"`` — the provider (LRCLIB) is queried at most once,
|
||||||
|
then the outcome is cached."""
|
||||||
|
return LyricsOut.from_entity(await lyrics.get_lyrics(track_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{track_id}/lyrics/refetch")
|
||||||
|
async def refetch_track_lyrics(
|
||||||
|
track_id: uuid.UUID, lyrics: LyricsServiceDep, _: CurrentUser
|
||||||
|
) -> LyricsOut:
|
||||||
|
"""Force a fresh provider lookup, bypassing the cache (user-triggered)."""
|
||||||
|
return LyricsOut.from_entity(await lyrics.get_lyrics(track_id, force=True))
|
||||||
|
|
||||||
|
|
||||||
@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]
|
||||||
|
|||||||
@@ -1,23 +1,67 @@
|
|||||||
"""User settings endpoints, including scrobbling configuration."""
|
"""User settings endpoints, including scrobbling configuration.
|
||||||
|
|
||||||
from typing import Any
|
Settings are per-caller and created lazily, so a first read returns defaults.
|
||||||
|
The scrobbler session key is write-only — accepted on ``PUT`` but never returned.
|
||||||
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, UserSettingsServiceDep
|
||||||
|
from app.api.schemas.settings import (
|
||||||
|
ScrobblingOut,
|
||||||
|
ScrobblingUpdate,
|
||||||
|
SettingsOut,
|
||||||
|
SettingsUpdate,
|
||||||
|
)
|
||||||
|
from app.domain.entities.settings import UserSettings
|
||||||
|
|
||||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_settings_out(settings: UserSettings) -> SettingsOut:
|
||||||
|
return SettingsOut(theme=settings.theme, stream_quality=settings.stream_quality)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_scrobbling_out(settings: UserSettings) -> ScrobblingOut:
|
||||||
|
return ScrobblingOut(
|
||||||
|
enabled=settings.scrobble_enabled,
|
||||||
|
provider=settings.scrobble_provider,
|
||||||
|
username=settings.scrobble_username,
|
||||||
|
configured=settings.scrobble_session_key_enc is not None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def get_settings() -> Any: ...
|
async def get_settings(user: CurrentUser, service: UserSettingsServiceDep) -> SettingsOut:
|
||||||
|
return _to_settings_out(await service.get(user.id))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("")
|
@router.patch("")
|
||||||
async def update_settings() -> Any: ...
|
async def update_settings(
|
||||||
|
body: SettingsUpdate, user: CurrentUser, service: UserSettingsServiceDep
|
||||||
|
) -> SettingsOut:
|
||||||
|
settings = await service.update_general(
|
||||||
|
user.id, theme=body.theme, stream_quality=body.stream_quality
|
||||||
|
)
|
||||||
|
return _to_settings_out(settings)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/scrobbling")
|
@router.get("/scrobbling")
|
||||||
async def get_scrobbling_settings() -> Any: ...
|
async def get_scrobbling_settings(
|
||||||
|
user: CurrentUser, service: UserSettingsServiceDep
|
||||||
|
) -> ScrobblingOut:
|
||||||
|
return _to_scrobbling_out(await service.get(user.id))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/scrobbling")
|
@router.put("/scrobbling")
|
||||||
async def set_scrobbling_settings() -> Any: ...
|
async def set_scrobbling_settings(
|
||||||
|
body: ScrobblingUpdate, user: CurrentUser, service: UserSettingsServiceDep
|
||||||
|
) -> ScrobblingOut:
|
||||||
|
settings = await service.set_scrobbling(
|
||||||
|
user.id,
|
||||||
|
enabled=body.enabled,
|
||||||
|
provider=body.provider,
|
||||||
|
username=body.username,
|
||||||
|
session_key=body.session_key,
|
||||||
|
)
|
||||||
|
return _to_scrobbling_out(settings)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Lyrics service (plan §6.7).
|
||||||
|
|
||||||
|
Get-or-fetch with caching: a track's lyrics are served from the DB when present;
|
||||||
|
on a miss (or an expired ``not_found``) we ask the provider (LRCLIB) once, then
|
||||||
|
cache the outcome. ``not_found`` is cached with a TTL so tracks that genuinely
|
||||||
|
have no lyrics aren't looked up on every play, but can eventually be retried.
|
||||||
|
|
||||||
|
Degrades gracefully: if the provider is unreachable the lookup just yields a
|
||||||
|
``not_found`` — the endpoint still returns 200 with empty lyrics, never an error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from app.domain.entities.lyrics import Lyrics
|
||||||
|
from app.domain.errors import NotFoundError
|
||||||
|
from app.domain.ports import (
|
||||||
|
AlbumRepository,
|
||||||
|
ArtistRepository,
|
||||||
|
LyricsProvider,
|
||||||
|
LyricsRepository,
|
||||||
|
TrackRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-lookup a cached "not_found" only after this long — long enough not to spam
|
||||||
|
# the provider, short enough that lyrics added upstream eventually surface.
|
||||||
|
_NOT_FOUND_TTL = dt.timedelta(days=7)
|
||||||
|
|
||||||
|
_STATUS_FOUND = "found"
|
||||||
|
_STATUS_NOT_FOUND = "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
class LyricsService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
lyrics: LyricsRepository,
|
||||||
|
tracks: TrackRepository,
|
||||||
|
artists: ArtistRepository,
|
||||||
|
albums: AlbumRepository,
|
||||||
|
provider: LyricsProvider,
|
||||||
|
) -> None:
|
||||||
|
self._lyrics = lyrics
|
||||||
|
self._tracks = tracks
|
||||||
|
self._artists = artists
|
||||||
|
self._albums = albums
|
||||||
|
self._provider = provider
|
||||||
|
|
||||||
|
async def get_lyrics(self, track_id: uuid.UUID, *, force: bool = False) -> Lyrics:
|
||||||
|
"""Return cached lyrics, fetching from the provider on a miss/expiry.
|
||||||
|
``force`` (the refetch endpoint) bypasses the cache entirely."""
|
||||||
|
cached = await self._lyrics.get(track_id)
|
||||||
|
if not force and cached is not None and self._is_fresh(cached):
|
||||||
|
return cached
|
||||||
|
return await self._fetch_and_cache(track_id)
|
||||||
|
|
||||||
|
def _is_fresh(self, cached: Lyrics) -> bool:
|
||||||
|
if cached.status == _STATUS_FOUND:
|
||||||
|
return True
|
||||||
|
if cached.status == _STATUS_NOT_FOUND:
|
||||||
|
return dt.datetime.now(dt.UTC) - cached.fetched_at < _NOT_FOUND_TTL
|
||||||
|
# "pending" (never fetched) → not fresh, go fetch.
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _fetch_and_cache(self, track_id: uuid.UUID) -> Lyrics:
|
||||||
|
track = await self._tracks.get_by_id(track_id)
|
||||||
|
if track is None:
|
||||||
|
raise NotFoundError(f"Track {track_id} not found.")
|
||||||
|
|
||||||
|
artist = await self._artists.get_by_id(track.artist_id)
|
||||||
|
album = (
|
||||||
|
await self._albums.get_by_id(track.album_id)
|
||||||
|
if track.album_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
result = await self._provider.fetch(
|
||||||
|
artist=artist.name if artist else "",
|
||||||
|
title=track.title,
|
||||||
|
album=album.title if album else None,
|
||||||
|
duration_seconds=track.duration_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return await self._lyrics.upsert(
|
||||||
|
track_id=track_id,
|
||||||
|
synced=None,
|
||||||
|
plain=None,
|
||||||
|
source=None,
|
||||||
|
status=_STATUS_NOT_FOUND,
|
||||||
|
)
|
||||||
|
return await self._lyrics.upsert(
|
||||||
|
track_id=track_id,
|
||||||
|
synced=result.synced,
|
||||||
|
plain=result.plain,
|
||||||
|
source=result.source,
|
||||||
|
status=_STATUS_FOUND,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Recommendation / radio service (plan §6.5).
|
||||||
|
|
||||||
|
Tries the external ML recommender first; when it's unavailable or declines
|
||||||
|
(returns ``None``), falls back to metadata heuristics over the catalogue — so
|
||||||
|
similar/radio always work, worse, without ML (graceful-degradation invariant).
|
||||||
|
|
||||||
|
``reason`` values are short codes (``ml`` / ``similar`` / ``from_likes`` /
|
||||||
|
``discover``) the client localizes for the "why is this playing?" affordance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.domain.entities.track import Artist, Track
|
||||||
|
from app.domain.errors import NotFoundError
|
||||||
|
from app.domain.ports import (
|
||||||
|
ArtistRepository,
|
||||||
|
LikeRepository,
|
||||||
|
Recommender,
|
||||||
|
TrackRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
REASON_ML = "ml"
|
||||||
|
REASON_SIMILAR = "similar"
|
||||||
|
REASON_FROM_LIKES = "from_likes"
|
||||||
|
REASON_DISCOVER = "discover"
|
||||||
|
|
||||||
|
_LIKED_SEED_POOL = 50
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RadioPick:
|
||||||
|
track: Track
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
recommender: Recommender,
|
||||||
|
tracks: TrackRepository,
|
||||||
|
artists: ArtistRepository,
|
||||||
|
likes: LikeRepository,
|
||||||
|
) -> None:
|
||||||
|
self._recommender = recommender
|
||||||
|
self._tracks = tracks
|
||||||
|
self._artists = artists
|
||||||
|
self._likes = likes
|
||||||
|
|
||||||
|
# -- similar ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def similar_tracks(
|
||||||
|
self, track_id: uuid.UUID, *, limit: int
|
||||||
|
) -> tuple[str, list[Track]]:
|
||||||
|
seed = await self._tracks.get_by_id(track_id)
|
||||||
|
if seed is None:
|
||||||
|
raise NotFoundError(f"Track {track_id} not found.")
|
||||||
|
|
||||||
|
if self._recommender.is_available():
|
||||||
|
ids = await self._recommender.similar_track_ids(
|
||||||
|
track_id, limit=limit, exclude_ids=[track_id]
|
||||||
|
)
|
||||||
|
if ids is not None:
|
||||||
|
return REASON_ML, await self._hydrate_tracks(ids)
|
||||||
|
|
||||||
|
found = await self._tracks.list_similar(
|
||||||
|
genre=seed.genre,
|
||||||
|
artist_id=seed.artist_id,
|
||||||
|
exclude_ids=[track_id],
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return REASON_SIMILAR, found
|
||||||
|
|
||||||
|
async def similar_artists(
|
||||||
|
self, artist_id: uuid.UUID, *, limit: int
|
||||||
|
) -> tuple[str, list[Artist]]:
|
||||||
|
if await self._artists.get_by_id(artist_id) is None:
|
||||||
|
raise NotFoundError(f"Artist {artist_id} not found.")
|
||||||
|
|
||||||
|
if self._recommender.is_available():
|
||||||
|
ids = await self._recommender.similar_artist_ids(artist_id, limit=limit)
|
||||||
|
if ids is not None:
|
||||||
|
by_id = {a.id: a for a in await self._artists.get_many(ids)}
|
||||||
|
return REASON_ML, [by_id[i] for i in ids if i in by_id]
|
||||||
|
|
||||||
|
found = await self._artists.list_similar(artist_id=artist_id, limit=limit)
|
||||||
|
return REASON_SIMILAR, found
|
||||||
|
|
||||||
|
# -- radio -----------------------------------------------------------------
|
||||||
|
|
||||||
|
async def radio(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
seed_track_id: uuid.UUID | None,
|
||||||
|
from_likes: bool,
|
||||||
|
exploration: float,
|
||||||
|
limit: int,
|
||||||
|
exclude_ids: list[uuid.UUID],
|
||||||
|
) -> tuple[str, list[RadioPick]]:
|
||||||
|
exploration = min(1.0, max(0.0, exploration))
|
||||||
|
|
||||||
|
if self._recommender.is_available():
|
||||||
|
ids = await self._recommender.radio_track_ids(
|
||||||
|
seed_track_id=seed_track_id,
|
||||||
|
exploration=exploration,
|
||||||
|
limit=limit,
|
||||||
|
exclude_ids=exclude_ids,
|
||||||
|
)
|
||||||
|
if ids is not None:
|
||||||
|
picks = [
|
||||||
|
RadioPick(track=t, reason=REASON_ML)
|
||||||
|
for t in await self._hydrate_tracks(ids)
|
||||||
|
]
|
||||||
|
return REASON_ML, picks
|
||||||
|
|
||||||
|
return "metadata", await self._radio_fallback(
|
||||||
|
user_id=user_id,
|
||||||
|
seed_track_id=seed_track_id,
|
||||||
|
from_likes=from_likes,
|
||||||
|
exploration=exploration,
|
||||||
|
limit=limit,
|
||||||
|
exclude_ids=exclude_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _radio_fallback(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
seed_track_id: uuid.UUID | None,
|
||||||
|
from_likes: bool,
|
||||||
|
exploration: float,
|
||||||
|
limit: int,
|
||||||
|
exclude_ids: list[uuid.UUID],
|
||||||
|
) -> list[RadioPick]:
|
||||||
|
exclude = list(dict.fromkeys(exclude_ids)) # de-dupe, keep order
|
||||||
|
explore_n = round(limit * exploration)
|
||||||
|
similar_n = limit - explore_n
|
||||||
|
picks: list[RadioPick] = []
|
||||||
|
|
||||||
|
seed, seed_reason = await self._resolve_seed(
|
||||||
|
user_id, seed_track_id, from_likes
|
||||||
|
)
|
||||||
|
if seed is not None and similar_n > 0:
|
||||||
|
for track in await self._tracks.list_similar(
|
||||||
|
genre=seed.genre,
|
||||||
|
artist_id=seed.artist_id,
|
||||||
|
exclude_ids=exclude,
|
||||||
|
limit=similar_n,
|
||||||
|
):
|
||||||
|
picks.append(RadioPick(track=track, reason=seed_reason))
|
||||||
|
exclude.append(track.id)
|
||||||
|
|
||||||
|
# Fill the remainder (exploration + any similarity shortfall) with random
|
||||||
|
# playable tracks — this is also the total fallback when there's no seed.
|
||||||
|
remaining = limit - len(picks)
|
||||||
|
if remaining > 0:
|
||||||
|
for track in await self._tracks.sample_playable(
|
||||||
|
exclude_ids=exclude, limit=remaining
|
||||||
|
):
|
||||||
|
picks.append(RadioPick(track=track, reason=REASON_DISCOVER))
|
||||||
|
exclude.append(track.id)
|
||||||
|
|
||||||
|
random.shuffle(picks)
|
||||||
|
return picks
|
||||||
|
|
||||||
|
async def _resolve_seed(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
seed_track_id: uuid.UUID | None,
|
||||||
|
from_likes: bool,
|
||||||
|
) -> tuple[Track | None, str]:
|
||||||
|
if seed_track_id is not None:
|
||||||
|
return await self._tracks.get_by_id(seed_track_id), REASON_SIMILAR
|
||||||
|
if from_likes:
|
||||||
|
liked = await self._likes.list_liked_tracks(
|
||||||
|
user_id=user_id, limit=_LIKED_SEED_POOL, offset=0
|
||||||
|
)
|
||||||
|
if liked:
|
||||||
|
return random.choice(liked), REASON_FROM_LIKES
|
||||||
|
return None, REASON_DISCOVER
|
||||||
|
|
||||||
|
async def _hydrate_tracks(self, ids: list[uuid.UUID]) -> list[Track]:
|
||||||
|
"""Resolve ids → tracks preserving order, skipping any that vanished.
|
||||||
|
One batched query rather than N per-id round-trips."""
|
||||||
|
by_id = {t.id: t for t in await self._tracks.get_many(ids)}
|
||||||
|
return [by_id[i] for i in ids if i in by_id]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Offline-first sync use cases: delta pull + idempotent push.
|
||||||
|
|
||||||
|
The cursor is a server-clock timestamp obtained from the DB (``now``), so it is
|
||||||
|
immune to app/DB clock skew. A pull returns everything a user changed in the
|
||||||
|
half-open window ``(since, cursor]``; a push appends the event-log entries a
|
||||||
|
client accumulated offline. Events carry a client-generated id, so a replay is a
|
||||||
|
no-op (append is ``ON CONFLICT DO NOTHING``). Events for tracks this server does
|
||||||
|
not have are skipped rather than rejected (graceful degradation).
|
||||||
|
|
||||||
|
Known limitation (v1): the delta is unpaginated and, being wall-clock based, a
|
||||||
|
write committing right on the cursor boundary under concurrency can slip a cycle
|
||||||
|
— a periodic full resync (``since=None``) heals it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.domain.entities.history import PlayHistoryEntry
|
||||||
|
from app.domain.entities.like import Like
|
||||||
|
from app.domain.entities.playlist import Playlist
|
||||||
|
from app.domain.entities.track import Track
|
||||||
|
from app.domain.ports import (
|
||||||
|
HistoryRepository,
|
||||||
|
LikeRepository,
|
||||||
|
PlaylistRepository,
|
||||||
|
TrackRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LikeEvent:
|
||||||
|
id: uuid.UUID
|
||||||
|
track_id: uuid.UUID
|
||||||
|
value: str
|
||||||
|
created_at: dt.datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PlayEvent:
|
||||||
|
id: uuid.UUID
|
||||||
|
track_id: uuid.UUID
|
||||||
|
played_at: dt.datetime
|
||||||
|
play_duration_seconds: int | None
|
||||||
|
completed: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PlaylistDelta:
|
||||||
|
playlist: Playlist
|
||||||
|
track_ids: list[uuid.UUID]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SyncChanges:
|
||||||
|
cursor: dt.datetime
|
||||||
|
likes: list[Like]
|
||||||
|
plays: list[PlayHistoryEntry]
|
||||||
|
playlists: list[PlaylistDelta]
|
||||||
|
tracks: list[Track]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SyncPushResult:
|
||||||
|
cursor: dt.datetime
|
||||||
|
accepted_likes: int
|
||||||
|
accepted_plays: int
|
||||||
|
|
||||||
|
|
||||||
|
class SyncService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
likes: LikeRepository,
|
||||||
|
history: HistoryRepository,
|
||||||
|
playlists: PlaylistRepository,
|
||||||
|
tracks: TrackRepository,
|
||||||
|
now: Callable[[], Awaitable[dt.datetime]],
|
||||||
|
) -> None:
|
||||||
|
self._likes = likes
|
||||||
|
self._history = history
|
||||||
|
self._playlists = playlists
|
||||||
|
self._tracks = tracks
|
||||||
|
self._now = now
|
||||||
|
|
||||||
|
async def get_changes(self, user_id: uuid.UUID, *, since: dt.datetime | None) -> SyncChanges:
|
||||||
|
until = await self._now()
|
||||||
|
likes = await self._likes.list_since(user_id, since=since, until=until)
|
||||||
|
plays = await self._history.list_since(user_id, since=since, until=until)
|
||||||
|
changed = await self._playlists.list_changed_since(
|
||||||
|
owner_id=user_id, since=since, until=until
|
||||||
|
)
|
||||||
|
playlists = [
|
||||||
|
PlaylistDelta(playlist=p, track_ids=await self._playlists.track_ids(p.id))
|
||||||
|
for p in changed
|
||||||
|
]
|
||||||
|
tracks = await self._tracks.list_changed_since(since=since, until=until)
|
||||||
|
return SyncChanges(
|
||||||
|
cursor=until, likes=likes, plays=plays, playlists=playlists, tracks=tracks
|
||||||
|
)
|
||||||
|
|
||||||
|
async def push(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
likes: list[LikeEvent],
|
||||||
|
plays: list[PlayEvent],
|
||||||
|
) -> SyncPushResult:
|
||||||
|
accepted_likes = 0
|
||||||
|
for event in likes:
|
||||||
|
if await self._tracks.get_by_id(event.track_id) is None:
|
||||||
|
continue # skip events for tracks this server doesn't have
|
||||||
|
if await self._likes.add_event(
|
||||||
|
id=event.id,
|
||||||
|
user_id=user_id,
|
||||||
|
track_id=event.track_id,
|
||||||
|
value=event.value,
|
||||||
|
created_at=event.created_at,
|
||||||
|
):
|
||||||
|
accepted_likes += 1
|
||||||
|
|
||||||
|
accepted_plays = 0
|
||||||
|
for play in plays:
|
||||||
|
if await self._tracks.get_by_id(play.track_id) is None:
|
||||||
|
continue
|
||||||
|
if await self._history.add_event(
|
||||||
|
id=play.id,
|
||||||
|
user_id=user_id,
|
||||||
|
track_id=play.track_id,
|
||||||
|
played_at=play.played_at,
|
||||||
|
play_duration_seconds=play.play_duration_seconds,
|
||||||
|
completed=play.completed,
|
||||||
|
):
|
||||||
|
accepted_plays += 1
|
||||||
|
|
||||||
|
cursor = await self._now()
|
||||||
|
return SyncPushResult(
|
||||||
|
cursor=cursor, accepted_likes=accepted_likes, accepted_plays=accepted_plays
|
||||||
|
)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Transcode service + cache-path helpers (Group B / plan §6.6).
|
||||||
|
|
||||||
|
Cache layout under ``transcode_cache_path``::
|
||||||
|
|
||||||
|
{track_id}/opus_{kbps}.opus # direct quality renditions
|
||||||
|
{track_id}/hls/playlist.m3u8 # HLS rendition (AAC-in-TS)
|
||||||
|
{track_id}/hls/seg_000.ts …
|
||||||
|
|
||||||
|
The request side (streaming router) only *reads* the cache — misses fall back to
|
||||||
|
the original file and enqueue generation. The worker (``transcode_task``) writes
|
||||||
|
it. Path helpers are module-level so both sides agree on locations without one
|
||||||
|
importing the other.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
from app.domain.errors import NotFoundError
|
||||||
|
from app.domain.ports import TrackRepository
|
||||||
|
|
||||||
|
# Stream-quality name (matches the user-settings ``StreamQuality``) → Opus
|
||||||
|
# bitrate. ``original`` is absent: it means "serve the master, no transcode".
|
||||||
|
QUALITY_BITRATE: dict[str, int] = {"high": 128, "medium": 96, "low": 64}
|
||||||
|
|
||||||
|
# Single HLS rendition bitrate (AAC). One rendition keeps the MVP simple; a
|
||||||
|
# multi-bitrate ladder can come later.
|
||||||
|
HLS_BITRATE = 128
|
||||||
|
|
||||||
|
# Only these segment names may be served, guarding the segment route against
|
||||||
|
# path traversal.
|
||||||
|
_SEGMENT_RE = re.compile(r"^seg_\d{3,}\.ts$")
|
||||||
|
|
||||||
|
|
||||||
|
def bitrate_for_quality(quality: str) -> int | None:
|
||||||
|
"""Opus bitrate for a quality name, or ``None`` for ``original``/unknown."""
|
||||||
|
return QUALITY_BITRATE.get(quality)
|
||||||
|
|
||||||
|
|
||||||
|
def track_cache_dir(root: Path, track_id: uuid.UUID) -> Path:
|
||||||
|
return root / str(track_id)
|
||||||
|
|
||||||
|
|
||||||
|
def opus_path(root: Path, track_id: uuid.UUID, bitrate_kbps: int) -> Path:
|
||||||
|
return track_cache_dir(root, track_id) / f"opus_{bitrate_kbps}.opus"
|
||||||
|
|
||||||
|
|
||||||
|
def hls_dir(root: Path, track_id: uuid.UUID) -> Path:
|
||||||
|
return track_cache_dir(root, track_id) / "hls"
|
||||||
|
|
||||||
|
|
||||||
|
def hls_playlist_path(root: Path, track_id: uuid.UUID) -> Path:
|
||||||
|
return hls_dir(root, track_id) / "playlist.m3u8"
|
||||||
|
|
||||||
|
|
||||||
|
def hls_segment_path(root: Path, track_id: uuid.UUID, name: str) -> Path | None:
|
||||||
|
"""Resolve a segment file, or ``None`` if the name is not a valid segment."""
|
||||||
|
if not _SEGMENT_RE.fullmatch(name):
|
||||||
|
return None
|
||||||
|
return hls_dir(root, track_id) / name
|
||||||
|
|
||||||
|
|
||||||
|
def remove_track_cache(root: Path, track_id: uuid.UUID) -> None:
|
||||||
|
"""Delete every cached rendition for a track (Opus + HLS). Best-effort — used
|
||||||
|
when a track is deleted so its transcode cache doesn't dangle forever."""
|
||||||
|
shutil.rmtree(track_cache_dir(root, track_id), ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TranscodeService:
|
||||||
|
"""Request-side cache lookups for transcoded renditions."""
|
||||||
|
|
||||||
|
def __init__(self, *, tracks: TrackRepository, cache_root: Path) -> None:
|
||||||
|
self._tracks = tracks
|
||||||
|
self._root = cache_root
|
||||||
|
|
||||||
|
async def _require_streamable(self, track_id: uuid.UUID) -> None:
|
||||||
|
track = await self._tracks.get_by_id(track_id)
|
||||||
|
if track is None:
|
||||||
|
raise NotFoundError("Track not found.")
|
||||||
|
if track.storage_uri is None:
|
||||||
|
raise NotFoundError("Track is not yet downloaded.")
|
||||||
|
|
||||||
|
async def resolve_quality_file(
|
||||||
|
self, track_id: uuid.UUID, quality: str
|
||||||
|
) -> Path | None:
|
||||||
|
"""Cached Opus file for ``quality`` if present, else ``None`` (caller
|
||||||
|
falls back to the master and enqueues generation). ``original`` → None."""
|
||||||
|
bitrate = bitrate_for_quality(quality)
|
||||||
|
if bitrate is None:
|
||||||
|
return None
|
||||||
|
path = opus_path(self._root, track_id, bitrate)
|
||||||
|
exists = await anyio.to_thread.run_sync(path.exists)
|
||||||
|
return path if exists else None
|
||||||
|
|
||||||
|
async def hls_playlist(self, track_id: uuid.UUID) -> Path | None:
|
||||||
|
"""Cached HLS playlist if generated, else ``None`` (validates the track
|
||||||
|
exists so an unknown id 404s rather than silently missing)."""
|
||||||
|
await self._require_streamable(track_id)
|
||||||
|
path = hls_playlist_path(self._root, track_id)
|
||||||
|
exists = await anyio.to_thread.run_sync(path.exists)
|
||||||
|
return path if exists else None
|
||||||
|
|
||||||
|
def hls_segment(self, track_id: uuid.UUID, name: str) -> Path | None:
|
||||||
|
path = hls_segment_path(self._root, track_id, name)
|
||||||
|
if path is None or not path.exists():
|
||||||
|
return None
|
||||||
|
return path
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""User-settings use cases: general preferences + scrobbling configuration.
|
||||||
|
|
||||||
|
Settings rows are created lazily — a user who never saved anything reads clean
|
||||||
|
defaults. Partial updates are merged against current values here, so the
|
||||||
|
repository always persists the complete desired state. The scrobbler session
|
||||||
|
key is encrypted before it touches the DB (never stored or returned in plain).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from app.domain.entities.settings import UserSettings
|
||||||
|
from app.domain.ports import SubsonicCipher, UserSettingsRepository
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettingsService:
|
||||||
|
def __init__(self, *, settings: UserSettingsRepository, cipher: SubsonicCipher) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
# Same Fernet cipher used for the Subsonic app-password — reused here to
|
||||||
|
# encrypt the scrobbler session key at rest (symmetric, recoverable).
|
||||||
|
self._cipher = cipher
|
||||||
|
|
||||||
|
async def get(self, user_id: uuid.UUID) -> UserSettings:
|
||||||
|
return await self._settings.get(user_id) or UserSettings.defaults(user_id)
|
||||||
|
|
||||||
|
async def update_general(
|
||||||
|
self, user_id: uuid.UUID, *, theme: str | None, stream_quality: str | None
|
||||||
|
) -> UserSettings:
|
||||||
|
current = await self.get(user_id)
|
||||||
|
merged = replace(
|
||||||
|
current,
|
||||||
|
theme=theme if theme is not None else current.theme,
|
||||||
|
stream_quality=stream_quality if stream_quality is not None else current.stream_quality,
|
||||||
|
)
|
||||||
|
return await self._settings.upsert(merged)
|
||||||
|
|
||||||
|
async def set_scrobbling(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
provider: str | None,
|
||||||
|
username: str | None,
|
||||||
|
session_key: str | None,
|
||||||
|
) -> UserSettings:
|
||||||
|
"""Replace the scrobbling config. ``session_key`` is write-only: a new
|
||||||
|
value is encrypted and stored; omitting it keeps the existing key (the
|
||||||
|
client can't read it back to re-send it)."""
|
||||||
|
current = await self.get(user_id)
|
||||||
|
session_key_enc: str | None
|
||||||
|
if session_key is not None:
|
||||||
|
session_key_enc = self._cipher.encrypt(session_key)
|
||||||
|
else:
|
||||||
|
session_key_enc = current.scrobble_session_key_enc
|
||||||
|
merged = replace(
|
||||||
|
current,
|
||||||
|
scrobble_enabled=enabled,
|
||||||
|
scrobble_provider=provider,
|
||||||
|
scrobble_username=username,
|
||||||
|
scrobble_session_key_enc=session_key_enc,
|
||||||
|
)
|
||||||
|
return await self._settings.upsert(merged)
|
||||||
+83
-1
@@ -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,6 +57,20 @@ 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
|
||||||
|
|
||||||
|
# -- CORS -------------------------------------------------------------
|
||||||
|
# Origins allowed to call the API from a browser. The web UI is multi-
|
||||||
|
# instance — it connects to whatever origin the operator types on the
|
||||||
|
# connect screen — so a page served from origin A may call this backend at
|
||||||
|
# origin B (e.g. the direct :8000 port, a LAN IP, or 127.0.0.1 vs localhost).
|
||||||
|
# Auth rides in the ``Authorization`` bearer header (not cookies), so the
|
||||||
|
# wildcard default is safe here — it is paired with ``allow_credentials=False``.
|
||||||
|
# Set explicit origins in hardened deployments. Accepts a comma-separated
|
||||||
|
# string in ``.env`` (``CORS_ALLOW_ORIGINS=https://a,https://b``) or ``*``.
|
||||||
|
cors_allow_origins: list[str] = Field(default_factory=lambda: ["*"])
|
||||||
|
|
||||||
# -- subsonic ---------------------------------------------------------
|
# -- subsonic ---------------------------------------------------------
|
||||||
# Symmetric key (any string) used to encrypt each user's recoverable
|
# Symmetric key (any string) used to encrypt each user's recoverable
|
||||||
@@ -54,10 +81,21 @@ class Settings(BaseSettings):
|
|||||||
# -- 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")
|
||||||
|
# ffmpeg binary for transcoding/HLS (on PATH in the image); override for a
|
||||||
|
# non-standard location.
|
||||||
|
ffmpeg_path: str = "ffmpeg"
|
||||||
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
|
||||||
@@ -68,9 +106,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:
|
||||||
@@ -78,10 +141,29 @@ class Settings(BaseSettings):
|
|||||||
raise ValueError("database_url must use the asyncpg driver: postgresql+asyncpg://")
|
raise ValueError("database_url must use the asyncpg driver: postgresql+asyncpg://")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("cors_allow_origins", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _split_cors_origins(cls, v: object) -> object:
|
||||||
|
# Allow a plain comma-separated string in .env (pydantic would otherwise
|
||||||
|
# try to JSON-decode a list field): "a, b" -> ["a", "b"]; "*" -> ["*"].
|
||||||
|
if isinstance(v, str):
|
||||||
|
return [origin.strip() for origin in v.split(",") if origin.strip()]
|
||||||
|
return v
|
||||||
|
|
||||||
@property
|
@property
|
||||||
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:
|
||||||
|
|||||||
@@ -1,21 +1,37 @@
|
|||||||
"""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, SubsonicCredentials, 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",
|
"SubsonicCredentials",
|
||||||
"Track",
|
"Track",
|
||||||
"User",
|
"User",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Lyrics value objects (plan §6.7).
|
||||||
|
|
||||||
|
``Lyrics`` is the cached row for a track; ``LyricsResult`` is what a provider
|
||||||
|
(LRCLIB) returns for a lookup. Both cross the domain boundary — no framework
|
||||||
|
imports. Status values mirror ``LyricsStatus`` in the ORM enum ("found" /
|
||||||
|
"not_found" / "pending") but are kept as plain strings here so the domain stays
|
||||||
|
independent of the persistence layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LyricsResult:
|
||||||
|
"""A provider hit: synced (timestamped LRC) and/or plain text."""
|
||||||
|
|
||||||
|
synced: str | None
|
||||||
|
plain: str | None
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Lyrics:
|
||||||
|
"""Cached lyrics for one track. ``status`` is ``found`` / ``not_found`` /
|
||||||
|
``pending``; ``not_found`` is cached too (with a TTL in the service) so a
|
||||||
|
track with no lyrics doesn't hammer the provider on every play."""
|
||||||
|
|
||||||
|
track_id: uuid.UUID
|
||||||
|
synced: str | None
|
||||||
|
plain: str | None
|
||||||
|
source: str | None
|
||||||
|
status: str
|
||||||
|
fetched_at: dt.datetime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_lyrics(self) -> bool:
|
||||||
|
return self.status == "found" and (
|
||||||
|
self.synced is not None or self.plain is not None
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""User settings domain entity (general preferences + scrobbling config)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# Defaults for a user who has never saved settings — the row is created lazily,
|
||||||
|
# so reads return these before the first write.
|
||||||
|
DEFAULT_THEME = "system"
|
||||||
|
DEFAULT_STREAM_QUALITY = "original"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class UserSettings:
|
||||||
|
user_id: uuid.UUID
|
||||||
|
theme: str
|
||||||
|
stream_quality: str
|
||||||
|
scrobble_enabled: bool
|
||||||
|
scrobble_provider: str | None
|
||||||
|
scrobble_username: str | None
|
||||||
|
# Scrobbler session key / user token, encrypted at rest (never leaves the
|
||||||
|
# server in plaintext). ``None`` until the user configures scrobbling.
|
||||||
|
scrobble_session_key_enc: str | None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def defaults(cls, user_id: uuid.UUID) -> UserSettings:
|
||||||
|
return cls(
|
||||||
|
user_id=user_id,
|
||||||
|
theme=DEFAULT_THEME,
|
||||||
|
stream_quality=DEFAULT_STREAM_QUALITY,
|
||||||
|
scrobble_enabled=False,
|
||||||
|
scrobble_provider=None,
|
||||||
|
scrobble_username=None,
|
||||||
|
scrobble_session_key_enc=None,
|
||||||
|
)
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ class PermissionDeniedError(DomainError):
|
|||||||
code = "permission_denied"
|
code = "permission_denied"
|
||||||
|
|
||||||
|
|
||||||
|
class NotSupportedError(DomainError):
|
||||||
|
"""Operation is intentionally unsupported (e.g. a config knob that is managed
|
||||||
|
via environment, not mutable at runtime)."""
|
||||||
|
|
||||||
|
code = "not_supported"
|
||||||
|
|
||||||
|
|
||||||
class DependencyUnavailableError(DomainError):
|
class DependencyUnavailableError(DomainError):
|
||||||
"""An external dependency (source, ML, MusicBrainz) is unavailable.
|
"""An external dependency (source, ML, MusicBrainz) is unavailable.
|
||||||
|
|
||||||
@@ -69,6 +76,13 @@ class StorageError(DomainError):
|
|||||||
code = "storage_error"
|
code = "storage_error"
|
||||||
|
|
||||||
|
|
||||||
|
class TranscodeError(DomainError):
|
||||||
|
"""Transcoding (ffmpeg) failed. Raised in the worker; a play falls back to
|
||||||
|
the original file rather than surfacing this."""
|
||||||
|
|
||||||
|
code = "transcode_error"
|
||||||
|
|
||||||
|
|
||||||
class RangeNotSatisfiableError(DomainError):
|
class RangeNotSatisfiableError(DomainError):
|
||||||
"""Requested byte range cannot be satisfied."""
|
"""Requested byte range cannot be satisfied."""
|
||||||
|
|
||||||
|
|||||||
+374
-6
@@ -7,24 +7,39 @@ 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,
|
SubsonicCredentials,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from app.domain.entities.lyrics import Lyrics, LyricsResult
|
||||||
|
from app.domain.entities.settings import UserSettings
|
||||||
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: ...
|
||||||
@@ -43,6 +58,11 @@ class UserRepository(Protocol):
|
|||||||
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None: ...
|
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettingsRepository(Protocol):
|
||||||
|
async def get(self, user_id: uuid.UUID) -> UserSettings | None: ...
|
||||||
|
async def upsert(self, settings: UserSettings) -> UserSettings: ...
|
||||||
|
|
||||||
|
|
||||||
class SubsonicCipher(Protocol):
|
class SubsonicCipher(Protocol):
|
||||||
"""Symmetric encrypt/decrypt for the recoverable Subsonic app-password."""
|
"""Symmetric encrypt/decrypt for the recoverable Subsonic app-password."""
|
||||||
|
|
||||||
@@ -93,12 +113,27 @@ 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_similar(self, *, artist_id: uuid.UUID, limit: int) -> list[Artist]:
|
||||||
|
"""Artists sharing the seed artist's genres, ranked by overlap. Metadata
|
||||||
|
fallback for ``GET /artists/{id}/similar``. Defined before ``list`` so the
|
||||||
|
``list[Artist]`` annotation isn't shadowed by the method named ``list``."""
|
||||||
|
...
|
||||||
|
|
||||||
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]: ...
|
||||||
async def count(self, *, q: str | None) -> int: ...
|
async def count(self, *, q: str | None) -> int: ...
|
||||||
async def album_count(self, artist_id: uuid.UUID) -> int: ...
|
async def album_count(self, artist_id: uuid.UUID) -> int: ...
|
||||||
@@ -107,6 +142,11 @@ class ArtistRepository(Protocol):
|
|||||||
|
|
||||||
class TrackRepository(Protocol):
|
class TrackRepository(Protocol):
|
||||||
async def get_by_id(self, track_id: uuid.UUID) -> Track | None: ...
|
async def get_by_id(self, track_id: uuid.UUID) -> Track | None: ...
|
||||||
|
async def get_many(self, ids: list[uuid.UUID]) -> list[Track]:
|
||||||
|
"""Resolve multiple ids in one query (unordered) — batches the per-id
|
||||||
|
lookups radio/similar would otherwise fan out into N round-trips."""
|
||||||
|
...
|
||||||
|
|
||||||
async def get_by_source(self, source: str, source_id: str) -> Track | None: ...
|
async def get_by_source(self, source: str, source_id: str) -> Track | None: ...
|
||||||
async def add(
|
async def add(
|
||||||
self,
|
self,
|
||||||
@@ -114,24 +154,68 @@ 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 must come before ``list`` — the method named ``list`` shadows the
|
# genres / library_stats must come before ``list`` — the method named
|
||||||
# builtin in later annotations (same pattern as AlbumRepository below).
|
# ``list`` shadows the builtin in later annotations (same pattern as
|
||||||
|
# AlbumRepository below).
|
||||||
async def genres(self) -> list[tuple[str, int]]: ...
|
async def genres(self) -> list[tuple[str, int]]: ...
|
||||||
|
async def library_stats(self) -> LibraryStats: ...
|
||||||
|
async def list_similar(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
genre: str | None,
|
||||||
|
artist_id: uuid.UUID,
|
||||||
|
exclude_ids: list[uuid.UUID],
|
||||||
|
limit: int,
|
||||||
|
) -> list[Track]:
|
||||||
|
"""Playable tracks resembling a seed (same genre and/or artist), ranked
|
||||||
|
by match strength then shuffled. The metadata fallback for §6.5 radio /
|
||||||
|
similar when no ML service is configured."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def sample_playable(
|
||||||
|
self, *, exclude_ids: list[uuid.UUID], limit: int
|
||||||
|
) -> list[Track]:
|
||||||
|
"""Random playable tracks — the exploration filler for radio."""
|
||||||
|
...
|
||||||
|
async def find_duplicate_groups(self) -> list[tuple[str, list[Track]]]: ...
|
||||||
|
async def list_by_metadata_status(
|
||||||
|
self, status: str, *, limit: int, offset: int
|
||||||
|
) -> list[Track]: ...
|
||||||
|
async def all_storage_refs(self) -> list[tuple[uuid.UUID, str]]: ...
|
||||||
|
async def count_by_metadata_status(self, status: str) -> int: ...
|
||||||
|
async def list_changed_since(
|
||||||
|
self, *, since: dt.datetime | None, until: dt.datetime
|
||||||
|
) -> list[Track]: ...
|
||||||
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,
|
||||||
@@ -143,6 +227,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,
|
||||||
@@ -152,9 +237,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: ...
|
||||||
@@ -187,17 +323,38 @@ class PlaylistRepository(Protocol):
|
|||||||
self, playlist_id: uuid.UUID, *, limit: int, offset: int
|
self, playlist_id: uuid.UUID, *, limit: int, offset: int
|
||||||
) -> list[Track]: ...
|
) -> list[Track]: ...
|
||||||
async def get_track_total(self, playlist_id: uuid.UUID) -> int: ...
|
async def get_track_total(self, playlist_id: uuid.UUID) -> int: ...
|
||||||
|
async def has_track(self, playlist_id: uuid.UUID, track_id: uuid.UUID) -> bool: ...
|
||||||
async def add_track(
|
async def add_track(
|
||||||
self, playlist_id: uuid.UUID, track_id: uuid.UUID, *, position: float
|
self, playlist_id: uuid.UUID, track_id: uuid.UUID, *, position: float
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
async def remove_track(self, playlist_id: uuid.UUID, track_id: uuid.UUID) -> None: ...
|
async def remove_track(self, playlist_id: uuid.UUID, track_id: uuid.UUID) -> None: ...
|
||||||
async def max_position(self, playlist_id: uuid.UUID) -> float: ...
|
async def max_position(self, playlist_id: uuid.UUID) -> float: ...
|
||||||
|
async def reorder_tracks(
|
||||||
|
self, playlist_id: uuid.UUID, ordered_track_ids: list[uuid.UUID]
|
||||||
|
) -> None: ...
|
||||||
|
async def get_cover_path(self, playlist_id: uuid.UUID) -> str | None: ...
|
||||||
|
async def list_changed_since(
|
||||||
|
self, *, owner_id: uuid.UUID, since: dt.datetime | None, until: dt.datetime
|
||||||
|
) -> list[Playlist]: ...
|
||||||
|
async def track_ids(self, playlist_id: uuid.UUID) -> list[uuid.UUID]: ...
|
||||||
# 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(self, *, owner_id: uuid.UUID, limit: int, offset: int) -> list[Playlist]: ...
|
async def list(self, *, owner_id: uuid.UUID, limit: int, offset: int) -> list[Playlist]: ...
|
||||||
|
|
||||||
|
|
||||||
class LikeRepository(Protocol):
|
class LikeRepository(Protocol):
|
||||||
async def add(self, *, user_id: uuid.UUID, track_id: uuid.UUID, value: str) -> Like: ...
|
async def add(self, *, user_id: uuid.UUID, track_id: uuid.UUID, value: str) -> Like: ...
|
||||||
|
async def add_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
value: str,
|
||||||
|
created_at: dt.datetime,
|
||||||
|
) -> bool: ...
|
||||||
|
async def list_since(
|
||||||
|
self, user_id: uuid.UUID, *, since: dt.datetime | None, until: dt.datetime
|
||||||
|
) -> list[Like]: ...
|
||||||
async def get_latest_state(
|
async def get_latest_state(
|
||||||
self, *, user_id: uuid.UUID, track_ids: list[uuid.UUID]
|
self, *, user_id: uuid.UUID, track_ids: list[uuid.UUID]
|
||||||
) -> list[Like]: ...
|
) -> list[Like]: ...
|
||||||
@@ -217,7 +374,218 @@ class HistoryRepository(Protocol):
|
|||||||
play_duration_seconds: int | None,
|
play_duration_seconds: int | None,
|
||||||
completed: bool,
|
completed: bool,
|
||||||
) -> PlayHistoryEntry: ...
|
) -> PlayHistoryEntry: ...
|
||||||
|
async def add_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
played_at: dt.datetime,
|
||||||
|
play_duration_seconds: int | None,
|
||||||
|
completed: bool,
|
||||||
|
) -> bool: ...
|
||||||
|
async def list_since(
|
||||||
|
self, user_id: uuid.UUID, *, since: dt.datetime | None, until: dt.datetime
|
||||||
|
) -> list[PlayHistoryEntry]: ...
|
||||||
async def list(
|
async def list(
|
||||||
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: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Recommender(Protocol):
|
||||||
|
"""External ML recommender (plan §6.5, ``ML_SERVICE_URL``). Returns ordered
|
||||||
|
track/artist ids, or ``None`` when unavailable/erroring so the service falls
|
||||||
|
back to metadata heuristics — ML is never a hard dependency (invariant)."""
|
||||||
|
|
||||||
|
def is_available(self) -> bool: ...
|
||||||
|
|
||||||
|
async def similar_track_ids(
|
||||||
|
self, track_id: uuid.UUID, *, limit: int, exclude_ids: list[uuid.UUID]
|
||||||
|
) -> list[uuid.UUID] | None: ...
|
||||||
|
|
||||||
|
async def similar_artist_ids(
|
||||||
|
self, artist_id: uuid.UUID, *, limit: int
|
||||||
|
) -> list[uuid.UUID] | None: ...
|
||||||
|
|
||||||
|
async def radio_track_ids(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
seed_track_id: uuid.UUID | None,
|
||||||
|
exploration: float,
|
||||||
|
limit: int,
|
||||||
|
exclude_ids: list[uuid.UUID],
|
||||||
|
) -> list[uuid.UUID] | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Transcoder(Protocol):
|
||||||
|
"""Transcodes an audio file with ffmpeg (plan §6.6 / Group B). ``to_opus``
|
||||||
|
writes a single Opus rendition; ``to_hls`` writes an HLS playlist + segments
|
||||||
|
(AAC-in-TS) into ``out_dir``. Both raise ``TranscodeError`` on failure; heavy
|
||||||
|
work always runs in a worker, never the request cycle."""
|
||||||
|
|
||||||
|
async def to_opus(self, src: Path, dest: Path, *, bitrate_kbps: int) -> None: ...
|
||||||
|
async def to_hls(self, src: Path, out_dir: Path, *, bitrate_kbps: int) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class LyricsProvider(Protocol):
|
||||||
|
"""Fetches lyrics from an external database (LRCLIB) by artist/title/album/
|
||||||
|
duration. Returns a hit or ``None`` (no match / service down), never raising."""
|
||||||
|
|
||||||
|
async def fetch(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
artist: str,
|
||||||
|
title: str,
|
||||||
|
album: str | None,
|
||||||
|
duration_seconds: int | None,
|
||||||
|
) -> LyricsResult | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class LyricsRepository(Protocol):
|
||||||
|
"""Cached lyrics, one row per track. ``upsert`` also caches a ``not_found``
|
||||||
|
(empty text) so misses aren't re-fetched until the service's TTL lapses."""
|
||||||
|
|
||||||
|
async def get(self, track_id: uuid.UUID) -> Lyrics | None: ...
|
||||||
|
async def upsert(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
synced: str | None,
|
||||||
|
plain: str | None,
|
||||||
|
source: str | None,
|
||||||
|
status: str,
|
||||||
|
) -> Lyrics: ...
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,6 +14,7 @@ from app.infrastructure.db.models.play_history import PlayHistoryModel
|
|||||||
from app.infrastructure.db.models.playlist import PlaylistModel, PlaylistTrackModel
|
from app.infrastructure.db.models.playlist import PlaylistModel, PlaylistTrackModel
|
||||||
from app.infrastructure.db.models.track import TrackModel
|
from app.infrastructure.db.models.track import TrackModel
|
||||||
from app.infrastructure.db.models.user import RefreshTokenModel, UserModel
|
from app.infrastructure.db.models.user import RefreshTokenModel, UserModel
|
||||||
|
from app.infrastructure.db.models.user_settings import UserSettingsModel
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AlbumModel",
|
"AlbumModel",
|
||||||
@@ -27,4 +28,5 @@ __all__ = [
|
|||||||
"RefreshTokenModel",
|
"RefreshTokenModel",
|
||||||
"TrackModel",
|
"TrackModel",
|
||||||
"UserModel",
|
"UserModel",
|
||||||
|
"UserSettingsModel",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -37,3 +37,12 @@ class LikeModel(UUIDPrimaryKeyMixin, Base):
|
|||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
# Server ingestion time — the delta-sync ordering key. Set once at insert and
|
||||||
|
# never changed; distinct from ``created_at`` (the real event time, which a
|
||||||
|
# sync push preserves from the client even when it happened offline earlier).
|
||||||
|
synced_at: Mapped[dt.datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|||||||
@@ -34,3 +34,11 @@ class PlayHistoryModel(UUIDPrimaryKeyMixin, Base):
|
|||||||
)
|
)
|
||||||
play_duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
play_duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
# Server ingestion time — the delta-sync ordering key (see LikeModel). Distinct
|
||||||
|
# from ``played_at`` (the real play time), which a sync push preserves.
|
||||||
|
synced_at: Mapped[dt.datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""ORM model for per-user settings (general preferences + scrobbling)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.infrastructure.db.base import Base
|
||||||
|
from app.infrastructure.db.models.mixins import TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettingsModel(TimestampMixin, Base):
|
||||||
|
"""One row per user, created lazily on first save. The primary key *is* the
|
||||||
|
user id (a 1:1 extension of ``users``), so there's no separate surrogate id."""
|
||||||
|
|
||||||
|
__tablename__ = "user_settings"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
theme: Mapped[str] = mapped_column(String(16), default="system", nullable=False)
|
||||||
|
stream_quality: Mapped[str] = mapped_column(String(16), default="original", nullable=False)
|
||||||
|
scrobble_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
scrobble_provider: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
scrobble_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
# Fernet-encrypted scrobbler session key / token (see core.security). Never
|
||||||
|
# the plaintext — mirrors how the Subsonic app-password is stored.
|
||||||
|
scrobble_session_key_enc: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
@@ -2,22 +2,32 @@
|
|||||||
|
|
||||||
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.lyrics_repository import SqlAlchemyLyricsRepository
|
||||||
from app.infrastructure.db.repositories.playlist_repository import SqlAlchemyPlaylistRepository
|
from app.infrastructure.db.repositories.playlist_repository import SqlAlchemyPlaylistRepository
|
||||||
from app.infrastructure.db.repositories.refresh_token_repository import (
|
from app.infrastructure.db.repositories.refresh_token_repository import (
|
||||||
SqlAlchemyRefreshTokenRepository,
|
SqlAlchemyRefreshTokenRepository,
|
||||||
)
|
)
|
||||||
from app.infrastructure.db.repositories.track_repository import SqlAlchemyTrackRepository
|
from app.infrastructure.db.repositories.track_repository import SqlAlchemyTrackRepository
|
||||||
from app.infrastructure.db.repositories.user_repository import SqlAlchemyUserRepository
|
from app.infrastructure.db.repositories.user_repository import SqlAlchemyUserRepository
|
||||||
|
from app.infrastructure.db.repositories.user_settings_repository import (
|
||||||
|
SqlAlchemyUserSettingsRepository,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"SqlAlchemyAlbumRepository",
|
"SqlAlchemyAlbumRepository",
|
||||||
"SqlAlchemyArtistRepository",
|
"SqlAlchemyArtistRepository",
|
||||||
|
"SqlAlchemyDownloadJobRepository",
|
||||||
"SqlAlchemyHistoryRepository",
|
"SqlAlchemyHistoryRepository",
|
||||||
"SqlAlchemyLikeRepository",
|
"SqlAlchemyLikeRepository",
|
||||||
|
"SqlAlchemyLyricsRepository",
|
||||||
"SqlAlchemyPlaylistRepository",
|
"SqlAlchemyPlaylistRepository",
|
||||||
"SqlAlchemyRefreshTokenRepository",
|
"SqlAlchemyRefreshTokenRepository",
|
||||||
"SqlAlchemyTrackRepository",
|
"SqlAlchemyTrackRepository",
|
||||||
"SqlAlchemyUserRepository",
|
"SqlAlchemyUserRepository",
|
||||||
|
"SqlAlchemyUserSettingsRepository",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -49,6 +77,26 @@ class SqlAlchemyArtistRepository:
|
|||||||
)
|
)
|
||||||
return [_to_entity(r) for r in rows]
|
return [_to_entity(r) for r in rows]
|
||||||
|
|
||||||
|
async def list_similar(self, *, artist_id: uuid.UUID, limit: int) -> list[Artist]:
|
||||||
|
# Artists whose tracks fall in the seed artist's genres, ranked by how
|
||||||
|
# many such tracks they have. Defined before ``list`` so the ``list[Artist]``
|
||||||
|
# return annotation isn't shadowed by the method named ``list``.
|
||||||
|
seed_genres = (
|
||||||
|
select(TrackModel.genre)
|
||||||
|
.where(TrackModel.artist_id == artist_id, TrackModel.genre.is_not(None))
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(ArtistModel)
|
||||||
|
.join(TrackModel, TrackModel.artist_id == ArtistModel.id)
|
||||||
|
.where(TrackModel.genre.in_(seed_genres), ArtistModel.id != artist_id)
|
||||||
|
.group_by(ArtistModel.id)
|
||||||
|
.order_by(func.count(TrackModel.id).desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
rows = (await self._session.execute(stmt)).scalars().all()
|
||||||
|
return [_to_entity(r) for r in rows]
|
||||||
|
|
||||||
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]:
|
||||||
stmt = select(ArtistModel)
|
stmt = select(ArtistModel)
|
||||||
if q:
|
if q:
|
||||||
@@ -80,3 +128,4 @@ class SqlAlchemyArtistRepository:
|
|||||||
.where(TrackModel.artist_id == artist_id)
|
.where(TrackModel.artist_id == artist_id)
|
||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,6 +4,7 @@ import datetime as dt
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.domain.entities.history import PlayHistoryEntry
|
from app.domain.entities.history import PlayHistoryEntry
|
||||||
@@ -46,6 +47,51 @@ class SqlAlchemyHistoryRepository:
|
|||||||
await self._session.refresh(row)
|
await self._session.refresh(row)
|
||||||
return _to_entity(row)
|
return _to_entity(row)
|
||||||
|
|
||||||
|
async def add_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
played_at: dt.datetime,
|
||||||
|
play_duration_seconds: int | None,
|
||||||
|
completed: bool,
|
||||||
|
) -> bool:
|
||||||
|
"""Idempotent append for sync push: insert a client-generated play event,
|
||||||
|
skipping it if the ``id`` already exists (a replay). Returns whether a new
|
||||||
|
row was stored. Defined before ``list`` (name-shadowing)."""
|
||||||
|
stmt = (
|
||||||
|
pg_insert(PlayHistoryModel)
|
||||||
|
.values(
|
||||||
|
id=id,
|
||||||
|
user_id=user_id,
|
||||||
|
track_id=track_id,
|
||||||
|
played_at=played_at,
|
||||||
|
play_duration_seconds=play_duration_seconds,
|
||||||
|
completed=completed,
|
||||||
|
)
|
||||||
|
.on_conflict_do_nothing(index_elements=["id"])
|
||||||
|
.returning(PlayHistoryModel.id)
|
||||||
|
)
|
||||||
|
inserted = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return inserted is not None
|
||||||
|
|
||||||
|
async def list_since(
|
||||||
|
self, user_id: uuid.UUID, *, since: dt.datetime | None, until: dt.datetime
|
||||||
|
) -> list[PlayHistoryEntry]:
|
||||||
|
"""Play events for a user ingested in the half-open window ``(since,
|
||||||
|
until]`` (by ``synced_at``, the server-side sync key — so events pushed
|
||||||
|
with an older ``played_at`` still surface), oldest first. ``since=None``
|
||||||
|
returns everything up to ``until``."""
|
||||||
|
stmt = select(PlayHistoryModel).where(
|
||||||
|
PlayHistoryModel.user_id == user_id, PlayHistoryModel.synced_at <= until
|
||||||
|
)
|
||||||
|
if since is not None:
|
||||||
|
stmt = stmt.where(PlayHistoryModel.synced_at > since)
|
||||||
|
stmt = stmt.order_by(PlayHistoryModel.synced_at)
|
||||||
|
rows = (await self._session.execute(stmt)).scalars().all()
|
||||||
|
return [_to_entity(r) for r in rows]
|
||||||
|
|
||||||
async def list(self, *, user_id: uuid.UUID, limit: int, offset: int) -> list[PlayHistoryEntry]:
|
async def list(self, *, user_id: uuid.UUID, limit: int, offset: int) -> list[PlayHistoryEntry]:
|
||||||
rows = (
|
rows = (
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
Likes are an append-only event log. Current state = latest event per (user, track).
|
Likes are an append-only event log. Current state = latest event per (user, track).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import Subquery, func, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.domain.entities.like import Like
|
from app.domain.entities.like import Like
|
||||||
@@ -38,7 +40,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,
|
||||||
)
|
)
|
||||||
@@ -55,31 +61,96 @@ class SqlAlchemyLikeRepository:
|
|||||||
await self._session.refresh(row)
|
await self._session.refresh(row)
|
||||||
return _to_entity(row)
|
return _to_entity(row)
|
||||||
|
|
||||||
|
async def add_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
value: str,
|
||||||
|
created_at: dt.datetime,
|
||||||
|
) -> bool:
|
||||||
|
"""Idempotent append for sync push: insert a client-generated like event,
|
||||||
|
skipping it if the ``id`` already exists (a replay). Preserves the
|
||||||
|
client's ``created_at`` (the event happened offline earlier). Returns
|
||||||
|
whether a new row was stored."""
|
||||||
|
stmt = (
|
||||||
|
pg_insert(LikeModel)
|
||||||
|
.values(
|
||||||
|
id=id,
|
||||||
|
user_id=user_id,
|
||||||
|
track_id=track_id,
|
||||||
|
value=value,
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
.on_conflict_do_nothing(index_elements=["id"])
|
||||||
|
.returning(LikeModel.id)
|
||||||
|
)
|
||||||
|
inserted = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return inserted is not None
|
||||||
|
|
||||||
|
async def list_since(
|
||||||
|
self, user_id: uuid.UUID, *, since: dt.datetime | None, until: dt.datetime
|
||||||
|
) -> list[Like]:
|
||||||
|
"""Like events for a user ingested in the half-open window ``(since,
|
||||||
|
until]`` (by ``synced_at``, the server-side sync key — so events pushed
|
||||||
|
with an older ``created_at`` still surface), oldest first. ``since=None``
|
||||||
|
returns everything up to ``until``."""
|
||||||
|
stmt = select(LikeModel).where(
|
||||||
|
LikeModel.user_id == user_id, LikeModel.synced_at <= until
|
||||||
|
)
|
||||||
|
if since is not None:
|
||||||
|
stmt = stmt.where(LikeModel.synced_at > since)
|
||||||
|
stmt = stmt.order_by(LikeModel.synced_at)
|
||||||
|
rows = (await self._session.execute(stmt)).scalars().all()
|
||||||
|
return [_to_entity(r) for r in rows]
|
||||||
|
|
||||||
|
def _latest_events_sq(
|
||||||
|
self, user_id: uuid.UUID, track_ids: list[uuid.UUID] | None
|
||||||
|
) -> Subquery:
|
||||||
|
"""The latest like event per ``track_id`` for a user, as a subquery.
|
||||||
|
|
||||||
|
``DISTINCT ON (track_id)`` with a deterministic tiebreaker (``created_at``
|
||||||
|
then ``id``) picks exactly one row per track even when two events share an
|
||||||
|
identical ``created_at`` — likes carry a client-supplied timestamp from
|
||||||
|
offline sync, so ties are realistic and a plain ``max()``+equality-join
|
||||||
|
would return both rows (double-counting the track)."""
|
||||||
|
stmt = select(
|
||||||
|
LikeModel.track_id,
|
||||||
|
LikeModel.value.label("value"),
|
||||||
|
LikeModel.created_at.label("created_at"),
|
||||||
|
).where(LikeModel.user_id == user_id)
|
||||||
|
if track_ids is not None:
|
||||||
|
stmt = stmt.where(LikeModel.track_id.in_(track_ids))
|
||||||
|
return (
|
||||||
|
stmt.distinct(LikeModel.track_id)
|
||||||
|
.order_by(
|
||||||
|
LikeModel.track_id,
|
||||||
|
LikeModel.created_at.desc(),
|
||||||
|
LikeModel.id.desc(),
|
||||||
|
)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
async def get_latest_state(
|
async def get_latest_state(
|
||||||
self, *, user_id: uuid.UUID, track_ids: list[uuid.UUID]
|
self, *, user_id: uuid.UUID, track_ids: list[uuid.UUID]
|
||||||
) -> list[Like]:
|
) -> list[Like]:
|
||||||
if not track_ids:
|
if not track_ids:
|
||||||
return []
|
return []
|
||||||
# Subquery: max(created_at) per track for this user
|
|
||||||
max_sq = (
|
|
||||||
select(
|
|
||||||
LikeModel.track_id,
|
|
||||||
func.max(LikeModel.created_at).label("latest"),
|
|
||||||
)
|
|
||||||
.where(LikeModel.user_id == user_id, LikeModel.track_id.in_(track_ids))
|
|
||||||
.group_by(LikeModel.track_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
rows = (
|
rows = (
|
||||||
(
|
(
|
||||||
await self._session.execute(
|
await self._session.execute(
|
||||||
select(LikeModel)
|
select(LikeModel)
|
||||||
.join(
|
.where(
|
||||||
max_sq,
|
LikeModel.user_id == user_id,
|
||||||
(LikeModel.track_id == max_sq.c.track_id)
|
LikeModel.track_id.in_(track_ids),
|
||||||
& (LikeModel.created_at == max_sq.c.latest),
|
)
|
||||||
|
.distinct(LikeModel.track_id)
|
||||||
|
.order_by(
|
||||||
|
LikeModel.track_id,
|
||||||
|
LikeModel.created_at.desc(),
|
||||||
|
LikeModel.id.desc(),
|
||||||
)
|
)
|
||||||
.where(LikeModel.user_id == user_id)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
@@ -91,31 +162,14 @@ class SqlAlchemyLikeRepository:
|
|||||||
self, *, user_id: uuid.UUID, limit: int, offset: int
|
self, *, user_id: uuid.UUID, limit: int, offset: int
|
||||||
) -> list[Track]:
|
) -> list[Track]:
|
||||||
# Tracks where the latest like event has value='like', ordered by like time desc
|
# Tracks where the latest like event has value='like', ordered by like time desc
|
||||||
max_sq = (
|
latest_sq = self._latest_events_sq(user_id, None)
|
||||||
select(
|
|
||||||
LikeModel.track_id,
|
|
||||||
func.max(LikeModel.created_at).label("latest"),
|
|
||||||
)
|
|
||||||
.where(LikeModel.user_id == user_id)
|
|
||||||
.group_by(LikeModel.track_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
liked_sq = (
|
|
||||||
select(LikeModel.track_id, LikeModel.created_at)
|
|
||||||
.join(
|
|
||||||
max_sq,
|
|
||||||
(LikeModel.track_id == max_sq.c.track_id)
|
|
||||||
& (LikeModel.created_at == max_sq.c.latest),
|
|
||||||
)
|
|
||||||
.where(LikeModel.user_id == user_id, LikeModel.value == "like")
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
rows = (
|
rows = (
|
||||||
(
|
(
|
||||||
await self._session.execute(
|
await self._session.execute(
|
||||||
select(TrackModel)
|
select(TrackModel)
|
||||||
.join(liked_sq, TrackModel.id == liked_sq.c.track_id)
|
.join(latest_sq, TrackModel.id == latest_sq.c.track_id)
|
||||||
.order_by(liked_sq.c.created_at.desc())
|
.where(latest_sq.c.value == "like")
|
||||||
|
.order_by(latest_sq.c.created_at.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
)
|
)
|
||||||
@@ -126,25 +180,11 @@ class SqlAlchemyLikeRepository:
|
|||||||
return [_track_to_entity(r) for r in rows]
|
return [_track_to_entity(r) for r in rows]
|
||||||
|
|
||||||
async def count_liked_tracks(self, *, user_id: uuid.UUID) -> int:
|
async def count_liked_tracks(self, *, user_id: uuid.UUID) -> int:
|
||||||
max_sq = (
|
latest_sq = self._latest_events_sq(user_id, None)
|
||||||
select(
|
|
||||||
LikeModel.track_id,
|
|
||||||
func.max(LikeModel.created_at).label("latest"),
|
|
||||||
)
|
|
||||||
.where(LikeModel.user_id == user_id)
|
|
||||||
.group_by(LikeModel.track_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
liked_sq = (
|
|
||||||
select(LikeModel.track_id)
|
|
||||||
.join(
|
|
||||||
max_sq,
|
|
||||||
(LikeModel.track_id == max_sq.c.track_id)
|
|
||||||
& (LikeModel.created_at == max_sq.c.latest),
|
|
||||||
)
|
|
||||||
.where(LikeModel.user_id == user_id, LikeModel.value == "like")
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
await self._session.execute(select(func.count()).select_from(liked_sq))
|
await self._session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(latest_sq)
|
||||||
|
.where(latest_sq.c.value == "like")
|
||||||
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Lyrics repository — adapter over ``AsyncSession``.
|
||||||
|
|
||||||
|
One cached row per track (``track_id`` unique). ``upsert`` refreshes the row and
|
||||||
|
bumps ``fetched_at`` so the service's TTL is measured from the last fetch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.entities.lyrics import Lyrics
|
||||||
|
from app.infrastructure.db.models.lyrics import LyricsModel
|
||||||
|
|
||||||
|
|
||||||
|
def _to_entity(row: LyricsModel) -> Lyrics:
|
||||||
|
return Lyrics(
|
||||||
|
track_id=row.track_id,
|
||||||
|
synced=row.synced,
|
||||||
|
plain=row.plain,
|
||||||
|
source=row.source,
|
||||||
|
status=row.status,
|
||||||
|
fetched_at=row.fetched_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SqlAlchemyLyricsRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get(self, track_id: uuid.UUID) -> Lyrics | None:
|
||||||
|
row = await self._session.scalar(
|
||||||
|
select(LyricsModel).where(LyricsModel.track_id == track_id)
|
||||||
|
)
|
||||||
|
return _to_entity(row) if row is not None else None
|
||||||
|
|
||||||
|
async def upsert(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
track_id: uuid.UUID,
|
||||||
|
synced: str | None,
|
||||||
|
plain: str | None,
|
||||||
|
source: str | None,
|
||||||
|
status: str,
|
||||||
|
) -> Lyrics:
|
||||||
|
values = {
|
||||||
|
"track_id": track_id,
|
||||||
|
"synced": synced,
|
||||||
|
"plain": plain,
|
||||||
|
"source": source,
|
||||||
|
"status": status,
|
||||||
|
"fetched_at": func.now(),
|
||||||
|
}
|
||||||
|
stmt = (
|
||||||
|
pg_insert(LyricsModel)
|
||||||
|
.values(**values)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=[LyricsModel.track_id],
|
||||||
|
set_={
|
||||||
|
"synced": synced,
|
||||||
|
"plain": plain,
|
||||||
|
"source": source,
|
||||||
|
"status": status,
|
||||||
|
"fetched_at": func.now(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.returning(LyricsModel)
|
||||||
|
)
|
||||||
|
row = (await self._session.scalars(stmt)).one()
|
||||||
|
await self._session.flush()
|
||||||
|
return _to_entity(row)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Playlist repository — adapter over ``AsyncSession``."""
|
"""Playlist repository — adapter over ``AsyncSession``."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
@@ -37,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,
|
||||||
)
|
)
|
||||||
@@ -134,9 +139,22 @@ class SqlAlchemyPlaylistRepository:
|
|||||||
async def get_track_total(self, playlist_id: uuid.UUID) -> int:
|
async def get_track_total(self, playlist_id: uuid.UUID) -> int:
|
||||||
return await self.track_count(playlist_id)
|
return await self.track_count(playlist_id)
|
||||||
|
|
||||||
|
async def has_track(self, playlist_id: uuid.UUID, track_id: uuid.UUID) -> bool:
|
||||||
|
row = (
|
||||||
|
await self._session.execute(
|
||||||
|
select(PlaylistTrackModel.id).where(
|
||||||
|
PlaylistTrackModel.playlist_id == playlist_id,
|
||||||
|
PlaylistTrackModel.track_id == track_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
async def add_track(
|
async def add_track(
|
||||||
self, playlist_id: uuid.UUID, track_id: uuid.UUID, *, position: float
|
self, playlist_id: uuid.UUID, track_id: uuid.UUID, *, position: float
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if await self.has_track(playlist_id, track_id):
|
||||||
|
return
|
||||||
row = PlaylistTrackModel(playlist_id=playlist_id, track_id=track_id, position=position)
|
row = PlaylistTrackModel(playlist_id=playlist_id, track_id=track_id, position=position)
|
||||||
self._session.add(row)
|
self._session.add(row)
|
||||||
playlist = await self._session.get(PlaylistModel, playlist_id)
|
playlist = await self._session.get(PlaylistModel, playlist_id)
|
||||||
@@ -144,6 +162,26 @@ class SqlAlchemyPlaylistRepository:
|
|||||||
playlist.version = playlist.version + 1
|
playlist.version = playlist.version + 1
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
|
|
||||||
|
async def reorder_tracks(
|
||||||
|
self, playlist_id: uuid.UUID, ordered_track_ids: list[uuid.UUID]
|
||||||
|
) -> None:
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await self._session.execute(
|
||||||
|
select(PlaylistTrackModel).where(PlaylistTrackModel.playlist_id == playlist_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_track_id = {row.track_id: row for row in rows}
|
||||||
|
for position, track_id in enumerate(ordered_track_ids, start=1):
|
||||||
|
by_track_id[track_id].position = float(position)
|
||||||
|
playlist = await self._session.get(PlaylistModel, playlist_id)
|
||||||
|
if playlist is not None:
|
||||||
|
playlist.version = playlist.version + 1
|
||||||
|
await self._session.flush()
|
||||||
|
|
||||||
async def remove_track(self, playlist_id: uuid.UUID, track_id: uuid.UUID) -> None:
|
async def remove_track(self, playlist_id: uuid.UUID, track_id: uuid.UUID) -> None:
|
||||||
row = (
|
row = (
|
||||||
await self._session.execute(
|
await self._session.execute(
|
||||||
@@ -170,6 +208,41 @@ class SqlAlchemyPlaylistRepository:
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
return float(result) if result is not None else 0.0
|
return float(result) if result is not None else 0.0
|
||||||
|
|
||||||
|
async def get_cover_path(self, playlist_id: uuid.UUID) -> str | None:
|
||||||
|
"""The playlist's stored cover key, or ``None`` (missing playlist or no
|
||||||
|
cover). Read directly — the entity doesn't carry the storage key."""
|
||||||
|
return (
|
||||||
|
await self._session.execute(
|
||||||
|
select(PlaylistModel.cover_path).where(PlaylistModel.id == playlist_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_changed_since(
|
||||||
|
self, *, owner_id: uuid.UUID, since: dt.datetime | None, until: dt.datetime
|
||||||
|
) -> list[Playlist]:
|
||||||
|
"""A user's playlists changed in the window ``(since, until]`` (metadata
|
||||||
|
or membership — every mutation bumps ``updated_at``/``version``). Defined
|
||||||
|
before ``list`` (name-shadowing)."""
|
||||||
|
stmt = select(PlaylistModel).where(
|
||||||
|
PlaylistModel.owner_id == owner_id, PlaylistModel.updated_at <= until
|
||||||
|
)
|
||||||
|
if since is not None:
|
||||||
|
stmt = stmt.where(PlaylistModel.updated_at > since)
|
||||||
|
stmt = stmt.order_by(PlaylistModel.updated_at)
|
||||||
|
rows = (await self._session.execute(stmt)).scalars().all()
|
||||||
|
return [_to_entity(r) for r in rows]
|
||||||
|
|
||||||
|
async def track_ids(self, playlist_id: uuid.UUID) -> list[uuid.UUID]:
|
||||||
|
"""Ordered track ids of a playlist (by position) — for sync payloads."""
|
||||||
|
rows = (
|
||||||
|
await self._session.execute(
|
||||||
|
select(PlaylistTrackModel.track_id)
|
||||||
|
.where(PlaylistTrackModel.playlist_id == playlist_id)
|
||||||
|
.order_by(PlaylistTrackModel.position)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return list(rows)
|
||||||
|
|
||||||
# list must come after methods using list[...] in signatures (builtin name shadowing)
|
# list must come after methods using list[...] in signatures (builtin name shadowing)
|
||||||
async def list(self, *, owner_id: uuid.UUID, limit: int, offset: int) -> list[Playlist]:
|
async def list(self, *, owner_id: uuid.UUID, limit: int, offset: int) -> list[Playlist]:
|
||||||
rows = (
|
rows = (
|
||||||
|
|||||||
@@ -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 case, func, or_, 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,
|
||||||
)
|
)
|
||||||
@@ -39,6 +46,16 @@ class SqlAlchemyTrackRepository:
|
|||||||
row = await self._session.get(TrackModel, track_id)
|
row = await self._session.get(TrackModel, track_id)
|
||||||
return _to_entity(row) if row is not None else None
|
return _to_entity(row) if row is not None else None
|
||||||
|
|
||||||
|
async def get_many(self, ids: list[uuid.UUID]) -> list[Track]:
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
rows = (
|
||||||
|
(await self._session.execute(select(TrackModel).where(TrackModel.id.in_(ids))))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_to_entity(r) for r in rows]
|
||||||
|
|
||||||
async def get_by_source(self, source: str, source_id: str) -> Track | None:
|
async def get_by_source(self, source: str, source_id: str) -> Track | None:
|
||||||
row = (
|
row = (
|
||||||
await self._session.execute(
|
await self._session.execute(
|
||||||
@@ -56,13 +73,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,12 +93,38 @@ 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:
|
||||||
@@ -102,12 +146,192 @@ class SqlAlchemyTrackRepository:
|
|||||||
).all()
|
).all()
|
||||||
return [(row.genre, row.cnt) for row in rows]
|
return [(row.genre, row.cnt) for row in rows]
|
||||||
|
|
||||||
|
async def list_similar(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
genre: str | None,
|
||||||
|
artist_id: uuid.UUID,
|
||||||
|
exclude_ids: list[uuid.UUID],
|
||||||
|
limit: int,
|
||||||
|
) -> list[Track]:
|
||||||
|
# Rank a same-genre hit above a same-artist hit; shuffle within a tier so
|
||||||
|
# the mix varies. Only playable (locally-stored) tracks are candidates.
|
||||||
|
if genre is not None:
|
||||||
|
match = or_(TrackModel.genre == genre, TrackModel.artist_id == artist_id)
|
||||||
|
score = case((TrackModel.genre == genre, 2), else_=0) + case(
|
||||||
|
(TrackModel.artist_id == artist_id, 1), else_=0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
match = TrackModel.artist_id == artist_id
|
||||||
|
score = case((TrackModel.artist_id == artist_id, 1), else_=0)
|
||||||
|
|
||||||
|
stmt = select(TrackModel).where(TrackModel.storage_uri.is_not(None), match)
|
||||||
|
if exclude_ids:
|
||||||
|
stmt = stmt.where(TrackModel.id.not_in(exclude_ids))
|
||||||
|
stmt = stmt.order_by(score.desc(), func.random()).limit(limit)
|
||||||
|
rows = (await self._session.execute(stmt)).scalars().all()
|
||||||
|
return [_to_entity(r) for r in rows]
|
||||||
|
|
||||||
|
async def sample_playable(
|
||||||
|
self, *, exclude_ids: list[uuid.UUID], limit: int
|
||||||
|
) -> list[Track]:
|
||||||
|
stmt = select(TrackModel).where(TrackModel.storage_uri.is_not(None))
|
||||||
|
if exclude_ids:
|
||||||
|
stmt = stmt.where(TrackModel.id.not_in(exclude_ids))
|
||||||
|
stmt = stmt.order_by(func.random()).limit(limit)
|
||||||
|
rows = (await self._session.execute(stmt)).scalars().all()
|
||||||
|
return [_to_entity(r) for r 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 find_duplicate_groups(self) -> list[tuple[str, list[Track]]]:
|
||||||
|
"""Tracks that share an ``acoustid_fingerprint`` (the dedup key), grouped
|
||||||
|
by it — only fingerprints with more than one track. Empty when clean.
|
||||||
|
Defined before ``list`` for the same name-shadowing reason as ``genres``."""
|
||||||
|
dup_fps = (
|
||||||
|
select(TrackModel.acoustid_fingerprint)
|
||||||
|
.where(TrackModel.acoustid_fingerprint.is_not(None))
|
||||||
|
.group_by(TrackModel.acoustid_fingerprint)
|
||||||
|
.having(func.count(TrackModel.id) > 1)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await self._session.execute(
|
||||||
|
select(TrackModel)
|
||||||
|
.where(TrackModel.acoustid_fingerprint.in_(dup_fps))
|
||||||
|
.order_by(TrackModel.acoustid_fingerprint, TrackModel.created_at)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
groups: dict[str, list[Track]] = {}
|
||||||
|
for row in rows:
|
||||||
|
fingerprint = row.acoustid_fingerprint
|
||||||
|
assert fingerprint is not None # filtered to non-null above
|
||||||
|
groups.setdefault(fingerprint, []).append(_to_entity(row))
|
||||||
|
return list(groups.items())
|
||||||
|
|
||||||
|
async def list_by_metadata_status(
|
||||||
|
self, status: str, *, limit: int, offset: int
|
||||||
|
) -> list[Track]:
|
||||||
|
"""Tracks in a given ``metadata_status`` (e.g. ``pending``/``failed``),
|
||||||
|
newest first. Defined before ``list`` (name-shadowing)."""
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await self._session.execute(
|
||||||
|
select(TrackModel)
|
||||||
|
.where(TrackModel.metadata_status == status)
|
||||||
|
.order_by(TrackModel.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_to_entity(r) for r in rows]
|
||||||
|
|
||||||
|
async def all_storage_refs(self) -> list[tuple[uuid.UUID, str]]:
|
||||||
|
"""``(id, storage_uri)`` for every *local* track — for the cleanup
|
||||||
|
worker's filesystem reconciliation. Remote placeholders have no local
|
||||||
|
file (``availability != local``) and are skipped. No entity hydration."""
|
||||||
|
rows = (
|
||||||
|
await self._session.execute(
|
||||||
|
select(TrackModel.id, TrackModel.storage_uri).where(
|
||||||
|
TrackModel.availability == TrackAvailability.LOCAL.value,
|
||||||
|
TrackModel.storage_uri.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [(row.id, row.storage_uri) for row in rows]
|
||||||
|
|
||||||
|
async def count_by_metadata_status(self, status: str) -> int:
|
||||||
|
return (
|
||||||
|
await self._session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(TrackModel)
|
||||||
|
.where(TrackModel.metadata_status == status)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
async def list_changed_since(
|
||||||
|
self, *, since: dt.datetime | None, until: dt.datetime
|
||||||
|
) -> list[Track]:
|
||||||
|
"""Catalogue tracks changed in the window ``(since, until]`` (by
|
||||||
|
``updated_at``), oldest first — the delta a client caches for offline use.
|
||||||
|
Defined before ``list`` (name-shadowing)."""
|
||||||
|
stmt = select(TrackModel).where(TrackModel.updated_at <= until)
|
||||||
|
if since is not None:
|
||||||
|
stmt = stmt.where(TrackModel.updated_at > since)
|
||||||
|
stmt = stmt.order_by(TrackModel.updated_at)
|
||||||
|
rows = (await self._session.execute(stmt)).scalars().all()
|
||||||
|
return [_to_entity(r) for r in 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,
|
||||||
@@ -118,6 +342,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}%"))
|
||||||
|
|
||||||
@@ -142,12 +368,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()
|
||||||
@@ -159,6 +388,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:
|
||||||
@@ -169,7 +401,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()
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""User-settings repository — adapter over ``AsyncSession``."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.entities.settings import UserSettings
|
||||||
|
from app.infrastructure.db.models.user_settings import UserSettingsModel
|
||||||
|
|
||||||
|
|
||||||
|
def _to_entity(row: UserSettingsModel) -> UserSettings:
|
||||||
|
return UserSettings(
|
||||||
|
user_id=row.user_id,
|
||||||
|
theme=row.theme,
|
||||||
|
stream_quality=row.stream_quality,
|
||||||
|
scrobble_enabled=row.scrobble_enabled,
|
||||||
|
scrobble_provider=row.scrobble_provider,
|
||||||
|
scrobble_username=row.scrobble_username,
|
||||||
|
scrobble_session_key_enc=row.scrobble_session_key_enc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SqlAlchemyUserSettingsRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get(self, user_id: uuid.UUID) -> UserSettings | None:
|
||||||
|
row = await self._session.get(UserSettingsModel, user_id)
|
||||||
|
return _to_entity(row) if row is not None else None
|
||||||
|
|
||||||
|
async def upsert(self, settings: UserSettings) -> UserSettings:
|
||||||
|
"""Create or replace the caller's settings row with ``settings`` in full.
|
||||||
|
The service merges partial updates against current values before calling
|
||||||
|
this, so the write always carries the complete desired state."""
|
||||||
|
row = await self._session.get(UserSettingsModel, settings.user_id)
|
||||||
|
if row is None:
|
||||||
|
row = UserSettingsModel(user_id=settings.user_id)
|
||||||
|
self._session.add(row)
|
||||||
|
row.theme = settings.theme
|
||||||
|
row.stream_quality = settings.stream_quality
|
||||||
|
row.scrobble_enabled = settings.scrobble_enabled
|
||||||
|
row.scrobble_provider = settings.scrobble_provider
|
||||||
|
row.scrobble_username = settings.scrobble_username
|
||||||
|
row.scrobble_session_key_enc = settings.scrobble_session_key_enc
|
||||||
|
await self._session.flush()
|
||||||
|
await self._session.refresh(row)
|
||||||
|
return _to_entity(row)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Metadata-enrichment adapters: tag reader, fingerprinter, AcoustID client."""
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""LrclibHttpClient — fetches lyrics from LRCLIB (plan §6.7).
|
||||||
|
|
||||||
|
LRCLIB is a free, keyless lyrics database. ``/api/get`` does an exact match on
|
||||||
|
artist+track+album+duration; if that misses we fall back to ``/api/search`` and
|
||||||
|
take the best-scoring hit. Graceful degradation: any network/parse error →
|
||||||
|
``fetch`` returns ``None`` (the service then caches a ``not_found``), never
|
||||||
|
raising. No API key is needed, so this provider is always "available".
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.domain.entities.lyrics import LyricsResult
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
_BASE_URL = "https://lrclib.net"
|
||||||
|
_TIMEOUT_SECONDS = 10.0
|
||||||
|
_SOURCE = "lrclib"
|
||||||
|
|
||||||
|
|
||||||
|
class LrclibHttpClient:
|
||||||
|
"""Implements :class:`app.domain.ports.LyricsProvider`."""
|
||||||
|
|
||||||
|
def __init__(self, *, user_agent: str, base_url: str = _BASE_URL) -> None:
|
||||||
|
self._user_agent = user_agent
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
|
||||||
|
async def fetch(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
artist: str,
|
||||||
|
title: str,
|
||||||
|
album: str | None,
|
||||||
|
duration_seconds: int | None,
|
||||||
|
) -> LyricsResult | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=_TIMEOUT_SECONDS,
|
||||||
|
headers={"User-Agent": self._user_agent},
|
||||||
|
base_url=self._base_url,
|
||||||
|
) as client:
|
||||||
|
hit = await self._get(client, artist, title, album, duration_seconds)
|
||||||
|
if hit is None:
|
||||||
|
hit = await self._search(client, artist, title)
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
log.warning("lrclib.fetch_failed", error=str(exc))
|
||||||
|
return None
|
||||||
|
return hit
|
||||||
|
|
||||||
|
async def _get(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
artist: str,
|
||||||
|
title: str,
|
||||||
|
album: str | None,
|
||||||
|
duration_seconds: int | None,
|
||||||
|
) -> LyricsResult | None:
|
||||||
|
"""Exact match via ``/api/get`` (404 when nothing matches exactly)."""
|
||||||
|
params = {"artist_name": artist, "track_name": title}
|
||||||
|
if album:
|
||||||
|
params["album_name"] = album
|
||||||
|
if duration_seconds is not None:
|
||||||
|
params["duration"] = str(duration_seconds)
|
||||||
|
resp = await client.get("/api/get", params=params)
|
||||||
|
if resp.status_code == httpx.codes.NOT_FOUND:
|
||||||
|
return None
|
||||||
|
resp.raise_for_status()
|
||||||
|
return _to_result(resp.json())
|
||||||
|
|
||||||
|
async def _search(
|
||||||
|
self, client: httpx.AsyncClient, artist: str, title: str
|
||||||
|
) -> LyricsResult | None:
|
||||||
|
"""Fuzzy fallback via ``/api/search`` — take the first usable hit."""
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/search", params={"artist_name": artist, "track_name": title}
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
results = resp.json()
|
||||||
|
if not isinstance(results, list):
|
||||||
|
return None
|
||||||
|
for item in results:
|
||||||
|
result = _to_result(item)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_result(payload: object) -> LyricsResult | None:
|
||||||
|
"""Map an LRCLIB record to a ``LyricsResult``. Instrumental tracks and empty
|
||||||
|
records yield ``None`` (nothing worth caching as "found")."""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
if payload.get("instrumental"):
|
||||||
|
return None
|
||||||
|
synced = payload.get("syncedLyrics") or None
|
||||||
|
plain = payload.get("plainLyrics") or None
|
||||||
|
if synced is None and plain is None:
|
||||||
|
return None
|
||||||
|
return LyricsResult(synced=synced, plain=plain, source=_SOURCE)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""ML/recommender adapters (plan §6.5). ML is optional — see Recommender port."""
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Recommender adapters (plan §6.5).
|
||||||
|
|
||||||
|
``NullRecommender`` is the default: no ML service, so every method reports
|
||||||
|
unavailable and the ``RecommendationService`` uses its metadata fallback. When
|
||||||
|
an embedding service exists, wire ``RemoteRecommender`` (skeleton below) to
|
||||||
|
``ML_SERVICE_URL`` — its exact request/response contract is TODO pending that
|
||||||
|
service. Both keep the invariant: ML is optional, never a hard dependency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NullRecommender:
|
||||||
|
"""No ML configured — always unavailable, always ``None`` (→ fallback)."""
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def similar_track_ids(
|
||||||
|
self, track_id: uuid.UUID, *, limit: int, exclude_ids: list[uuid.UUID]
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def similar_artist_ids(
|
||||||
|
self, artist_id: uuid.UUID, *, limit: int
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def radio_track_ids(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
seed_track_id: uuid.UUID | None,
|
||||||
|
exploration: float,
|
||||||
|
limit: int,
|
||||||
|
exclude_ids: list[uuid.UUID],
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteRecommender:
|
||||||
|
"""HTTP client for an external embedding/recommender service.
|
||||||
|
|
||||||
|
TODO: the request/response schema below is a placeholder — align it with the
|
||||||
|
real ML service once its contract is known. Until then this stays unused
|
||||||
|
(``deps`` wires ``NullRecommender``). Every call is defensive: any error or a
|
||||||
|
malformed body returns ``None`` so the service degrades to metadata, matching
|
||||||
|
the graceful-degradation invariant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str) -> None:
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _post_ids(self, path: str, payload: dict[str, object]) -> list[uuid.UUID] | None:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS) as client:
|
||||||
|
resp = await client.post(f"{self._base_url}{path}", json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
ids = data.get("track_ids") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(ids, list):
|
||||||
|
return None
|
||||||
|
return [uuid.UUID(str(i)) for i in ids]
|
||||||
|
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
||||||
|
log.warning("recommender.remote_failed", path=path, error=str(exc))
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def similar_track_ids(
|
||||||
|
self, track_id: uuid.UUID, *, limit: int, exclude_ids: list[uuid.UUID]
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
return await self._post_ids(
|
||||||
|
"/similar/tracks",
|
||||||
|
{"track_id": str(track_id), "limit": limit,
|
||||||
|
"exclude": [str(i) for i in exclude_ids]},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def similar_artist_ids(
|
||||||
|
self, artist_id: uuid.UUID, *, limit: int
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
# Artist recommendations aren't part of the placeholder track contract.
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def radio_track_ids(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
seed_track_id: uuid.UUID | None,
|
||||||
|
exploration: float,
|
||||||
|
limit: int,
|
||||||
|
exclude_ids: list[uuid.UUID],
|
||||||
|
) -> list[uuid.UUID] | None:
|
||||||
|
return await self._post_ids(
|
||||||
|
"/radio",
|
||||||
|
{
|
||||||
|
"seed_track_id": str(seed_track_id) if seed_track_id else None,
|
||||||
|
"exploration": exploration,
|
||||||
|
"limit": limit,
|
||||||
|
"exclude": [str(i) for i in exclude_ids],
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""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 indexables(self) -> list[IndexableSource]:
|
||||||
|
"""Every registered source that can be indexed (for a full re-scan)."""
|
||||||
|
return [cast(IndexableSource, b) for b in self._by_name.values() if hasattr(b, "scan")]
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user