Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb7827d09c | |||
| c5a473fddf | |||
| 9a78cf5261 | |||
| d16c6085c9 | |||
| ed77acf0fe | |||
| 591a938e71 | |||
| 313af3a070 | |||
| 8271de34eb | |||
| 048854f92a | |||
| c47242aa3a | |||
| a263272935 | |||
| df580578f6 | |||
| fb5ce3c708 | |||
| e45e578f54 | |||
| 58b98ab5ed | |||
| 78007461e1 | |||
| ea880edd57 | |||
| fa23568214 | |||
| 636820afb8 | |||
| 63c7d05eca |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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")
|
||||||
+130
-1
@@ -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,16 +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.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_enrich
|
from app.workers.queue import enqueue_download, enqueue_enrich, enqueue_materialize
|
||||||
|
|
||||||
|
|
||||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||||
@@ -105,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) ---------------------------------------------
|
||||||
@@ -132,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,
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-5
@@ -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
|
||||||
@@ -16,12 +17,17 @@ from fastapi.responses import Response, StreamingResponse
|
|||||||
from app.api.covers import resolve_album_for_track, stream_cover
|
from app.api.covers import resolve_album_for_track, stream_cover
|
||||||
from app.api.deps import (
|
from app.api.deps import (
|
||||||
AlbumRepoDep,
|
AlbumRepoDep,
|
||||||
|
ArtistRepoDep,
|
||||||
FileStorageDep,
|
FileStorageDep,
|
||||||
|
LyricsServiceDep,
|
||||||
StreamingServiceDep,
|
StreamingServiceDep,
|
||||||
|
SubsonicFormat,
|
||||||
SubsonicUser,
|
SubsonicUser,
|
||||||
TrackRepoDep,
|
TrackRepoDep,
|
||||||
)
|
)
|
||||||
|
from app.api.rest.envelope import subsonic_response
|
||||||
from app.api.rest.ids import IdKind, decode_track, parse
|
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.entities.album import Album
|
||||||
from app.domain.errors import NotFoundError, StorageError
|
from app.domain.errors import NotFoundError, StorageError
|
||||||
|
|
||||||
@@ -65,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}"',
|
||||||
@@ -99,6 +105,35 @@ async def get_cover_art(
|
|||||||
if album is not None and album.cover_path:
|
if album is not None and album.cover_path:
|
||||||
try:
|
try:
|
||||||
return await stream_cover(storage, album.cover_path)
|
return await stream_cover(storage, album.cover_path)
|
||||||
except NotFoundError, StorageError:
|
except (NotFoundError, StorageError):
|
||||||
pass
|
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
|
||||||
@@ -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,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,11 +16,15 @@ 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
|
metadata_error: str | None
|
||||||
enriched_at: dt.datetime | None
|
enriched_at: dt.datetime | None
|
||||||
|
availability: str
|
||||||
source: str
|
source: str
|
||||||
has_cover: bool
|
has_cover: bool
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
@@ -28,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
|
||||||
+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."
|
||||||
|
)
|
||||||
|
|||||||
+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)
|
||||||
|
|||||||
+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")
|
||||||
|
|||||||
+23
-10
@@ -1,14 +1,13 @@
|
|||||||
"""External source endpoints: enumerate sources and trigger imports.
|
"""External source endpoints: enumerate sources, search, and trigger imports.
|
||||||
|
|
||||||
Listing/health are read-only (any authenticated user). Scanning a source is an
|
Listing/health/search are read-only (any authenticated user). Scanning a source
|
||||||
admin action and runs in a worker — the endpoint only enqueues it.
|
is an admin action and runs in a worker — the endpoint only enqueues it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from app.api.deps import CurrentUser, SourceRegistryDep, SuperUser, TrackRepoDep
|
||||||
|
from app.api.schemas.external_search import ExternalSearchResponse, ExternalSearchResultOut
|
||||||
from app.api.deps import CurrentUser, SourceRegistryDep, SuperUser
|
|
||||||
from app.api.schemas.source import ScanResponse, SourceHealthOut, SourceInfoOut
|
from app.api.schemas.source import ScanResponse, SourceHealthOut, SourceInfoOut
|
||||||
from app.domain.errors import DependencyUnavailableError
|
from app.domain.errors import DependencyUnavailableError
|
||||||
from app.workers.queue import enqueue
|
from app.workers.queue import enqueue
|
||||||
@@ -39,6 +38,20 @@ async def source_health(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{source}/search")
|
@router.get("/{source}/search")
|
||||||
async def search_source(source: str, _: CurrentUser) -> Any:
|
async def search_source(
|
||||||
# Search is for fetch-style sources (youtube, …) — not yet implemented.
|
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,
|
||||||
|
)
|
||||||
|
|||||||
+217
-11
@@ -1,8 +1,9 @@
|
|||||||
"""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 fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
@@ -12,15 +13,33 @@ from app.api.deps import (
|
|||||||
ArtistRepoDep,
|
ArtistRepoDep,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
FileStorageDep,
|
FileStorageDep,
|
||||||
|
LyricsServiceDep,
|
||||||
|
MetadataServiceDep,
|
||||||
|
RecommendationServiceDep,
|
||||||
|
RemoteLibraryServiceDep,
|
||||||
StreamUser,
|
StreamUser,
|
||||||
TrackRepoDep,
|
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
|
from app.workers.queue import enqueue, enqueue_transcode
|
||||||
|
|
||||||
router = APIRouter(prefix="/tracks", tags=["tracks"])
|
router = APIRouter(prefix="/tracks", tags=["tracks"])
|
||||||
|
|
||||||
@@ -41,9 +60,13 @@ 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,
|
metadata_error=t.metadata_error,
|
||||||
enriched_at=t.enriched_at,
|
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),
|
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,
|
||||||
@@ -61,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),
|
||||||
@@ -70,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})
|
||||||
@@ -86,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,
|
||||||
@@ -143,16 +219,71 @@ 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")
|
||||||
@@ -187,8 +318,83 @@ async def enrich_metadata(
|
|||||||
|
|
||||||
|
|
||||||
@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,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,
|
||||||
|
)
|
||||||
@@ -79,9 +79,13 @@ class MetadataEnrichmentService:
|
|||||||
if track.metadata_status == "manual":
|
if track.metadata_status == "manual":
|
||||||
log.info("enrich_skip_manual", track_id=str(track_id))
|
log.info("enrich_skip_manual", track_id=str(track_id))
|
||||||
return EnrichmentResult(track_id=track_id, status="skipped")
|
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(track.storage_uri)
|
tags = await self._read_local(storage_uri)
|
||||||
match = await self._identify(track.storage_uri)
|
match = await self._identify(storage_uri)
|
||||||
|
|
||||||
# Merge order is tag-first by default — embedded tags fix the common
|
# Merge order is tag-first by default — embedded tags fix the common
|
||||||
# well-tagged offline case. But a *high-confidence* AcoustID match is the
|
# well-tagged offline case. But a *high-confidence* AcoustID match is the
|
||||||
@@ -125,7 +129,7 @@ class MetadataEnrichmentService:
|
|||||||
if album is not None:
|
if album is not None:
|
||||||
await self._resolve_cover(
|
await self._resolve_cover(
|
||||||
album,
|
album,
|
||||||
storage_uri=track.storage_uri,
|
storage_uri=storage_uri,
|
||||||
release_group_mbid=match.release_group_mbid if match else None,
|
release_group_mbid=match.release_group_mbid if match else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,6 +166,31 @@ class MetadataEnrichmentService:
|
|||||||
return "No metadata match: AcoustID lookup is unavailable (no API key)."
|
return "No metadata match: AcoustID lookup is unavailable (no API key)."
|
||||||
return "No metadata match found in tags or AcoustID."
|
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:
|
async def _read_local(self, storage_uri: str) -> AudioTags | None:
|
||||||
try:
|
try:
|
||||||
async with self._storage.as_local_path(storage_uri) as path:
|
async with self._storage.as_local_path(storage_uri) as path:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -61,6 +61,17 @@ class Settings(BaseSettings):
|
|||||||
# admin-only (POST /admin/users). Registered users are never superusers.
|
# admin-only (POST /admin/users). Registered users are never superusers.
|
||||||
allow_registration: bool = True
|
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
|
||||||
# Subsonic app-password at rest. A Fernet key is derived from it; rotating
|
# Subsonic app-password at rest. A Fernet key is derived from it; rotating
|
||||||
@@ -70,7 +81,13 @@ 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
|
||||||
|
|
||||||
@@ -100,6 +117,11 @@ class Settings(BaseSettings):
|
|||||||
# deployments should set their own contact email; see
|
# deployments should set their own contact email; see
|
||||||
# ``musicbrainz_user_agent`` below for how it's used.
|
# ``musicbrainz_user_agent`` below for how it's used.
|
||||||
musicbrainz_owner_email: str | None = None
|
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 -------------------------------------------------------
|
# -- enrichment -------------------------------------------------------
|
||||||
@@ -119,6 +141,15 @@ 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"
|
||||||
|
|||||||
@@ -2,11 +2,17 @@
|
|||||||
|
|
||||||
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.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.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
|
||||||
|
|
||||||
@@ -16,7 +22,11 @@ __all__ = [
|
|||||||
"AudioTags",
|
"AudioTags",
|
||||||
"CoverArt",
|
"CoverArt",
|
||||||
"Credentials",
|
"Credentials",
|
||||||
|
"DiskUsage",
|
||||||
|
"DownloadJob",
|
||||||
"Fingerprint",
|
"Fingerprint",
|
||||||
|
"FormatBreakdown",
|
||||||
|
"LibraryStats",
|
||||||
"Like",
|
"Like",
|
||||||
"ObjectStat",
|
"ObjectStat",
|
||||||
"PlayHistoryEntry",
|
"PlayHistoryEntry",
|
||||||
|
|||||||
@@ -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,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,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,16 +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
|
metadata_error: str | None
|
||||||
enriched_at: dt.datetime | 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."""
|
||||||
|
|
||||||
|
|||||||
+275
-8
@@ -7,7 +7,7 @@ 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, Iterator
|
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
|
||||||
@@ -17,7 +17,10 @@ from app.domain.entities import (
|
|||||||
AudioTags,
|
AudioTags,
|
||||||
CoverArt,
|
CoverArt,
|
||||||
Credentials,
|
Credentials,
|
||||||
|
DiskUsage,
|
||||||
|
DownloadJob,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
|
LibraryStats,
|
||||||
Like,
|
Like,
|
||||||
ObjectStat,
|
ObjectStat,
|
||||||
PlayHistoryEntry,
|
PlayHistoryEntry,
|
||||||
@@ -26,10 +29,17 @@ from app.domain.entities import (
|
|||||||
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 SourceFile, SourceInfo
|
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: ...
|
||||||
@@ -48,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."""
|
||||||
|
|
||||||
@@ -98,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: ...
|
||||||
@@ -112,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,
|
||||||
@@ -119,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,
|
||||||
@@ -148,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,
|
||||||
@@ -196,6 +276,20 @@ class AlbumRepository(Protocol):
|
|||||||
year: int | None,
|
year: int | None,
|
||||||
musicbrainz_id: str | None,
|
musicbrainz_id: str | None,
|
||||||
) -> Album: ...
|
) -> 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 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]: ...
|
||||||
@@ -229,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]: ...
|
||||||
@@ -259,12 +374,73 @@ 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):
|
class SourceBackend(Protocol):
|
||||||
"""A registered source of tracks (mounted folder, YouTube, …).
|
"""A registered source of tracks (mounted folder, YouTube, …).
|
||||||
|
|
||||||
@@ -283,6 +459,29 @@ class IndexableSource(SourceBackend, Protocol):
|
|||||||
def scan(self) -> Iterator[SourceFile]: ...
|
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) -----------------------------------------
|
# -- metadata enrichment (plan §6.2) -----------------------------------------
|
||||||
class AudioTagReader(Protocol):
|
class AudioTagReader(Protocol):
|
||||||
"""Reads embedded tags from a local audio file. Returns ``None`` only when
|
"""Reads embedded tags from a local audio file. Returns ``None`` only when
|
||||||
@@ -302,10 +501,13 @@ class AudioFingerprinter(Protocol):
|
|||||||
class AcoustIdClient(Protocol):
|
class AcoustIdClient(Protocol):
|
||||||
"""AcoustID lookup. ``is_available`` is False without an API key (the whole
|
"""AcoustID lookup. ``is_available`` is False without an API key (the whole
|
||||||
fingerprint path is then skipped). ``lookup`` returns the best match or
|
fingerprint path is then skipped). ``lookup`` returns the best match or
|
||||||
``None`` (no result / service down), never raising."""
|
``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: ...
|
def is_available(self) -> bool: ...
|
||||||
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None: ...
|
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None: ...
|
||||||
|
async def lookup_all(self, fingerprint: Fingerprint) -> list[RecordingMatch]: ...
|
||||||
|
|
||||||
|
|
||||||
class CoverArtExtractor(Protocol):
|
class CoverArtExtractor(Protocol):
|
||||||
@@ -322,3 +524,68 @@ class CoverArtProvider(Protocol):
|
|||||||
|
|
||||||
def is_available(self) -> bool: ...
|
def is_available(self) -> bool: ...
|
||||||
async def fetch_release_group(self, release_group_mbid: str) -> CoverArt | None: ...
|
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: ...
|
||||||
|
|||||||
+58
-2
@@ -10,8 +10,14 @@ here — a source yields a file plus a minimal title; enrichment (plan §6.2) fi
|
|||||||
the rest later, so this stays a thin discovery layer (CLAUDE.md: no duplicated
|
the rest later, so this stays a thin discovery layer (CLAUDE.md: no duplicated
|
||||||
business logic)."""
|
business logic)."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -20,7 +26,7 @@ class SourceInfo:
|
|||||||
|
|
||||||
name: str
|
name: str
|
||||||
label: str
|
label: str
|
||||||
kind: str # "indexable" (more kinds — search/download — arrive with youtube)
|
kind: str # KIND_INDEXABLE | KIND_FETCH
|
||||||
available: bool
|
available: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -37,3 +43,53 @@ class SourceFile:
|
|||||||
suggested_title: str
|
suggested_title: str
|
||||||
file_format: str
|
file_format: str
|
||||||
file_size: int
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -41,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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -63,6 +65,58 @@ class SqlAlchemyAlbumRepository:
|
|||||||
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,
|
||||||
|
*,
|
||||||
|
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:
|
async def set_cover_path(self, album_id: uuid.UUID, cover_path: str) -> None:
|
||||||
row = await self._session.get(AlbumModel, album_id)
|
row = await self._session.get(AlbumModel, album_id)
|
||||||
if row is not None:
|
if row is not 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,9 +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,
|
metadata_error=row.metadata_error,
|
||||||
enriched_at=row.enriched_at,
|
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,
|
||||||
)
|
)
|
||||||
@@ -57,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()
|
||||||
@@ -93,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)
|
||||||
)
|
)
|
||||||
@@ -128,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,9 +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,
|
metadata_error=row.metadata_error,
|
||||||
enriched_at=row.enriched_at,
|
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,
|
||||||
)
|
)
|
||||||
@@ -136,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)
|
||||||
@@ -146,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(
|
||||||
@@ -172,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 = (
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
import datetime as dt
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -26,9 +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,
|
metadata_error=row.metadata_error,
|
||||||
enriched_at=row.enriched_at,
|
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,
|
||||||
)
|
)
|
||||||
@@ -42,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(
|
||||||
@@ -59,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,
|
||||||
@@ -78,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:
|
||||||
@@ -105,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,
|
||||||
@@ -121,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}%"))
|
||||||
|
|
||||||
@@ -145,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()
|
||||||
@@ -162,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:
|
||||||
@@ -172,6 +401,12 @@ 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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -46,6 +46,18 @@ class AcoustIdHttpClient:
|
|||||||
return bool(self._api_key)
|
return bool(self._api_key)
|
||||||
|
|
||||||
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None:
|
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:
|
if not self._api_key:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -65,13 +77,11 @@ class AcoustIdHttpClient:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
payload = resp.json()
|
return resp.json() # type: ignore[no-any-return]
|
||||||
except httpx.HTTPError, ValueError:
|
except httpx.HTTPError, ValueError:
|
||||||
log.warning("acoustid_lookup_failed")
|
log.warning("acoustid_lookup_failed")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return _parse_best_match(payload)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _throttle(cls) -> None:
|
async def _throttle(cls) -> None:
|
||||||
async with cls._throttle_lock:
|
async with cls._throttle_lock:
|
||||||
@@ -82,22 +92,39 @@ class AcoustIdHttpClient:
|
|||||||
cls._last_call_monotonic = time.monotonic()
|
cls._last_call_monotonic = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_MATCHES = 5
|
||||||
|
|
||||||
|
|
||||||
def _parse_best_match(payload: object) -> RecordingMatch | None:
|
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":
|
if not isinstance(payload, dict) or payload.get("status") != "ok":
|
||||||
return None
|
return []
|
||||||
results = payload.get("results")
|
results = payload.get("results")
|
||||||
if not isinstance(results, list) or not results:
|
if not isinstance(results, list) or not results:
|
||||||
return None
|
return []
|
||||||
|
|
||||||
# Results are returned best-score-first; take the top scoring one.
|
# Results are returned best-score-first, but sort defensively and cap the
|
||||||
best = max(results, key=lambda r: r.get("score", 0.0) if isinstance(r, dict) else 0.0)
|
# number of candidates surfaced to the editor.
|
||||||
if not isinstance(best, dict):
|
candidates = [r for r in results if isinstance(r, dict)]
|
||||||
return None
|
candidates.sort(key=lambda r: r.get("score", 0.0), reverse=True)
|
||||||
|
|
||||||
acoustid = best.get("id")
|
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):
|
if not isinstance(acoustid, str):
|
||||||
return None
|
return None
|
||||||
score = float(best.get("score", 0.0))
|
score = float(result.get("score", 0.0)) # type: ignore[arg-type]
|
||||||
|
|
||||||
recording_mbid: str | None = None
|
recording_mbid: str | None = None
|
||||||
release_group_mbid: str | None = None
|
release_group_mbid: str | None = None
|
||||||
@@ -105,7 +132,7 @@ def _parse_best_match(payload: object) -> RecordingMatch | None:
|
|||||||
artist: str | None = None
|
artist: str | None = None
|
||||||
album: str | None = None
|
album: str | None = None
|
||||||
|
|
||||||
recordings = best.get("recordings")
|
recordings = result.get("recordings")
|
||||||
if isinstance(recordings, list) and recordings and isinstance(recordings[0], dict):
|
if isinstance(recordings, list) and recordings and isinstance(recordings[0], dict):
|
||||||
rec = recordings[0]
|
rec = recordings[0]
|
||||||
recording_mbid = rec.get("id") if isinstance(rec.get("id"), str) else None
|
recording_mbid = rec.get("id") if isinstance(rec.get("id"), str) else None
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
|
"""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],
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
Built from settings at the composition root. Only sources that are configured
|
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
|
are registered (e.g. ``local`` appears only when ``LOCAL_MEDIA_IMPORT_PATH`` is
|
||||||
set), so enumeration reflects what the instance can actually use.
|
set; ``youtube`` only when ``YOUTUBE_ENABLED``), so enumeration reflects what the
|
||||||
|
instance can actually use.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.domain.errors import NotFoundError, ValidationError
|
from app.domain.errors import NotFoundError, ValidationError
|
||||||
from app.domain.ports import IndexableSource, SourceBackend
|
from app.domain.ports import FetchableSource, IndexableSource, SearchableSource, SourceBackend
|
||||||
from app.domain.sources import SourceInfo
|
from app.domain.sources import SourceInfo
|
||||||
from app.infrastructure.sources.local_folder import LocalFolderSource
|
from app.infrastructure.sources.local_folder import LocalFolderSource
|
||||||
|
from app.infrastructure.sources.youtube import YouTubeMusicSource
|
||||||
|
|
||||||
|
|
||||||
class SourceRegistry:
|
class SourceRegistry:
|
||||||
@@ -30,6 +32,26 @@ class SourceRegistry:
|
|||||||
raise ValidationError(f"Source {name!r} cannot be indexed.")
|
raise ValidationError(f"Source {name!r} cannot be indexed.")
|
||||||
return cast(IndexableSource, backend)
|
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]:
|
def infos(self) -> list[SourceInfo]:
|
||||||
return [backend.info() for backend in self._by_name.values()]
|
return [backend.info() for backend in self._by_name.values()]
|
||||||
|
|
||||||
@@ -38,4 +60,11 @@ def build_source_registry(settings: Settings) -> SourceRegistry:
|
|||||||
backends: list[SourceBackend] = []
|
backends: list[SourceBackend] = []
|
||||||
if settings.local_media_import_path is not None:
|
if settings.local_media_import_path is not None:
|
||||||
backends.append(LocalFolderSource(settings.local_media_import_path))
|
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)
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ class S3FileStorage:
|
|||||||
except ClientError as exc:
|
except ClientError as exc:
|
||||||
raise StorageError(str(exc)) from exc
|
raise StorageError(str(exc)) from exc
|
||||||
|
|
||||||
|
async def disk_usage(self) -> None:
|
||||||
|
# Object stores have no fixed-capacity volume to report.
|
||||||
|
return None
|
||||||
|
|
||||||
def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]:
|
def as_local_path(self, key: str) -> AbstractAsyncContextManager[Path]:
|
||||||
return self._as_local_path_cm(key)
|
return self._as_local_path_cm(key)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""ffmpeg-based transcoding adapters (Group B / plan §6.6)."""
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""FfmpegTranscoder — Opus + HLS renditions via the ffmpeg CLI.
|
||||||
|
|
||||||
|
Implements :class:`app.domain.ports.Transcoder`. Runs ffmpeg as a subprocess
|
||||||
|
(only ever from a worker — CLAUDE.md: no heavy work in the request cycle) and
|
||||||
|
raises :class:`TranscodeError` on a non-zero exit. Opus is used for the direct
|
||||||
|
quality renditions; HLS segments are AAC-in-MPEG-TS (broad player support).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.domain.errors import TranscodeError
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
# HLS segment length. 10s is a common VOD default — few requests, quick seeks.
|
||||||
|
_HLS_SEGMENT_SECONDS = "10"
|
||||||
|
|
||||||
|
|
||||||
|
def _mkdir(path: Path) -> None:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FfmpegTranscoder:
|
||||||
|
def __init__(self, ffmpeg_path: str = "ffmpeg") -> None:
|
||||||
|
self._ffmpeg = ffmpeg_path
|
||||||
|
|
||||||
|
async def to_opus(self, src: Path, dest: Path, *, bitrate_kbps: int) -> None:
|
||||||
|
await anyio.to_thread.run_sync(_mkdir, dest.parent)
|
||||||
|
# Per-writer temp name (not a shared ``.part``) so two concurrent jobs for
|
||||||
|
# the same rendition can't interleave into one file — each writes its own
|
||||||
|
# temp and the last atomic replace wins, both leaving a valid output.
|
||||||
|
tmp = dest.with_name(f"{dest.name}.{uuid.uuid4().hex}.part")
|
||||||
|
await self._run(
|
||||||
|
"-i", str(src),
|
||||||
|
"-vn", "-c:a", "libopus", "-b:a", f"{bitrate_kbps}k",
|
||||||
|
"-f", "opus", str(tmp),
|
||||||
|
)
|
||||||
|
# Publish atomically so a concurrent reader never sees a half-written file.
|
||||||
|
await anyio.to_thread.run_sync(tmp.replace, dest)
|
||||||
|
|
||||||
|
async def to_hls(self, src: Path, out_dir: Path, *, bitrate_kbps: int) -> None:
|
||||||
|
await anyio.to_thread.run_sync(_mkdir, out_dir)
|
||||||
|
playlist = out_dir / "playlist.m3u8"
|
||||||
|
segments = out_dir / "seg_%03d.ts"
|
||||||
|
await self._run(
|
||||||
|
"-i", str(src),
|
||||||
|
"-vn", "-c:a", "aac", "-b:a", f"{bitrate_kbps}k",
|
||||||
|
"-f", "hls",
|
||||||
|
"-hls_time", _HLS_SEGMENT_SECONDS,
|
||||||
|
"-hls_playlist_type", "vod",
|
||||||
|
"-hls_segment_filename", str(segments),
|
||||||
|
str(playlist),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run(self, *args: str) -> None:
|
||||||
|
cmd = [self._ffmpeg, "-y", "-nostdin", "-loglevel", "error", *args]
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
_, stderr = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
detail = stderr.decode(errors="replace").strip()[-500:]
|
||||||
|
log.error("ffmpeg_failed", returncode=proc.returncode, error=detail)
|
||||||
|
raise TranscodeError(f"ffmpeg exited {proc.returncode}: {detail}")
|
||||||
+14
@@ -4,6 +4,7 @@ from collections.abc import AsyncIterator
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket
|
from fastapi import FastAPI, WebSocket
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.errors import register_exception_handlers
|
from app.api.errors import register_exception_handlers
|
||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
@@ -40,6 +41,19 @@ def create_app() -> FastAPI:
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(CorrelationIdMiddleware)
|
app.add_middleware(CorrelationIdMiddleware)
|
||||||
|
# CORS added last → outermost, so browser preflight (OPTIONS) is answered
|
||||||
|
# before anything else. The web UI can connect cross-origin (direct :8000,
|
||||||
|
# a LAN IP, 127.0.0.1 vs localhost), which needs these headers. Bearer-token
|
||||||
|
# auth (no cookies) → wildcard origins are safe with allow_credentials=False.
|
||||||
|
if settings.cors_allow_origins:
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_allow_origins,
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
expose_headers=["Content-Range", "Accept-Ranges", "Content-Length"],
|
||||||
|
)
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""arq worker settings — the queue runtime. Task functions register here.
|
"""arq worker settings — the queue runtime. Task functions register here.
|
||||||
|
|
||||||
Run with: ``arq app.workers.arq_worker.WorkerSettings``.
|
Run with: ``arq app.workers.arq_worker.WorkerSettings``.
|
||||||
Tasks (download, transcode) are appended to ``functions`` in later steps.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
@@ -10,8 +9,12 @@ from arq.connections import RedisSettings
|
|||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.logging import configure_logging, get_logger
|
from app.core.logging import configure_logging, get_logger
|
||||||
|
from app.workers.tasks.cleanup_task import cleanup_storage
|
||||||
|
from app.workers.tasks.download_task import download_track
|
||||||
from app.workers.tasks.enrich_task import enrich_track
|
from app.workers.tasks.enrich_task import enrich_track
|
||||||
from app.workers.tasks.import_task import scan_local_folder
|
from app.workers.tasks.import_task import scan_local_folder
|
||||||
|
from app.workers.tasks.materialize_task import materialize_track
|
||||||
|
from app.workers.tasks.transcode_task import transcode_track
|
||||||
|
|
||||||
log = get_logger("worker")
|
log = get_logger("worker")
|
||||||
|
|
||||||
@@ -27,7 +30,14 @@ async def shutdown(_ctx: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class WorkerSettings:
|
class WorkerSettings:
|
||||||
functions: ClassVar[list[Any]] = [scan_local_folder, enrich_track]
|
functions: ClassVar[list[Any]] = [
|
||||||
|
scan_local_folder,
|
||||||
|
enrich_track,
|
||||||
|
download_track,
|
||||||
|
materialize_track,
|
||||||
|
cleanup_storage,
|
||||||
|
transcode_track,
|
||||||
|
]
|
||||||
on_startup = startup
|
on_startup = startup
|
||||||
on_shutdown = shutdown
|
on_shutdown = shutdown
|
||||||
max_jobs = get_settings().max_parallel_downloads
|
max_jobs = get_settings().max_parallel_downloads
|
||||||
|
|||||||
@@ -34,6 +34,53 @@ async def enqueue(function: str, **kwargs: Any) -> str:
|
|||||||
return str(job.job_id)
|
return str(job.job_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_download(job_id: uuid.UUID) -> None:
|
||||||
|
"""Best-effort enqueue of a download job for the worker.
|
||||||
|
|
||||||
|
The job row is already persisted as ``queued``, so this is a follow-up, not a
|
||||||
|
barrier: if the queue is unreachable we log and move on (graceful
|
||||||
|
degradation) — the job stays ``queued`` and can be retried later. Deferred a
|
||||||
|
few seconds so the request's DB transaction commits before the worker reads
|
||||||
|
the row (same reason as :func:`enqueue_enrich`)."""
|
||||||
|
try:
|
||||||
|
await enqueue("download_track", job_id=str(job_id), _defer_by=3)
|
||||||
|
except DependencyUnavailableError:
|
||||||
|
log.warning("download_enqueue_failed", job_id=str(job_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_materialize(job_id: uuid.UUID) -> None:
|
||||||
|
"""Best-effort enqueue of a materialize job for the worker (plan: Model C
|
||||||
|
lazy materialization). Same deferred-commit reasoning as
|
||||||
|
:func:`enqueue_download` — the job row stays ``queued`` and can be retried
|
||||||
|
if the queue is unreachable."""
|
||||||
|
try:
|
||||||
|
await enqueue("materialize_track", job_id=str(job_id), _defer_by=3)
|
||||||
|
except DependencyUnavailableError:
|
||||||
|
log.warning("materialize_enqueue_failed", job_id=str(job_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_transcode(
|
||||||
|
track_id: uuid.UUID, *, quality: str = "high", hls: bool = True
|
||||||
|
) -> str:
|
||||||
|
"""Enqueue a transcode job (§6.6). Unlike the best-effort follow-ups below,
|
||||||
|
this is user/stream-driven, so it surfaces a job id (and a 503 via the caller
|
||||||
|
if the queue is down) rather than swallowing failures."""
|
||||||
|
return await enqueue(
|
||||||
|
"transcode_track", track_id=str(track_id), quality=quality, hls=hls
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_transcode_quiet(
|
||||||
|
track_id: uuid.UUID, *, quality: str = "high", hls: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""Best-effort transcode enqueue for a streaming cache miss — never blocks or
|
||||||
|
fails the play (the master is served meanwhile)."""
|
||||||
|
try:
|
||||||
|
await enqueue_transcode(track_id, quality=quality, hls=hls)
|
||||||
|
except DependencyUnavailableError:
|
||||||
|
log.warning("transcode_enqueue_failed", track_id=str(track_id))
|
||||||
|
|
||||||
|
|
||||||
async def enqueue_enrich(track_id: uuid.UUID) -> None:
|
async def enqueue_enrich(track_id: uuid.UUID) -> None:
|
||||||
"""Best-effort enqueue of metadata enrichment for a freshly stored track.
|
"""Best-effort enqueue of metadata enrichment for a freshly stored track.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""arq task: reconcile the catalogue against storage.
|
||||||
|
|
||||||
|
Scans every *local* track and drops rows whose backing file has vanished from
|
||||||
|
storage (a "dangling" reference). Remote placeholders have no local file yet and
|
||||||
|
are skipped by the repository query. The filesystem checks (one ``exists()`` per
|
||||||
|
track) are heavy, so this runs off the request cycle (CLAUDE.md). Guarded against
|
||||||
|
a storage outage: if an implausibly large share of files look missing, it assumes
|
||||||
|
the backend is down and aborts without deleting anything.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.infrastructure.db import session_scope
|
||||||
|
from app.infrastructure.db.repositories import SqlAlchemyTrackRepository
|
||||||
|
from app.infrastructure.storage.provider import get_file_storage
|
||||||
|
|
||||||
|
log = get_logger("worker.cleanup")
|
||||||
|
|
||||||
|
# If more than this fraction of tracks look missing, assume the storage backend
|
||||||
|
# is unavailable (not that the library really evaporated) and refuse to delete.
|
||||||
|
_OUTAGE_GUARD_FRACTION = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_storage(_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
storage = get_file_storage()
|
||||||
|
async with session_scope() as session:
|
||||||
|
tracks = SqlAlchemyTrackRepository(session)
|
||||||
|
refs = await tracks.all_storage_refs()
|
||||||
|
missing = [track_id for track_id, uri in refs if not await storage.exists(uri)]
|
||||||
|
|
||||||
|
if refs and len(missing) > len(refs) * _OUTAGE_GUARD_FRACTION:
|
||||||
|
log.warning("cleanup_aborted_outage_guard", scanned=len(refs), missing=len(missing))
|
||||||
|
return {"scanned": len(refs), "removed": 0, "aborted": True}
|
||||||
|
|
||||||
|
for track_id in missing:
|
||||||
|
await tracks.delete(track_id)
|
||||||
|
|
||||||
|
log.info("cleanup_done", scanned=len(refs), removed=len(missing))
|
||||||
|
return {"scanned": len(refs), "removed": len(missing), "aborted": False}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""arq task: download one queued job through a fetch source (plan §6.1).
|
||||||
|
|
||||||
|
Flow: load job → ``downloading`` → ``backend.fetch`` (progress streamed to the
|
||||||
|
job row) → ``enriching`` → store file + minimal track → ``done`` → enqueue
|
||||||
|
enrichment. yt-dlp fails often, so a failed fetch retries with exponential
|
||||||
|
backoff (``download_max_retries``); only after the last try is the job marked
|
||||||
|
``failed`` with a reason for the §A5 download manager.
|
||||||
|
|
||||||
|
Heavy I/O belongs off the request cycle (CLAUDE.md); the HTTP endpoint only
|
||||||
|
enqueues. The job row tolerates being deleted mid-flight (cancellation) — status
|
||||||
|
writes against a missing row are no-ops.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from arq import Retry
|
||||||
|
|
||||||
|
from app.application.download_service import DownloadService
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.logging import correlation_id, get_logger
|
||||||
|
from app.domain.entities.download import DownloadJob
|
||||||
|
from app.domain.errors import NotFoundError, ValidationError
|
||||||
|
from app.domain.ports import FetchableSource, ProgressCallback
|
||||||
|
from app.domain.sources import DownloadResult
|
||||||
|
from app.infrastructure.db import session_scope
|
||||||
|
from app.infrastructure.db.repositories import (
|
||||||
|
SqlAlchemyArtistRepository,
|
||||||
|
SqlAlchemyDownloadJobRepository,
|
||||||
|
SqlAlchemyTrackRepository,
|
||||||
|
)
|
||||||
|
from app.infrastructure.sources.registry import build_source_registry
|
||||||
|
from app.infrastructure.storage.provider import get_file_storage
|
||||||
|
from app.workers.queue import enqueue_enrich
|
||||||
|
|
||||||
|
log = get_logger("worker.download")
|
||||||
|
|
||||||
|
# Exponential backoff between retries: 30s, 60s, 120s … capped.
|
||||||
|
_BACKOFF_BASE_SECONDS = 30
|
||||||
|
_BACKOFF_MAX_SECONDS = 600
|
||||||
|
# Only write progress when it advances by at least this much (avoid hammering
|
||||||
|
# the DB on every yt-dlp chunk).
|
||||||
|
_PROGRESS_STEP = 0.01
|
||||||
|
|
||||||
|
|
||||||
|
async def download_track(_ctx: dict[str, Any], *, job_id: str) -> dict[str, Any]:
|
||||||
|
correlation_id.set(f"dl:{job_id}")
|
||||||
|
jid = uuid.UUID(job_id)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
job = await _load_job(jid)
|
||||||
|
if job is None:
|
||||||
|
log.info("download_job_missing", job_id=job_id) # cancelled before pickup
|
||||||
|
return {"job_id": job_id, "status": "missing"}
|
||||||
|
|
||||||
|
registry = build_source_registry(settings)
|
||||||
|
try:
|
||||||
|
backend = registry.fetchable(job.source)
|
||||||
|
except (NotFoundError, ValidationError) as exc:
|
||||||
|
await _mark_failed(jid, f"Source unavailable: {exc}")
|
||||||
|
return {"job_id": job_id, "status": "failed"}
|
||||||
|
if job.source_id is None:
|
||||||
|
await _mark_failed(jid, "Job has no source_id to download.")
|
||||||
|
return {"job_id": job_id, "status": "failed"}
|
||||||
|
|
||||||
|
await _set_status(jid, "downloading")
|
||||||
|
try:
|
||||||
|
result = await _run_fetch(backend, job.source_id, jid)
|
||||||
|
except Exception as exc:
|
||||||
|
return await _handle_failure(jid, exc, settings.download_max_retries, job_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
track_id = await _import_result(jid, job, result)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("download_import_failed", job_id=job_id)
|
||||||
|
await _mark_failed(jid, f"Import failed: {type(exc).__name__}: {exc}")
|
||||||
|
return {"job_id": job_id, "status": "failed"}
|
||||||
|
|
||||||
|
await enqueue_enrich(track_id)
|
||||||
|
log.info("download_complete", job_id=job_id, track_id=str(track_id))
|
||||||
|
return {"job_id": job_id, "status": "done", "track_id": str(track_id)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_fetch(
|
||||||
|
backend: FetchableSource, source_id: str, jid: uuid.UUID
|
||||||
|
) -> DownloadResult:
|
||||||
|
"""Fetch the file, streaming progress into the job row. A single session is
|
||||||
|
held for the download so progress writes don't churn connections; each
|
||||||
|
throttled update is committed so API pollers see it."""
|
||||||
|
async with session_scope() as session:
|
||||||
|
repo = SqlAlchemyDownloadJobRepository(session)
|
||||||
|
last = 0.0
|
||||||
|
|
||||||
|
async def on_progress(frac: float) -> None:
|
||||||
|
nonlocal last
|
||||||
|
if frac - last < _PROGRESS_STEP:
|
||||||
|
return
|
||||||
|
last = frac
|
||||||
|
await repo.set_progress(jid, frac)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
cb: ProgressCallback = on_progress
|
||||||
|
return await backend.fetch(source_id, on_progress=cb)
|
||||||
|
|
||||||
|
|
||||||
|
async def _import_result(jid: uuid.UUID, job: DownloadJob, result: DownloadResult) -> uuid.UUID:
|
||||||
|
async with session_scope() as session:
|
||||||
|
repo = SqlAlchemyDownloadJobRepository(session)
|
||||||
|
await repo.set_status(jid, status="enriching")
|
||||||
|
service = DownloadService(
|
||||||
|
jobs=repo,
|
||||||
|
tracks=SqlAlchemyTrackRepository(session),
|
||||||
|
artists=SqlAlchemyArtistRepository(session),
|
||||||
|
storage=get_file_storage(),
|
||||||
|
)
|
||||||
|
track_id = await service.store_result(
|
||||||
|
source=job.source, result=result, requested_by=job.requested_by
|
||||||
|
)
|
||||||
|
await repo.set_status(jid, status="done", track_id=track_id)
|
||||||
|
return track_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_failure(
|
||||||
|
jid: uuid.UUID, exc: Exception, max_retries: int, job_id: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
async with session_scope() as session:
|
||||||
|
tries = await SqlAlchemyDownloadJobRepository(session).increment_retry(jid)
|
||||||
|
if tries <= max_retries:
|
||||||
|
backoff = min(_BACKOFF_BASE_SECONDS * 2 ** (tries - 1), _BACKOFF_MAX_SECONDS)
|
||||||
|
log.warning("download_retry", job_id=job_id, attempt=tries, defer=backoff)
|
||||||
|
raise Retry(defer=backoff) from exc
|
||||||
|
log.exception("download_failed", job_id=job_id)
|
||||||
|
await _mark_failed(jid, f"Download failed after {tries} attempts: {type(exc).__name__}: {exc}")
|
||||||
|
return {"job_id": job_id, "status": "failed"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_job(jid: uuid.UUID) -> DownloadJob | None:
|
||||||
|
async with session_scope() as session:
|
||||||
|
return await SqlAlchemyDownloadJobRepository(session).get_by_id(jid)
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_status(jid: uuid.UUID, status: str) -> None:
|
||||||
|
async with session_scope() as session:
|
||||||
|
await SqlAlchemyDownloadJobRepository(session).set_status(jid, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_failed(jid: uuid.UUID, error: str) -> None:
|
||||||
|
async with session_scope() as session:
|
||||||
|
await SqlAlchemyDownloadJobRepository(session).set_status(
|
||||||
|
jid, status="failed", error_message=error
|
||||||
|
)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""arq task: materialize a remote placeholder track (plan: Model C).
|
||||||
|
|
||||||
|
Counterpart to ``download_task`` for tracks that were *saved* from a remote
|
||||||
|
browse hit without audio (``availability="remote"``, ``storage_uri=NULL``).
|
||||||
|
The job's ``track_id`` already points at the existing placeholder row — on
|
||||||
|
success the file is stored and ``TrackRepository.materialize`` fills the row
|
||||||
|
in place (the track's ``id`` never changes), then enrichment is enqueued as
|
||||||
|
usual.
|
||||||
|
|
||||||
|
Shares its fetch/retry/failure machinery with ``download_task`` — only the
|
||||||
|
"what happens on success" step differs (fill in an existing row vs. create a
|
||||||
|
new one).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.logging import correlation_id, get_logger
|
||||||
|
from app.domain.errors import NotFoundError, ValidationError
|
||||||
|
from app.domain.sources import DownloadResult
|
||||||
|
from app.infrastructure.db import session_scope
|
||||||
|
from app.infrastructure.db.repositories import (
|
||||||
|
SqlAlchemyDownloadJobRepository,
|
||||||
|
SqlAlchemyTrackRepository,
|
||||||
|
)
|
||||||
|
from app.infrastructure.sources.registry import build_source_registry
|
||||||
|
from app.infrastructure.storage.provider import get_file_storage
|
||||||
|
from app.workers.queue import enqueue_enrich
|
||||||
|
from app.workers.tasks.download_task import _handle_failure, _load_job, _mark_failed, _run_fetch
|
||||||
|
|
||||||
|
log = get_logger("worker.materialize")
|
||||||
|
|
||||||
|
|
||||||
|
async def materialize_track(_ctx: dict[str, Any], *, job_id: str) -> dict[str, Any]:
|
||||||
|
correlation_id.set(f"mat:{job_id}")
|
||||||
|
jid = uuid.UUID(job_id)
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
job = await _load_job(jid)
|
||||||
|
if job is None:
|
||||||
|
log.info("materialize_job_missing", job_id=job_id) # cancelled before pickup
|
||||||
|
return {"job_id": job_id, "status": "missing"}
|
||||||
|
if job.track_id is None or job.source_id is None:
|
||||||
|
await _mark_failed(jid, "Materialize job missing track_id/source_id.")
|
||||||
|
return {"job_id": job_id, "status": "failed"}
|
||||||
|
|
||||||
|
registry = build_source_registry(settings)
|
||||||
|
try:
|
||||||
|
backend = registry.fetchable(job.source)
|
||||||
|
except (NotFoundError, ValidationError) as exc:
|
||||||
|
await _mark_failed(jid, f"Source unavailable: {exc}")
|
||||||
|
return {"job_id": job_id, "status": "failed"}
|
||||||
|
|
||||||
|
await _set_status(jid, "downloading")
|
||||||
|
try:
|
||||||
|
result = await _run_fetch(backend, job.source_id, jid)
|
||||||
|
except Exception as exc:
|
||||||
|
return await _handle_failure(jid, exc, settings.download_max_retries, job_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _materialize_result(jid, job.track_id, result)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("materialize_finalize_failed", job_id=job_id)
|
||||||
|
await _mark_failed(jid, f"Materialize failed: {type(exc).__name__}: {exc}")
|
||||||
|
return {"job_id": job_id, "status": "failed"}
|
||||||
|
|
||||||
|
await enqueue_enrich(job.track_id)
|
||||||
|
log.info("materialize_complete", job_id=job_id, track_id=str(job.track_id))
|
||||||
|
return {"job_id": job_id, "status": "done", "track_id": str(job.track_id)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _materialize_result(jid: uuid.UUID, track_id: uuid.UUID, result: DownloadResult) -> None:
|
||||||
|
"""Store the downloaded file and fill in the placeholder track in place."""
|
||||||
|
key = f"tracks/{str(track_id)[:2]}/{track_id}.{result.file_format}"
|
||||||
|
storage = get_file_storage()
|
||||||
|
try:
|
||||||
|
await storage.save_file(key, result.path)
|
||||||
|
async with session_scope() as session:
|
||||||
|
job_repo = SqlAlchemyDownloadJobRepository(session)
|
||||||
|
await job_repo.set_status(jid, status="enriching")
|
||||||
|
tracks = SqlAlchemyTrackRepository(session)
|
||||||
|
await tracks.materialize(
|
||||||
|
track_id,
|
||||||
|
storage_uri=key,
|
||||||
|
file_format=result.file_format,
|
||||||
|
file_size=result.file_size,
|
||||||
|
bitrate=result.bitrate,
|
||||||
|
)
|
||||||
|
await job_repo.set_status(jid, status="done", track_id=track_id)
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await anyio.Path(result.path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_status(jid: uuid.UUID, status: str) -> None:
|
||||||
|
async with session_scope() as session:
|
||||||
|
await SqlAlchemyDownloadJobRepository(session).set_status(jid, status=status)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""arq task: transcode a track to a cached Opus rendition and/or HLS (§6.6).
|
||||||
|
|
||||||
|
Triggered by ``POST /tracks/{id}/optimize`` and by a streaming cache miss. Reads
|
||||||
|
the master once (via ``storage.as_local_path``) and writes into
|
||||||
|
``transcode_cache_path``. Idempotent — existing outputs are skipped, so repeated
|
||||||
|
enqueues (e.g. several plays before the first finishes) are cheap. The DB session
|
||||||
|
is released before ffmpeg runs; the track entity is a detached value object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
from app.application.transcode_service import (
|
||||||
|
HLS_BITRATE,
|
||||||
|
bitrate_for_quality,
|
||||||
|
hls_dir,
|
||||||
|
hls_playlist_path,
|
||||||
|
opus_path,
|
||||||
|
)
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.infrastructure.db import session_scope
|
||||||
|
from app.infrastructure.db.repositories import SqlAlchemyTrackRepository
|
||||||
|
from app.infrastructure.storage.provider import get_file_storage
|
||||||
|
from app.infrastructure.transcode.ffmpeg import FfmpegTranscoder
|
||||||
|
|
||||||
|
log = get_logger("worker.transcode")
|
||||||
|
|
||||||
|
|
||||||
|
def _swap_dir(tmp_dir: Path, final_dir: Path) -> None:
|
||||||
|
"""Publish a freshly-built HLS rendition atomically: replace the final dir in
|
||||||
|
one rename so a reader never sees a playlist referencing half-written
|
||||||
|
segments. Any stale partial at the destination is cleared first."""
|
||||||
|
if final_dir.exists():
|
||||||
|
shutil.rmtree(final_dir)
|
||||||
|
tmp_dir.replace(final_dir)
|
||||||
|
|
||||||
|
|
||||||
|
async def transcode_track(
|
||||||
|
_ctx: dict[str, Any],
|
||||||
|
*,
|
||||||
|
track_id: str,
|
||||||
|
quality: str = "high",
|
||||||
|
hls: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
tid = uuid.UUID(track_id)
|
||||||
|
|
||||||
|
async with session_scope() as session:
|
||||||
|
track = await SqlAlchemyTrackRepository(session).get_by_id(tid)
|
||||||
|
if track is None or track.storage_uri is None:
|
||||||
|
log.warning("transcode_skip_no_file", track_id=track_id)
|
||||||
|
return {"track_id": track_id, "opus": False, "hls": False}
|
||||||
|
|
||||||
|
storage = get_file_storage()
|
||||||
|
transcoder = FfmpegTranscoder(settings.ffmpeg_path)
|
||||||
|
root = settings.transcode_cache_path
|
||||||
|
did_opus = False
|
||||||
|
did_hls = False
|
||||||
|
|
||||||
|
async with storage.as_local_path(track.storage_uri) as src:
|
||||||
|
bitrate = bitrate_for_quality(quality)
|
||||||
|
if bitrate is not None:
|
||||||
|
dest = opus_path(root, tid, bitrate)
|
||||||
|
if not dest.exists():
|
||||||
|
await transcoder.to_opus(src, dest, bitrate_kbps=bitrate)
|
||||||
|
did_opus = True
|
||||||
|
if hls and not hls_playlist_path(root, tid).exists():
|
||||||
|
# Build into a private temp dir, then swap it in atomically — the
|
||||||
|
# cached playlist only becomes visible once every segment is written.
|
||||||
|
final_dir = hls_dir(root, tid)
|
||||||
|
tmp_dir = final_dir.with_name(f"hls.{uuid.uuid4().hex}.tmp")
|
||||||
|
await transcoder.to_hls(src, tmp_dir, bitrate_kbps=HLS_BITRATE)
|
||||||
|
await anyio.to_thread.run_sync(_swap_dir, tmp_dir, final_dir)
|
||||||
|
did_hls = True
|
||||||
|
|
||||||
|
log.info("transcode_done", track_id=track_id, opus=did_opus, hls=did_hls)
|
||||||
|
return {"track_id": track_id, "opus": did_opus, "hls": did_hls}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
{
|
||||||
|
"0": "Storage Backend & Disk Usage",
|
||||||
|
"1": "Auth Schemas & Tokens",
|
||||||
|
"2": "Error Mapping & Search Schemas",
|
||||||
|
"3": "Download Service Orchestration",
|
||||||
|
"4": "Playlist Schemas & API",
|
||||||
|
"5": "Album Repository",
|
||||||
|
"6": "ORM Base & Models",
|
||||||
|
"7": "Correlation ID Middleware & Config",
|
||||||
|
"8": "Auth Services (native + Subsonic)",
|
||||||
|
"9": "Audio Tags & Metadata Test Fakes",
|
||||||
|
"10": "YouTube Source Backend",
|
||||||
|
"11": "Dependency Wiring (deps.py)",
|
||||||
|
"12": "Library Stats & Track Availability",
|
||||||
|
"13": "Library Import Service",
|
||||||
|
"14": "Subsonic Response Envelope",
|
||||||
|
"15": "Chromaprint Fingerprinting",
|
||||||
|
"16": "Album/Search Response Schemas",
|
||||||
|
"17": "Pagination & Likes Endpoints",
|
||||||
|
"18": "Metadata Enrichment Service",
|
||||||
|
"19": "Subsonic ID Encoding",
|
||||||
|
"20": "Track & Artist Domain Entities",
|
||||||
|
"21": "Source Port Protocols",
|
||||||
|
"22": "User Management Service",
|
||||||
|
"23": "Metadata Enrichment Value Objects",
|
||||||
|
"24": "DB Session & Health Checks",
|
||||||
|
"25": "Download Service Tests",
|
||||||
|
"26": "Download Job Domain Entity",
|
||||||
|
"27": "Credentials & User Repository",
|
||||||
|
"28": "Streaming Auth Dependencies",
|
||||||
|
"29": "Subsonic Browsing Endpoints",
|
||||||
|
"30": "Remote Library Service Tests",
|
||||||
|
"31": "Worker Session Scope & Download Task",
|
||||||
|
"32": "Metadata Enrichment Pipeline",
|
||||||
|
"33": "Album Domain & Cover Value Object",
|
||||||
|
"34": "Downloads API Tests",
|
||||||
|
"35": "Subsonic Search Encoding",
|
||||||
|
"36": "In-Memory Auth Repository Fakes",
|
||||||
|
"37": "Storage Stats Response Schemas",
|
||||||
|
"38": "Subsonic App-Password Crypto",
|
||||||
|
"39": "Artist Schemas & Endpoints",
|
||||||
|
"40": "Download Request/Response Schemas",
|
||||||
|
"41": "Settings & MusicBrainz User-Agent",
|
||||||
|
"42": "Subsonic API Integration Tests",
|
||||||
|
"43": "Playlist Endpoints",
|
||||||
|
"44": "Play History Domain & Repository",
|
||||||
|
"45": "DB Engine Lifecycle",
|
||||||
|
"46": "Shared Query Param Annotations",
|
||||||
|
"47": "Local Folder Source Backend",
|
||||||
|
"48": "Track Metadata Match Schemas",
|
||||||
|
"49": "Like Domain Entity (event log)",
|
||||||
|
"50": "Auth API Integration Tests",
|
||||||
|
"51": "Cover API Tests",
|
||||||
|
"52": "Upload/Stream API Tests",
|
||||||
|
"53": "Tag Parsing Helpers",
|
||||||
|
"54": "Metadata API Tests",
|
||||||
|
"55": "Subsonic Auth Service Tests",
|
||||||
|
"56": "Cover Art Serving",
|
||||||
|
"57": "Play History Schemas",
|
||||||
|
"58": "Refresh Token Repository",
|
||||||
|
"59": "Radio Endpoints",
|
||||||
|
"60": "Subsonic Legacy Password Decoding",
|
||||||
|
"61": "Management CLI",
|
||||||
|
"62": "Sources API Tests",
|
||||||
|
"63": "Hexagonal Architecture Layers (doc)",
|
||||||
|
"64": "Test DB Fixtures (conftest)",
|
||||||
|
"65": "Upload Response Schemas",
|
||||||
|
"66": "Cover Art Extraction (Vorbis/MP4)",
|
||||||
|
"67": "Storage Stats API Tests",
|
||||||
|
"68": "AcoustID Response Parser Tests",
|
||||||
|
"69": "Like Request/Response Schemas",
|
||||||
|
"70": "Streaming Endpoint",
|
||||||
|
"71": "Auth Token Issue/Refresh Flow",
|
||||||
|
"72": "Generic Job/Result Store Protocol",
|
||||||
|
"73": "Remote Library Materialization",
|
||||||
|
"74": "CI Docker Publish Workflow",
|
||||||
|
"75": "User Scrobbling Settings",
|
||||||
|
"76": "Domain Invariants (dedup, manual overwrite, offline tags)",
|
||||||
|
"77": "Alembic Migration Environment",
|
||||||
|
"78": "Cover Art Archive Client",
|
||||||
|
"79": "Health Endpoint Smoke Tests",
|
||||||
|
"80": "ASGI Middleware Protocol",
|
||||||
|
"81": "File Hashing Utilities",
|
||||||
|
"82": "Migration: auth_users",
|
||||||
|
"83": "Migration: music_schema",
|
||||||
|
"84": "Migration: rename track file_path",
|
||||||
|
"85": "Migration: subsonic_app_password",
|
||||||
|
"86": "Migration: track_enrichment_outcome",
|
||||||
|
"87": "Migration: download_job_track_id",
|
||||||
|
"88": "Migration: remote_placeholders",
|
||||||
|
"89": "Package: app.api",
|
||||||
|
"90": "Package: app.api.schemas",
|
||||||
|
"91": "Package: app.application",
|
||||||
|
"92": "Package: app.core",
|
||||||
|
"93": "Package: app.domain",
|
||||||
|
"94": "Package: app.infrastructure",
|
||||||
|
"95": "Package: metadata-enrichment adapters",
|
||||||
|
"96": "Package: source backends",
|
||||||
|
"97": "Package: storage adapters",
|
||||||
|
"98": "Package: app root (mcma-backend)",
|
||||||
|
"99": "Package: arq worker",
|
||||||
|
"100": "Package: arq tasks",
|
||||||
|
"101": "Config Conventions (doc)",
|
||||||
|
"102": "Subsonic Adapter Convention (doc)",
|
||||||
|
"103": "DB Session Convention (doc)",
|
||||||
|
"104": "Health Convention (doc)",
|
||||||
|
"105": "Invariant: Graceful Degradation",
|
||||||
|
"106": "Invariant: Likes Are Append-Only",
|
||||||
|
"107": "Invariant: Stable Track content_id",
|
||||||
|
"108": "Logging Convention (doc)",
|
||||||
|
"109": "Python 3.14 Lazy Annotations Note",
|
||||||
|
"110": "App-Passwords Mechanism (doc)",
|
||||||
|
"111": "Local Dev Setup (doc)",
|
||||||
|
"112": "Tooling Section (doc)",
|
||||||
|
"113": "Package: workers",
|
||||||
|
"114": "app/application — use cases / services",
|
||||||
|
"115": "Composition roots (app/main.py, app/api/deps.py)",
|
||||||
|
"116": "app/core — cross-cutting concerns",
|
||||||
|
"117": "app/domain — pure business core",
|
||||||
|
"118": "Error handling convention (domain errors → HTTP in app/api/errors.py)",
|
||||||
|
"119": "Hexagonal architecture (ports & adapters)",
|
||||||
|
"120": "app/infrastructure — driven adapters",
|
||||||
|
"121": "Invariant: dedup on (source, source_id) and acoustid_fingerprint",
|
||||||
|
"122": "Invariant: no heavy work in the request cycle (goes to arq workers)",
|
||||||
|
"123": "Invariant: never overwrite metadata_status=manual",
|
||||||
|
"124": "Migrations convention (async, settings-driven Alembic)",
|
||||||
|
"125": "app/workers — arq background tasks",
|
||||||
|
"126": "Build (Docker) section",
|
||||||
|
"127": "Hexagonal architecture (ports & adapters) — README",
|
||||||
|
"128": "Database migrations (Alembic) section",
|
||||||
|
"129": "Subsonic API (/rest) section",
|
||||||
|
"130": "acoustid_trust_score (trust high-confidence AcoustID over junk tags)",
|
||||||
|
"131": "Offline tag reader (deterministic, always runs)",
|
||||||
|
"132": "scarlet_fire_otis_mcdonald.mp3 test fixture"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/Users/senko/.local/share/uv/tools/graphifyy/bin/python
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"0": "Storage Backend & Disk Usage", "1": "Auth Schemas & Tokens", "2": "Error Mapping & Search Schemas", "3": "Download Service Orchestration", "4": "Playlist Schemas & API", "5": "Album Repository", "6": "ORM Base & Models", "7": "Correlation ID Middleware & Config", "8": "Auth Services (native + Subsonic)", "9": "Audio Tags & Metadata Test Fakes", "10": "YouTube Source Backend", "11": "Dependency Wiring (deps.py)", "12": "Library Stats & Track Availability", "13": "Library Import Service", "14": "Subsonic Response Envelope", "15": "Chromaprint Fingerprinting", "16": "Album/Search Response Schemas", "17": "Pagination & Likes Endpoints", "18": "Metadata Enrichment Service", "19": "Subsonic ID Encoding", "20": "Track & Artist Domain Entities", "21": "Source Port Protocols", "22": "User Management Service", "23": "Metadata Enrichment Value Objects", "24": "DB Session & Health Checks", "25": "Download Service Tests", "26": "Download Job Domain Entity", "27": "Credentials & User Repository", "28": "Streaming Auth Dependencies", "29": "Subsonic Browsing Endpoints", "30": "Remote Library Service Tests", "31": "Worker Session Scope & Download Task", "32": "Metadata Enrichment Pipeline", "33": "Album Domain & Cover Value Object", "34": "Downloads API Tests", "35": "Subsonic Search Encoding", "36": "In-Memory Auth Repository Fakes", "37": "Storage Stats Response Schemas", "38": "Subsonic App-Password Crypto", "39": "Artist Schemas & Endpoints", "40": "Download Request/Response Schemas", "41": "Settings & MusicBrainz User-Agent", "42": "Subsonic API Integration Tests", "43": "Playlist Endpoints", "44": "Play History Domain & Repository", "45": "DB Engine Lifecycle", "46": "Shared Query Param Annotations", "47": "Local Folder Source Backend", "48": "Track Metadata Match Schemas", "49": "Like Domain Entity (event log)", "50": "Auth API Integration Tests", "51": "Cover API Tests", "52": "Upload/Stream API Tests", "53": "Tag Parsing Helpers", "54": "Metadata API Tests", "55": "Subsonic Auth Service Tests", "56": "Cover Art Serving", "57": "Play History Schemas", "58": "Refresh Token Repository", "59": "Radio Endpoints", "60": "Subsonic Legacy Password Decoding", "61": "Management CLI", "62": "Sources API Tests", "63": "Hexagonal Architecture Layers (doc)", "64": "Test DB Fixtures (conftest)", "65": "Upload Response Schemas", "66": "Cover Art Extraction (Vorbis/MP4)", "67": "Storage Stats API Tests", "68": "AcoustID Response Parser Tests", "69": "Like Request/Response Schemas", "70": "Streaming Endpoint", "71": "Auth Token Issue/Refresh Flow", "72": "Generic Job/Result Store Protocol", "73": "Remote Library Materialization", "74": "CI Docker Publish Workflow", "75": "User Scrobbling Settings", "76": "Domain Invariants (dedup, manual overwrite, offline tags)", "77": "Alembic Migration Environment", "78": "Cover Art Archive Client", "79": "Health Endpoint Smoke Tests", "80": "ASGI Middleware Protocol", "81": "File Hashing Utilities", "82": "Migration: auth_users", "83": "Migration: music_schema", "84": "Migration: rename track file_path", "85": "Migration: subsonic_app_password", "86": "Migration: track_enrichment_outcome", "87": "Migration: download_job_track_id", "88": "Migration: remote_placeholders", "89": "Package: app.api", "90": "Package: app.api.schemas", "91": "Package: app.application", "92": "Package: app.core", "93": "Package: app.domain", "94": "Package: app.infrastructure", "95": "Package: metadata-enrichment adapters", "96": "Package: source backends", "97": "Package: storage adapters", "98": "Package: app root (mcma-backend)", "99": "Package: arq worker", "100": "Package: arq tasks", "101": "Config Conventions (doc)", "102": "Subsonic Adapter Convention (doc)", "103": "DB Session Convention (doc)", "104": "Health Convention (doc)", "105": "Invariant: Graceful Degradation", "106": "Invariant: Likes Are Append-Only", "107": "Invariant: Stable Track content_id", "108": "Logging Convention (doc)", "109": "Python 3.14 Lazy Annotations Note", "110": "App-Passwords Mechanism (doc)", "111": "Local Dev Setup (doc)", "112": "Tooling Section (doc)", "113": "Package: workers"}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
# Graph Report - . (2026-07-11)
|
||||||
|
|
||||||
|
## Corpus Check
|
||||||
|
- 180 files · ~50,815 words
|
||||||
|
- Verdict: corpus is large enough that graph structure adds value.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- 1953 nodes · 5606 edges · 114 communities (89 shown, 25 thin omitted)
|
||||||
|
- Extraction: 90% EXTRACTED · 10% INFERRED · 0% AMBIGUOUS · INFERRED: 579 edges (avg confidence: 0.56)
|
||||||
|
- Token cost: 0 input · 44,973 output
|
||||||
|
|
||||||
|
## Community Hubs (Navigation)
|
||||||
|
- Storage Backend & Disk Usage
|
||||||
|
- Auth Schemas & Tokens
|
||||||
|
- Error Mapping & Search Schemas
|
||||||
|
- Download Service Orchestration
|
||||||
|
- Playlist Schemas & API
|
||||||
|
- Album Repository
|
||||||
|
- ORM Base & Models
|
||||||
|
- Correlation ID Middleware & Config
|
||||||
|
- Auth Services (native + Subsonic)
|
||||||
|
- Audio Tags & Metadata Test Fakes
|
||||||
|
- YouTube Source Backend
|
||||||
|
- Dependency Wiring (deps.py)
|
||||||
|
- Library Stats & Track Availability
|
||||||
|
- Library Import Service
|
||||||
|
- Subsonic Response Envelope
|
||||||
|
- Chromaprint Fingerprinting
|
||||||
|
- Album/Search Response Schemas
|
||||||
|
- Pagination & Likes Endpoints
|
||||||
|
- Metadata Enrichment Service
|
||||||
|
- Subsonic ID Encoding
|
||||||
|
- Track & Artist Domain Entities
|
||||||
|
- Source Port Protocols
|
||||||
|
- User Management Service
|
||||||
|
- Metadata Enrichment Value Objects
|
||||||
|
- DB Session & Health Checks
|
||||||
|
- Download Service Tests
|
||||||
|
- Download Job Domain Entity
|
||||||
|
- Credentials & User Repository
|
||||||
|
- Streaming Auth Dependencies
|
||||||
|
- Subsonic Browsing Endpoints
|
||||||
|
- Remote Library Service Tests
|
||||||
|
- Worker Session Scope & Download Task
|
||||||
|
- Metadata Enrichment Pipeline
|
||||||
|
- Album Domain & Cover Value Object
|
||||||
|
- Downloads API Tests
|
||||||
|
- Subsonic Search Encoding
|
||||||
|
- In-Memory Auth Repository Fakes
|
||||||
|
- Storage Stats Response Schemas
|
||||||
|
- Subsonic App-Password Crypto
|
||||||
|
- Artist Schemas & Endpoints
|
||||||
|
- Download Request/Response Schemas
|
||||||
|
- Settings & MusicBrainz User-Agent
|
||||||
|
- Subsonic API Integration Tests
|
||||||
|
- Playlist Endpoints
|
||||||
|
- Play History Domain & Repository
|
||||||
|
- DB Engine Lifecycle
|
||||||
|
- Shared Query Param Annotations
|
||||||
|
- Local Folder Source Backend
|
||||||
|
- Track Metadata Match Schemas
|
||||||
|
- Like Domain Entity (event log)
|
||||||
|
- Auth API Integration Tests
|
||||||
|
- Cover API Tests
|
||||||
|
- Upload/Stream API Tests
|
||||||
|
- Tag Parsing Helpers
|
||||||
|
- Metadata API Tests
|
||||||
|
- Subsonic Auth Service Tests
|
||||||
|
- Cover Art Serving
|
||||||
|
- Play History Schemas
|
||||||
|
- Refresh Token Repository
|
||||||
|
- Radio Endpoints
|
||||||
|
- Subsonic Legacy Password Decoding
|
||||||
|
- Management CLI
|
||||||
|
- Sources API Tests
|
||||||
|
- Hexagonal Architecture Layers (doc)
|
||||||
|
- Test DB Fixtures (conftest)
|
||||||
|
- Upload Response Schemas
|
||||||
|
- Cover Art Extraction (Vorbis/MP4)
|
||||||
|
- Storage Stats API Tests
|
||||||
|
- AcoustID Response Parser Tests
|
||||||
|
- Like Request/Response Schemas
|
||||||
|
- Streaming Endpoint
|
||||||
|
- Auth Token Issue/Refresh Flow
|
||||||
|
- Generic Job/Result Store Protocol
|
||||||
|
- Remote Library Materialization
|
||||||
|
- CI Docker Publish Workflow
|
||||||
|
- User Scrobbling Settings
|
||||||
|
- Domain Invariants (dedup, manual overwrite, offline tags)
|
||||||
|
- Alembic Migration Environment
|
||||||
|
- Cover Art Archive Client
|
||||||
|
- Health Endpoint Smoke Tests
|
||||||
|
- ASGI Middleware Protocol
|
||||||
|
- File Hashing Utilities
|
||||||
|
- Package: app.api
|
||||||
|
- Package: app.api.schemas
|
||||||
|
- Package: app.application
|
||||||
|
- Package: app.core
|
||||||
|
- Package: app.domain
|
||||||
|
- Package: app.infrastructure
|
||||||
|
- Package: metadata-enrichment adapters
|
||||||
|
- Package: source backends
|
||||||
|
- Package: storage adapters
|
||||||
|
- Package: app root (mcma-backend)
|
||||||
|
- Package: arq worker
|
||||||
|
- Package: arq tasks
|
||||||
|
- Config Conventions (doc)
|
||||||
|
- Subsonic Adapter Convention (doc)
|
||||||
|
- DB Session Convention (doc)
|
||||||
|
- Health Convention (doc)
|
||||||
|
- Invariant: Graceful Degradation
|
||||||
|
- Invariant: Likes Are Append-Only
|
||||||
|
- Invariant: Stable Track content_id
|
||||||
|
- Logging Convention (doc)
|
||||||
|
- Python 3.14 Lazy Annotations Note
|
||||||
|
- App-Passwords Mechanism (doc)
|
||||||
|
- Local Dev Setup (doc)
|
||||||
|
- Tooling Section (doc)
|
||||||
|
|
||||||
|
## God Nodes (most connected - your core abstractions)
|
||||||
|
1. `NotFoundError` - 94 edges
|
||||||
|
2. `UserService` - 55 edges
|
||||||
|
3. `Track` - 50 edges
|
||||||
|
4. `AuthenticationError` - 49 edges
|
||||||
|
5. `TrackRepository` - 49 edges
|
||||||
|
6. `SourceInfo` - 49 edges
|
||||||
|
7. `MetadataEnrichmentService` - 47 edges
|
||||||
|
8. `DownloadResult` - 47 edges
|
||||||
|
9. `get_settings()` - 45 edges
|
||||||
|
10. `FileStorage` - 43 edges
|
||||||
|
|
||||||
|
## Surprising Connections (you probably didn't know these)
|
||||||
|
- `test_id_malformed_rejected()` --indirect_call--> `NotFoundError` [INFERRED]
|
||||||
|
tests/test_subsonic_security.py → app/domain/errors.py
|
||||||
|
- `test_id_wrong_prefix_rejected()` --indirect_call--> `NotFoundError` [INFERRED]
|
||||||
|
tests/test_subsonic_security.py → app/domain/errors.py
|
||||||
|
- `acoustid_trust_score (trust high-confidence AcoustID over junk tags)` --semantically_similar_to--> `Invariant: never overwrite metadata_status=manual` [INFERRED] [semantically similar]
|
||||||
|
tests/fixtures/README.md → CLAUDE.md
|
||||||
|
- `FakeAcoustId` --uses--> `MetadataEnrichmentService` [INFERRED]
|
||||||
|
tests/test_metadata_service.py → app/application/metadata_service.py
|
||||||
|
- `FakeAlbumRepo` --uses--> `MetadataEnrichmentService` [INFERRED]
|
||||||
|
tests/test_metadata_service.py → app/application/metadata_service.py
|
||||||
|
|
||||||
|
## Import Cycles
|
||||||
|
- None detected.
|
||||||
|
|
||||||
|
## Hyperedges (group relationships)
|
||||||
|
- **Cross-cutting conventions (errors, config, logging, db sessions, migrations, health)** — claude_error_handling_convention, claude_config_convention, claude_logging_convention, claude_db_sessions_convention, claude_migrations_convention, claude_health_convention [EXTRACTED 1.00]
|
||||||
|
- **Non-negotiable domain invariants (for future sync + ML)** — claude_invariant_likes_event_log, claude_invariant_track_id_stable, claude_invariant_dedup, claude_invariant_graceful_degradation, claude_invariant_no_manual_overwrite, claude_invariant_no_heavy_work_in_request, claude_invariant_subsonic_compatibility [EXTRACTED 1.00]
|
||||||
|
- **Hexagonal architecture layers (domain/application/infrastructure/api/core/workers)** — claude_domain_layer, claude_application_layer, claude_infrastructure_layer, claude_api_layer, claude_core_layer, claude_workers_layer [EXTRACTED 1.00]
|
||||||
|
|
||||||
|
## Communities (114 total, 25 thin omitted)
|
||||||
|
|
||||||
|
### Community 0 - "Storage Backend & Disk Usage"
|
||||||
|
Cohesion: 0.05
|
||||||
|
Nodes (53): DiskUsage, ObjectStat, Value objects for file storage., Capacity of the volume backing the media store. ``None`` for backends (e.g., File storage operation failed., StorageError, Capacity of the volume backing the store, or ``None`` when the backend h, LocalFileStorage (+45 more)
|
||||||
|
|
||||||
|
### Community 1 - "Auth Schemas & Tokens"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (59): LoginRequest, BaseModel, Auth request/response schemas. Tokens are returned in the body (the client store, RefreshRequest, RegisterRequest, TokenResponse, BaseModel, Schemas for Subsonic app-password self-service (native /api/v1 surface). The Su (+51 more)
|
||||||
|
|
||||||
|
### Community 2 - "Error Mapping & Search Schemas"
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (44): _is_subsonic(), FastAPI, Maps domain exceptions to HTTP responses. The only place that knows both. Two s, register_exception_handlers(), ExternalSearchResponse, ExternalSearchResultOut, BaseModel, Schemas for searching external (fetch) sources — the §A4 discover screen. (+36 more)
|
||||||
|
|
||||||
|
### Community 3 - "Download Service Orchestration"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (24): DownloadRequest, EnrichEnqueuer, DownloadService — request external downloads and import their results. Two role, Outcome of asking for a download. Exactly one of the three states holds: th, EnrichEnqueuer, Path, Protocol, UploadService — handles user file uploads. (+16 more)
|
||||||
|
|
||||||
|
### Community 4 - "Playlist Schemas & API"
|
||||||
|
Cohesion: 0.11
|
||||||
|
Nodes (34): PlaylistAddTrack, PlaylistCreate, PlaylistOut, PlaylistUpdate, BaseModel, Playlist request/response schemas., add_playlist_track(), _build_playlist_out() (+26 more)
|
||||||
|
|
||||||
|
### Community 5 - "Album Repository"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (10): AlbumRepository, DownloadJobRepository, PlaylistRepository, datetime, UUID, Resolve/create an album bound to a remote ``(source, source_id)`` (lazy, Persistence for download jobs (plan §6.1). Drives the §A5 download manager a, Bump ``retry_count`` and return the new value. (+2 more)
|
||||||
|
|
||||||
|
### Community 6 - "ORM Base & Models"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (32): Base, Declarative base with a fixed naming convention. The naming convention makes Al, Base for all ORM models. Import models so Alembic sees their metadata., ORM model for albums., ORM model for artists., ORM model for download jobs (plan §6.1). Tracks a queued download through its l, LikeValue, LyricsStatus (+24 more)
|
||||||
|
|
||||||
|
### Community 7 - "Correlation ID Middleware & Config"
|
||||||
|
Cohesion: 0.09
|
||||||
|
Nodes (33): CorrelationIdMiddleware, HTTP middleware: bind a correlation id and log each request., Pure-ASGI middleware: reuse inbound ``X-Correlation-Id`` or mint one, bind i, app_version(), get_settings(), Application settings — single source of truth, sourced from environment. Nothin, Cached settings singleton. Patch the cache in tests via ``get_settings.cache_cle, _add_correlation_id() (+25 more)
|
||||||
|
|
||||||
|
### Community 8 - "Auth Services (native + Subsonic)"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (21): Authentication use cases: login, token refresh (rotation), logout, and access-to, SubsonicAuthService — app-password lifecycle + Subsonic auth verification. The, User entity. Admin is a single ``is_superuser`` flag — no role system in Phase, HistoryRepository, LikeRepository, PasswordHasher, Protocol, Ports — the contracts the application layer depends on. These are Protocols, no (+13 more)
|
||||||
|
|
||||||
|
### Community 9 - "Audio Tags & Metadata Test Fakes"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (28): AudioTags, Embedded tags read from the file itself (ID3 / Vorbis / MP4 …). Every field, _cover_service(), FakeAlbumRepo, FakeArtistRepo, FakeCoverExtractor, FakeCoverProvider, FakeTagReader (+20 more)
|
||||||
|
|
||||||
|
### Community 10 - "YouTube Source Backend"
|
||||||
|
Cohesion: 0.09
|
||||||
|
Nodes (30): Which backend imported a track. Drives ``is_replaceable`` (plan §6.6)., TrackSource, _default_download(), _default_search(), _libs_available(), Any, Path, ProgressCallback (+22 more)
|
||||||
|
|
||||||
|
### Community 11 - "Dependency Wiring (deps.py)"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (35): get_album_repository(), get_artist_repository(), get_auth_service(), get_download_service(), get_history_repository(), get_like_repository(), get_metadata_service(), get_password_hasher() (+27 more)
|
||||||
|
|
||||||
|
### Community 12 - "Library Stats & Track Availability"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (19): FormatBreakdown, LibraryStats, Per-container-format slice of the library (e.g. ``flac`` → 312 tracks)., Aggregate facts about everything the instance has stored. Computed from the, Track, Whether a track's audio is on local storage or still a remote placeholder (p, TrackAvailability, TrackModel (+11 more)
|
||||||
|
|
||||||
|
### Community 13 - "Library Import Service"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (18): ImportSummary, LibraryImportService, UUID, LibraryImportService — imports files discovered by an indexable source. Batch s, Source-backend value objects — framework-free. A *source* is a place tracks com, A single importable file discovered by an indexable source. ``source_id`` i, SourceFile, FakeArtistRepo (+10 more)
|
||||||
|
|
||||||
|
### Community 14 - "Subsonic Response Envelope"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (30): _build_xml(), _is_json(), Any, Element, Response, The Subsonic response envelope — one serializer, two wire formats. Every Subson, A ``status="failed"`` envelope carrying a Subsonic ``<error>``., Recursively drop ``None`` values so JSON output matches XML (no empty attrs). (+22 more)
|
||||||
|
|
||||||
|
### Community 15 - "Chromaprint Fingerprinting"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (21): FpcalcFingerprinter, Path, FpcalcFingerprinter — Chromaprint fingerprint via the ``fpcalc`` binary. ``fpca, Implements :class:`app.domain.ports.AudioFingerprinter`., MutagenTagReader, Implements :class:`app.domain.ports.AudioTagReader`., _acoustid_client(), _FakeAlbumRepo (+13 more)
|
||||||
|
|
||||||
|
### Community 16 - "Album/Search Response Schemas"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (28): AlbumOut, BaseModel, Album request/response schemas., LibrarySearchResponse, BaseModel, Search response schemas., _build_album_out(), get_album() (+20 more)
|
||||||
|
|
||||||
|
### Community 17 - "Pagination & Likes Endpoints"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (29): Shared pagination envelope for all paged list responses., TrackOut, get_likes(), AlbumRepoDep, ArtistRepoDep, Like endpoints. Likes are an append-only event-log — never updated in place., _build_track_out(), delete_track() (+21 more)
|
||||||
|
|
||||||
|
### Community 18 - "Metadata Enrichment Service"
|
||||||
|
Cohesion: 0.11
|
||||||
|
Nodes (11): _first_int(), MetadataEnrichmentService, _opt_str(), UUID, Explain a ``failed`` (no-identity) run in terms a user can act on: which, AcoustID candidates for the metadata editor's match picker (§A7). Read-, Fill in an album cover when it has none. Source order mirrors the tag-fi, CoverArt (+3 more)
|
||||||
|
|
||||||
|
### Community 19 - "Subsonic ID Encoding"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (28): decode_album(), decode_artist(), _decode_as(), decode_playlist(), decode_track(), encode(), encode_playlist(), encode_track() (+20 more)
|
||||||
|
|
||||||
|
### Community 20 - "Track & Artist Domain Entities"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (12): Artist, Track and Artist domain entities., Resolve/create an artist bound to a remote ``(source, source_id)`` (lazy, ArtistModel, AsyncSession, UUID, Artist repository — adapter over ``AsyncSession``., Resolve an artist by ``(source, source_id)`` first (re-browse/save dedup (+4 more)
|
||||||
|
|
||||||
|
### Community 21 - "Source Port Protocols"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (16): FetchableSource, IndexableSource, A registered source of tracks (mounted folder, YouTube, …). ``name`` is the, A source that enumerates files already on disk (e.g. the local folder)., A source that can be searched by free text (e.g. YouTube Music). Returns ``, A source that can download a previously-discovered item to local disk. ``fe, SearchableSource, SourceBackend (+8 more)
|
||||||
|
|
||||||
|
### Community 22 - "User Management Service"
|
||||||
|
Cohesion: 0.19
|
||||||
|
Nodes (24): AuthService, User-management use cases: admin CRUD plus self-service password change. Deleti, UserService, AlreadyExistsError, AuthenticationError, Resource conflicts with an existing one (e.g. duplicate)., Caller could not be authenticated., env() (+16 more)
|
||||||
|
|
||||||
|
### Community 23 - "Metadata Enrichment Value Objects"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (12): Fingerprint, Value objects for the metadata-enrichment pipeline (plan §6.2). Pure data carri, Chromaprint fingerprint plus the decoded duration (both needed by AcoustID)., A single AcoustID result, flattened to the fields enrichment cares about. `, RecordingMatch, AcoustIdHttpClient, _parse_matches(), _parse_one() (+4 more)
|
||||||
|
|
||||||
|
### Community 24 - "DB Session & Health Checks"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (24): get_session(), AsyncSession, Request-scoped DB session. Commits on success, rolls back on exception., _check_db(), _check_ml(), _check_redis(), health(), HealthResponse (+16 more)
|
||||||
|
|
||||||
|
### Community 25 - "Download Service Tests"
|
||||||
|
Cohesion: 0.16
|
||||||
|
Nodes (14): DownloadService, FakeArtistRepo, FakeJobRepo, FakeStorage, FakeTrackRepo, Path, UUID, Unit tests for DownloadService — DB-free, in-memory fakes. (+6 more)
|
||||||
|
|
||||||
|
### Community 26 - "Download Job Domain Entity"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (10): DownloadJob, Download job domain entity (plan §6.1). A queued fetch from an external source,, An unfinished (queued/downloading/enriching) job for the same item, if a, DownloadJobModel, DownloadStatus, Lifecycle of a download job (plan §6.1)., datetime, UUID (+2 more)
|
||||||
|
|
||||||
|
### Community 27 - "Credentials & User Repository"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (10): Credentials, A user paired with their stored password hash. Stays inside the application, A user paired with their *encrypted* Subsonic app-password. ``password_enc`, SubsonicCredentials, UserModel, AsyncSession, UUID, User repository — adapter over ``AsyncSession`` implementing ``app.domain.ports. (+2 more)
|
||||||
|
|
||||||
|
### Community 28 - "Streaming Auth Dependencies"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (13): get_current_superuser(), get_current_user(), get_streaming_user(), AuthServiceDep, CurrentUser, Authenticate a stream request. The browser ``<audio>`` element cannot send, UUID, Admin-driven password reset. Revokes all sessions. (+5 more)
|
||||||
|
|
||||||
|
### Community 29 - "Subsonic Browsing Endpoints"
|
||||||
|
Cohesion: 0.26
|
||||||
|
Nodes (26): _albums_for_artist(), _artists_index(), _decorate_albums(), get_album(), get_album_list(), get_album_list2(), get_artist(), get_artists() (+18 more)
|
||||||
|
|
||||||
|
### Community 30 - "Remote Library Service Tests"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (17): RemoteLibraryService, Input is well-formed but violates a business rule., ValidationError, FakeArtistRepo, FakeJobRepo, FakeTrackRepo, _local_track(), UUID (+9 more)
|
||||||
|
|
||||||
|
### Community 31 - "Worker Session Scope & Download Task"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (24): Transactional session for workers/scripts: commit on success, rollback on error., session_scope(), AsyncSession, SqlAlchemyDownloadJobRepository, build_source_registry(), download_track(), _handle_failure(), _import_result() (+16 more)
|
||||||
|
|
||||||
|
### Community 32 - "Metadata Enrichment Pipeline"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (15): EnrichmentResult, MetadataEnrichmentService — the §6.2 pipeline orchestrator. Order (tag-first):, AcoustIdClient, AudioFingerprinter, AudioTagReader, CoverArtExtractor, CoverArtProvider, Path (+7 more)
|
||||||
|
|
||||||
|
### Community 33 - "Album Domain & Cover Value Object"
|
||||||
|
Cohesion: 0.19
|
||||||
|
Nodes (11): Album, Cover-art value object — raw image bytes plus their MIME type. Crosses the doma, Domain entities and value objects — pure, framework-free., AlbumModel, AsyncSession, UUID, Album repository — adapter over ``AsyncSession``., Resolve an album by ``(title, artist_id)``, creating it if absent. Backf (+3 more)
|
||||||
|
|
||||||
|
### Community 34 - "Downloads API Tests"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (17): MonkeyPatch, FakeFetchSource, _login(), Any, AsyncClient, Path, Integration tests for downloads + external search. Requires a reachable Postgre, Remote browse (plan: Model C) — a fresh hit isn't in the library; after savi (+9 more)
|
||||||
|
|
||||||
|
### Community 35 - "Subsonic Search Encoding"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (21): encode_album(), encode_artist(), AlbumRepoDep, ArtistRepoDep, ge, le, Query, Response (+13 more)
|
||||||
|
|
||||||
|
### Community 36 - "In-Memory Auth Repository Fakes"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (8): InMemoryRefreshTokenRepository, InMemoryUserRepository, datetime, UUID, In-memory port implementations for fast, DB-free unit tests., _Stored, _Token, env()
|
||||||
|
|
||||||
|
### Community 37 - "Storage Stats Response Schemas"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (20): DiskUsageOut, FormatBreakdownOut, GenreCountOut, BaseModel, Storage / library statistics response schemas (§A6)., Everything the Storage screen needs in a single call., StorageStatsOut, get_broken_files() (+12 more)
|
||||||
|
|
||||||
|
### Community 38 - "Subsonic App-Password Crypto"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (15): generate_subsonic_password(), UUID, A fresh, high-entropy Subsonic app-password (url-safe, ~24 chars)., Symmetric encrypt/decrypt for the recoverable Subsonic app-password. Subson, SubsonicPasswordCipher, test_reveal_generates_then_is_stable(), test_rotate_then_authenticate(), Unit tests for Subsonic crypto + id helpers (no DB, no network). (+7 more)
|
||||||
|
|
||||||
|
### Community 39 - "Artist Schemas & Endpoints"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (18): ArtistOut, BaseModel, Artist request/response schemas., PagedResponse, BaseModel, get_artist(), get_artist_albums(), get_artist_tracks() (+10 more)
|
||||||
|
|
||||||
|
### Community 40 - "Download Request/Response Schemas"
|
||||||
|
Cohesion: 0.23
|
||||||
|
Nodes (17): DownloadCreate, DownloadCreateResponse, DownloadJobOut, BaseModel, Schemas for the download job endpoints (§A5 download manager)., Request to download an item discovered on a fetch source., Result of requesting a download. ``already_in_library`` → the item was alre, cancel_download() (+9 more)
|
||||||
|
|
||||||
|
### Community 41 - "Settings & MusicBrainz User-Agent"
|
||||||
|
Cohesion: 0.18
|
||||||
|
Nodes (13): User-Agent sent to MusicBrainz/AcoustID: ``MCMA/<version> ( <contact> )``., Settings, JwtTokenService, Security adapters: password hashing (argon2 via pwdlib) and JWT (pyjwt). These, Issues and verifies HS256 JWTs for access + refresh tokens. TTLs come from, BaseSettings, Unit tests for the security adapters (no DB, no network)., _settings() (+5 more)
|
||||||
|
|
||||||
|
### Community 42 - "Subsonic API Integration Tests"
|
||||||
|
Cohesion: 0.28
|
||||||
|
Nodes (18): _auth_params(), _login(), AsyncClient, Integration tests for the Subsonic /rest layer (happy path per endpoint group)., _seed_track(), _setup(), _subsonic_password(), test_get_album_list2() (+10 more)
|
||||||
|
|
||||||
|
### Community 43 - "Playlist Endpoints"
|
||||||
|
Cohesion: 0.34
|
||||||
|
Nodes (17): create_playlist(), delete_playlist(), get_playlist(), get_playlists(), _owned_playlist(), _playlist_dict(), _playlist_songs(), AlbumRepoDep (+9 more)
|
||||||
|
|
||||||
|
### Community 44 - "Play History Domain & Repository"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (10): PlayHistoryEntry, Play history domain entity — append-only scrobble log entry., PlayHistoryModel, AsyncSession, datetime, UUID, Play history repository — adapter over ``AsyncSession``., SqlAlchemyHistoryRepository (+2 more)
|
||||||
|
|
||||||
|
### Community 45 - "DB Engine Lifecycle"
|
||||||
|
Cohesion: 0.18
|
||||||
|
Nodes (17): dispose_engine(), get_engine(), Dispose the pooled engine on shutdown. Safe to call if never initialized., AsyncEngine, api(), _db_reachable(), api(), _db_reachable() (+9 more)
|
||||||
|
|
||||||
|
### Community 46 - "Shared Query Param Annotations"
|
||||||
|
Cohesion: 0.23
|
||||||
|
Nodes (15): ge, HistoryRepoDep, le, LikeRepoDep, Query, Response, SubsonicFormat, SubsonicUser (+7 more)
|
||||||
|
|
||||||
|
### Community 47 - "Local Folder Source Backend"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (12): LocalFolderSource, Path, Implements :class:`app.domain.ports.IndexableSource`., Path, Unit tests for the local-folder source + registry (no DB, no network)., _settings(), test_info_reports_kind_and_availability(), test_is_available_false_when_missing() (+4 more)
|
||||||
|
|
||||||
|
### Community 48 - "Track Metadata Match Schemas"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (15): MaterializeResponse, MetadataApply, MetadataMatch, MetadataMatchesOut, BaseModel, Track request/response schemas., One AcoustID candidate for the metadata editor's match picker (§A7)., Manual edits / accepted match applied via ``PUT /tracks/{id}/metadata``. Se (+7 more)
|
||||||
|
|
||||||
|
### Community 49 - "Like Domain Entity (event log)"
|
||||||
|
Cohesion: 0.23
|
||||||
|
Nodes (8): Like, Like domain entity — append-only event log entry., LikeModel, AsyncSession, UUID, Like repository — adapter over ``AsyncSession``. Likes are an append-only event, SqlAlchemyLikeRepository, _to_entity()
|
||||||
|
|
||||||
|
### Community 50 - "Auth API Integration Tests"
|
||||||
|
Cohesion: 0.28
|
||||||
|
Nodes (15): _login(), AsyncClient, Integration tests for the auth + admin HTTP surface. These require a reachable, test_admin_create_duplicate_conflicts(), test_admin_creates_user_and_nonadmin_forbidden(), test_deactivated_user_cannot_login(), test_login_and_me(), test_login_bad_credentials() (+7 more)
|
||||||
|
|
||||||
|
### Community 51 - "Cover API Tests"
|
||||||
|
Cohesion: 0.30
|
||||||
|
Nodes (14): api(), _db_reachable(), _login(), AsyncClient, Path, UUID, Integration tests for the native cover-art endpoints. Seeds an album with a sto, Create an artist + album (+ optional cover file) + track. Returns ``(album_i (+6 more)
|
||||||
|
|
||||||
|
### Community 52 - "Upload/Stream API Tests"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (14): api(), _db_reachable(), _login(), AsyncClient, Path, Integration tests for upload and streaming endpoints. Requires a reachable Post, test_list_tracks_filters_by_source(), test_stream_full() (+6 more)
|
||||||
|
|
||||||
|
### Community 53 - "Tag Parsing Helpers"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (10): _first(), _parse_track_number(), _parse_year(), Path, MutagenTagReader — reads embedded tags from a local audio file. The offline fir, EasyXxx tags expose values as lists; take the first non-empty string., Unit tests for the mutagen tag-parsing helpers — pure, no files., test_first_takes_head_of_list() (+2 more)
|
||||||
|
|
||||||
|
### Community 54 - "Metadata API Tests"
|
||||||
|
Cohesion: 0.35
|
||||||
|
Nodes (13): api(), _db_reachable(), _login(), AsyncClient, Path, Integration tests for the metadata-editor endpoints (§A7, §1H). Requires a reac, test_apply_metadata_not_found(), test_apply_metadata_partial_update() (+5 more)
|
||||||
|
|
||||||
|
### Community 55 - "Subsonic Auth Service Tests"
|
||||||
|
Cohesion: 0.27
|
||||||
|
Nodes (13): _md5(), Unit tests for SubsonicAuthService — verification + app-password lifecycle. DB-, _service_with_user(), test_authenticate_enc_password_success(), test_authenticate_inactive_user_fails(), test_authenticate_missing_credentials_is_validation_error(), test_authenticate_missing_username_is_validation_error(), test_authenticate_no_password_set_fails() (+5 more)
|
||||||
|
|
||||||
|
### Community 56 - "Cover Art Serving"
|
||||||
|
Cohesion: 0.19
|
||||||
|
Nodes (12): _content_type_for(), StreamingResponse, UUID, Shared cover-art serving helper (presentation). Streams a stored cover image fr, Stream a stored cover by its storage key. Raises ``NotFoundError`` if the ob, The album that owns a track (cover lives on the album), or ``None``., resolve_album_for_track(), stream_cover() (+4 more)
|
||||||
|
|
||||||
|
### Community 57 - "Play History Schemas"
|
||||||
|
Cohesion: 0.24
|
||||||
|
Nodes (11): HistoryIn, HistoryOut, BaseModel, Play history request/response schemas., get_history(), CurrentUser, HistoryRepoDep, Response (+3 more)
|
||||||
|
|
||||||
|
### Community 58 - "Refresh Token Repository"
|
||||||
|
Cohesion: 0.27
|
||||||
|
Nodes (7): A persisted, revocable refresh token (offline-first sessions). Stores only, RefreshTokenModel, AsyncSession, datetime, UUID, Refresh-token repository — adapter implementing ``app.domain.ports.RefreshTokenR, SqlAlchemyRefreshTokenRepository
|
||||||
|
|
||||||
|
### Community 59 - "Radio Endpoints"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (9): Native REST API, version 1. Aggregates feature routers under ``/api/v1``., next_radio_track(), Any, Radio / continuous-mix endpoints. Degrades gracefully when ML service is down., start_radio(), get_changes(), push_changes(), Any (+1 more)
|
||||||
|
|
||||||
|
### Community 60 - "Subsonic Legacy Password Decoding"
|
||||||
|
Cohesion: 0.24
|
||||||
|
Nodes (8): _decode_legacy_password(), _md5_hex(), UUID, Decode a Subsonic ``p`` param: ``enc:<hex>`` (hex-encoded) or plaintext., Resolve Subsonic query auth params to a domain :class:`User`. Raises :c, Generate a fresh app-password, store it encrypted, return the plaintext., Return the current app-password, generating one on first access., SubsonicAuthService
|
||||||
|
|
||||||
|
### Community 61 - "Management CLI"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (8): _cmd_create_admin(), _create_admin(), main(), Management CLI (``mcma``). Commands: * ``mcma version`` — print the bac, Argon2PasswordHasher, argon2id hasher with sensible defaults from pwdlib., Namespace, test_password_hash_roundtrip()
|
||||||
|
|
||||||
|
### Community 62 - "Sources API Tests"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (11): Any, scan_local_folder(), api(), _db_reachable(), _login(), AsyncClient, Path, Integration tests for sources: enumeration + the real import path. Requires a r (+3 more)
|
||||||
|
|
||||||
|
### Community 63 - "Hexagonal Architecture Layers (doc)"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (12): app/api — driving adapter (FastAPI), app/application — use cases / services, Composition roots (app/main.py, app/api/deps.py), app/core — cross-cutting concerns, app/domain — pure business core, Error handling convention (domain errors → HTTP in app/api/errors.py), Hexagonal architecture (ports & adapters), app/infrastructure — driven adapters (+4 more)
|
||||||
|
|
||||||
|
### Community 64 - "Test DB Fixtures (conftest)"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (11): _base_database_url(), client(), _create_test_db_if_missing(), _ensure_test_database(), AsyncClient, Shared test fixtures. The ASGI app is driven in-process via httpx + asgi-lifesp, Resolve the DB URL the app *would* use, mirroring pydantic-settings preceden, Return ``url`` with its database name swapped for ``name``. (+3 more)
|
||||||
|
|
||||||
|
### Community 65 - "Upload Response Schemas"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (9): BaseModel, Schemas for upload responses., UploadResponse, CurrentUser, Local file upload endpoint., upload_file(), File, UploadFile (+1 more)
|
||||||
|
|
||||||
|
### Community 66 - "Cover Art Extraction (Vorbis/MP4)"
|
||||||
|
Cohesion: 0.24
|
||||||
|
Nodes (9): _decode_vorbis_picture(), _from_picture(), _front_or_first(), MutagenCoverExtractor, Any, Path, Prefer the front-cover picture (type 3), else the first available., Implements :class:`app.domain.ports.CoverArtExtractor`. (+1 more)
|
||||||
|
|
||||||
|
### Community 67 - "Storage Stats API Tests"
|
||||||
|
Cohesion: 0.35
|
||||||
|
Nodes (10): api(), _db_reachable(), _login(), AsyncClient, Path, Integration tests for the storage statistics endpoint (§A6). Requires a reachab, test_storage_stats_counts_uploads(), test_storage_stats_empty_library() (+2 more)
|
||||||
|
|
||||||
|
### Community 68 - "AcoustID Response Parser Tests"
|
||||||
|
Cohesion: 0.42
|
||||||
|
Nodes (9): _parse_best_match(), _payload_with_results(), Unit tests for the AcoustID response parser — pure, no network., test_empty_results_returns_none(), test_error_status_returns_none(), test_non_dict_payload_returns_none(), test_parses_full_recording(), test_picks_highest_score() (+1 more)
|
||||||
|
|
||||||
|
### Community 69 - "Like Request/Response Schemas"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (8): LikeEvent, LikeState, BaseModel, Like request/response schemas., add_like(), get_likes_state(), CurrentUser, LikeRepoDep
|
||||||
|
|
||||||
|
### Community 70 - "Streaming Endpoint"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (8): alias, Header, StreamingResponse, StreamingServiceDep, StreamUser, UUID, Audio streaming endpoint — direct stream with Range support., stream_track()
|
||||||
|
|
||||||
|
### Community 71 - "Auth Token Issue/Refresh Flow"
|
||||||
|
Cohesion: 0.31
|
||||||
|
Nodes (5): _hash_token(), UUID, At-rest hash of a refresh token. A signed JWT is high-entropy, so a fast SHA, The access + refresh pair returned to clients on login/refresh., TokenPair
|
||||||
|
|
||||||
|
### Community 72 - "Generic Job/Result Store Protocol"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (3): UUID, Remove the job record. True mid-flight cancellation of an in-progress yt, Store a freshly downloaded file and create a minimal ``pending`` track.
|
||||||
|
|
||||||
|
### Community 73 - "Remote Library Materialization"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (6): MaterializeOutcome, UUID, RemoteLibraryService — save-to-library + materialize for remote browse hits (pla, Result of requesting materialization. ``job`` is ``None`` when the track is, Persist a placeholder for a remote browse hit. Idempotent: a hit already, Kick off (or report on) materializing a placeholder track. Already-loca
|
||||||
|
|
||||||
|
### Community 74 - "CI Docker Publish Workflow"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (8): Docker Buildx, dockerfiles/Dockerfile.prod, Gitea container registry, build job (Docker Build & Publish), cleanup job (Prune old image versions), KEEP_VERSIONS env (retention policy), push job (Docker Build & Publish), Build (Docker) section
|
||||||
|
|
||||||
|
### Community 75 - "User Scrobbling Settings"
|
||||||
|
Cohesion: 0.43
|
||||||
|
Nodes (6): get_scrobbling_settings(), get_settings(), Any, User settings endpoints, including scrobbling configuration., set_scrobbling_settings(), update_settings()
|
||||||
|
|
||||||
|
### Community 76 - "Domain Invariants (dedup, manual overwrite, offline tags)"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (7): Invariant: dedup on (source, source_id) and acoustid_fingerprint, Invariant: no heavy work in the request cycle (goes to arq workers), Invariant: never overwrite metadata_status=manual, Sources & importing music (source backends, local backend), acoustid_trust_score (trust high-confidence AcoustID over junk tags), Offline tag reader (deterministic, always runs), scarlet_fire_otis_mcdonald.mp3 test fixture
|
||||||
|
|
||||||
|
### Community 77 - "Alembic Migration Environment"
|
||||||
|
Cohesion: 0.40
|
||||||
|
Nodes (4): Alembic environment — async, settings-driven, model-aware. The DB URL comes fro, _run_migrations(), run_migrations_online(), Connection
|
||||||
|
|
||||||
|
### Community 79 - "Health Endpoint Smoke Tests"
|
||||||
|
Cohesion: 0.47
|
||||||
|
Nodes (5): AsyncClient, Smoke tests for the health endpoints., test_correlation_id_echoed(), test_liveness_ok(), test_readiness_reports_checks()
|
||||||
|
|
||||||
|
### Community 80 - "ASGI Middleware Protocol"
|
||||||
|
Cohesion: 0.50
|
||||||
|
Nodes (3): Receive, Scope, Send
|
||||||
|
|
||||||
|
### Community 81 - "File Hashing Utilities"
|
||||||
|
Cohesion: 0.50
|
||||||
|
Nodes (3): Path, File hashing utilities., sha256_of_file()
|
||||||
|
|
||||||
|
## Ambiguous Edges - Review These
|
||||||
|
- `build job (Docker Build & Publish)` → `Build (Docker) section` [AMBIGUOUS]
|
||||||
|
README.md · relation: conceptually_related_to
|
||||||
|
- `Composition roots (app/main.py, app/api/deps.py)` → `Migrations convention (async, settings-driven Alembic)` [AMBIGUOUS]
|
||||||
|
CLAUDE.md · relation: references
|
||||||
|
|
||||||
|
## Knowledge Gaps
|
||||||
|
- **16 isolated node(s):** `WorkerSettings`, `KEEP_VERSIONS env (retention policy)`, `dockerfiles/Dockerfile.prod`, `Docker Buildx`, `app/application — use cases / services` (+11 more)
|
||||||
|
These have ≤1 connection - possible missing edges or undocumented components.
|
||||||
|
- **25 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||||
|
|
||||||
|
## Suggested Questions
|
||||||
|
_Questions this graph is uniquely positioned to answer:_
|
||||||
|
|
||||||
|
- **What is the exact relationship between `build job (Docker Build & Publish)` and `Build (Docker) section`?**
|
||||||
|
_Edge tagged AMBIGUOUS (relation: conceptually_related_to) - confidence is low._
|
||||||
|
- **What is the exact relationship between `Composition roots (app/main.py, app/api/deps.py)` and `Migrations convention (async, settings-driven Alembic)`?**
|
||||||
|
_Edge tagged AMBIGUOUS (relation: references) - confidence is low._
|
||||||
|
- **Why does `NotFoundError` connect `Artist Schemas & Endpoints` to `Error Mapping & Search Schemas`, `Download Service Orchestration`, `Playlist Schemas & API`, `ORM Base & Models`, `Auth Services (native + Subsonic)`, `Library Stats & Track Availability`, `Album/Search Response Schemas`, `Pagination & Likes Endpoints`, `Subsonic ID Encoding`, `Source Port Protocols`, `User Management Service`, `Download Service Tests`, `Credentials & User Repository`, `Streaming Auth Dependencies`, `Subsonic Browsing Endpoints`, `Remote Library Service Tests`, `Worker Session Scope & Download Task`, `Subsonic App-Password Crypto`, `Playlist Endpoints`, `Shared Query Param Annotations`, `Track Metadata Match Schemas`, `Cover Art Serving`, `Play History Schemas`, `Subsonic Legacy Password Decoding`, `Generic Job/Result Store Protocol`, `Remote Library Materialization`?**
|
||||||
|
_High betweenness centrality (0.119) - this node is a cross-community bridge._
|
||||||
|
- **Why does `get_settings()` connect `Correlation ID Middleware & Config` to `Auth Schemas & Tokens`, `Error Mapping & Search Schemas`, `Downloads API Tests`, `Storage Stats API Tests`, `Settings & MusicBrainz User-Agent`, `Subsonic API Integration Tests`, `Dependency Wiring (deps.py)`, `Alembic Migration Environment`, `DB Engine Lifecycle`, `Chromaprint Fingerprinting`, `Auth API Integration Tests`, `Cover API Tests`, `Upload/Stream API Tests`, `Metadata API Tests`, `DB Session & Health Checks`, `Sources API Tests`, `Worker Session Scope & Download Task`?**
|
||||||
|
_High betweenness centrality (0.055) - this node is a cross-community bridge._
|
||||||
|
- **Why does `UserService` connect `User Management Service` to `Downloads API Tests`, `Storage Stats API Tests`, `In-Memory Auth Repository Fakes`, `Artist Schemas & Endpoints`, `Auth Services (native + Subsonic)`, `Subsonic API Integration Tests`, `Dependency Wiring (deps.py)`, `DB Engine Lifecycle`, `Auth API Integration Tests`, `Cover API Tests`, `Upload/Stream API Tests`, `Metadata API Tests`, `Streaming Auth Dependencies`, `Management CLI`, `Sources API Tests`?**
|
||||||
|
_High betweenness centrality (0.040) - this node is a cross-community bridge._
|
||||||
|
- **Are the 22 inferred relationships involving `NotFoundError` (e.g. with `IdKind` and `DownloadRequest`) actually correct?**
|
||||||
|
_`NotFoundError` has 22 INFERRED edges - model-reasoned connections that need verification._
|
||||||
|
- **Are the 7 inferred relationships involving `UserService` (e.g. with `AlreadyExistsError` and `AuthenticationError`) actually correct?**
|
||||||
|
_`UserService` has 7 INFERRED edges - model-reasoned connections that need verification._
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"date": "2026-07-11T12:41:35.149516+00:00",
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 44973,
|
||||||
|
"files": 180
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_input_tokens": 0,
|
||||||
|
"total_output_tokens": 44973
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,902 @@
|
|||||||
|
{
|
||||||
|
"alembic/env.py": {
|
||||||
|
"mtime": 1780740461.4551063,
|
||||||
|
"ast_hash": "0c0d6933ed9956cee4306f1d628312c0",
|
||||||
|
"semantic_hash": "0c0d6933ed9956cee4306f1d628312c0"
|
||||||
|
},
|
||||||
|
"alembic/versions/20260602_0001-0001_auth_users.py": {
|
||||||
|
"mtime": 1780740461.4554832,
|
||||||
|
"ast_hash": "28e6ac2a41d82a2e7c103fd76dada6e2",
|
||||||
|
"semantic_hash": "28e6ac2a41d82a2e7c103fd76dada6e2"
|
||||||
|
},
|
||||||
|
"alembic/versions/20260607_1137-e670d6c41d0c_music_schema.py": {
|
||||||
|
"mtime": 1780832300.9611268,
|
||||||
|
"ast_hash": "b461f41c1538fe4880c5b364af086b2b",
|
||||||
|
"semantic_hash": "b461f41c1538fe4880c5b364af086b2b"
|
||||||
|
},
|
||||||
|
"alembic/versions/20260608_1132-rename_track_file_path_to_storage_uri.py": {
|
||||||
|
"mtime": 1780907569.36547,
|
||||||
|
"ast_hash": "bed8ab7a1fc6f3bf362979084f6b0de3",
|
||||||
|
"semantic_hash": "bed8ab7a1fc6f3bf362979084f6b0de3"
|
||||||
|
},
|
||||||
|
"alembic/versions/20260608_1200-subsonic_app_password.py": {
|
||||||
|
"mtime": 1780930604.4753392,
|
||||||
|
"ast_hash": "3b3a637a91853472c9669919993a1659",
|
||||||
|
"semantic_hash": "3b3a637a91853472c9669919993a1659"
|
||||||
|
},
|
||||||
|
"alembic/versions/20260613_1300-track_enrichment_outcome.py": {
|
||||||
|
"mtime": 1781343651.3575113,
|
||||||
|
"ast_hash": "05aec9fc6eb0b328c06985c4d4459aac",
|
||||||
|
"semantic_hash": "05aec9fc6eb0b328c06985c4d4459aac"
|
||||||
|
},
|
||||||
|
"alembic/versions/20260614_1000-download_job_track_id.py": {
|
||||||
|
"mtime": 1781430910.3095262,
|
||||||
|
"ast_hash": "48ded0bc464b15a195264aa87d320035",
|
||||||
|
"semantic_hash": "48ded0bc464b15a195264aa87d320035"
|
||||||
|
},
|
||||||
|
"alembic/versions/20260614_1125-dc126696f5a6_remote_placeholders_track_availability_.py": {
|
||||||
|
"mtime": 1781436440.6638522,
|
||||||
|
"ast_hash": "75e076feded78601a2d3f30875218cf1",
|
||||||
|
"semantic_hash": "75e076feded78601a2d3f30875218cf1"
|
||||||
|
},
|
||||||
|
"app/__init__.py": {
|
||||||
|
"mtime": 1780740461.4555705,
|
||||||
|
"ast_hash": "ef646aa8ef2d6423fd5cf4f08d1664bb",
|
||||||
|
"semantic_hash": "ef646aa8ef2d6423fd5cf4f08d1664bb"
|
||||||
|
},
|
||||||
|
"app/api/__init__.py": {
|
||||||
|
"mtime": 1780740461.4556532,
|
||||||
|
"ast_hash": "929a4d345f2fff3676fa64e535e9aeb7",
|
||||||
|
"semantic_hash": "929a4d345f2fff3676fa64e535e9aeb7"
|
||||||
|
},
|
||||||
|
"app/api/covers.py": {
|
||||||
|
"mtime": 1781288153.596066,
|
||||||
|
"ast_hash": "6ae3715ac407329443975ad1c92f5783",
|
||||||
|
"semantic_hash": "6ae3715ac407329443975ad1c92f5783"
|
||||||
|
},
|
||||||
|
"app/api/deps.py": {
|
||||||
|
"mtime": 1781449490.956348,
|
||||||
|
"ast_hash": "b56df2ea8928b439f6a8e53e0781a41c",
|
||||||
|
"semantic_hash": "b56df2ea8928b439f6a8e53e0781a41c"
|
||||||
|
},
|
||||||
|
"app/api/errors.py": {
|
||||||
|
"mtime": 1780930796.1327183,
|
||||||
|
"ast_hash": "b1858380e0a8507efd9e50ec90a2f758",
|
||||||
|
"semantic_hash": "b1858380e0a8507efd9e50ec90a2f758"
|
||||||
|
},
|
||||||
|
"app/api/health.py": {
|
||||||
|
"mtime": 1780740461.456011,
|
||||||
|
"ast_hash": "32c1573c5f745065ce6c99a9704f2598",
|
||||||
|
"semantic_hash": "32c1573c5f745065ce6c99a9704f2598"
|
||||||
|
},
|
||||||
|
"app/api/middleware.py": {
|
||||||
|
"mtime": 1780740461.4560637,
|
||||||
|
"ast_hash": "00ea3e4a5b609e34ec5758a31e89ddf4",
|
||||||
|
"semantic_hash": "00ea3e4a5b609e34ec5758a31e89ddf4"
|
||||||
|
},
|
||||||
|
"app/api/rest/__init__.py": {
|
||||||
|
"mtime": 1780740461.456142,
|
||||||
|
"ast_hash": "84bf0012853cb9c37881975963b8f69b",
|
||||||
|
"semantic_hash": "84bf0012853cb9c37881975963b8f69b"
|
||||||
|
},
|
||||||
|
"app/api/rest/annotation.py": {
|
||||||
|
"mtime": 1780931171.6711574,
|
||||||
|
"ast_hash": "871f7edd81a0df68c6926dd0b2576804",
|
||||||
|
"semantic_hash": "871f7edd81a0df68c6926dd0b2576804"
|
||||||
|
},
|
||||||
|
"app/api/rest/browsing.py": {
|
||||||
|
"mtime": 1780931241.2565289,
|
||||||
|
"ast_hash": "d95d5a21b6a7d8a30cc0474a59f3fd67",
|
||||||
|
"semantic_hash": "d95d5a21b6a7d8a30cc0474a59f3fd67"
|
||||||
|
},
|
||||||
|
"app/api/rest/envelope.py": {
|
||||||
|
"mtime": 1780931220.9899473,
|
||||||
|
"ast_hash": "4a5f20478b2a0859a801f698b41cfd1f",
|
||||||
|
"semantic_hash": "4a5f20478b2a0859a801f698b41cfd1f"
|
||||||
|
},
|
||||||
|
"app/api/rest/ids.py": {
|
||||||
|
"mtime": 1780931210.7758856,
|
||||||
|
"ast_hash": "6305e21b8c656577c90c5e23ca964c0e",
|
||||||
|
"semantic_hash": "6305e21b8c656577c90c5e23ca964c0e"
|
||||||
|
},
|
||||||
|
"app/api/rest/media.py": {
|
||||||
|
"mtime": 1781436734.361887,
|
||||||
|
"ast_hash": "4becad2b17cf9f433951b61bc6e73f7e",
|
||||||
|
"semantic_hash": "4becad2b17cf9f433951b61bc6e73f7e"
|
||||||
|
},
|
||||||
|
"app/api/rest/playlists.py": {
|
||||||
|
"mtime": 1780931241.256631,
|
||||||
|
"ast_hash": "1ebcb252330e19128cfaa9b1e6267d5c",
|
||||||
|
"semantic_hash": "1ebcb252330e19128cfaa9b1e6267d5c"
|
||||||
|
},
|
||||||
|
"app/api/rest/search.py": {
|
||||||
|
"mtime": 1780931241.2568173,
|
||||||
|
"ast_hash": "49a1337453ed04bccb2d2b09d3732d32",
|
||||||
|
"semantic_hash": "49a1337453ed04bccb2d2b09d3732d32"
|
||||||
|
},
|
||||||
|
"app/api/rest/serializers.py": {
|
||||||
|
"mtime": 1781436734.3620095,
|
||||||
|
"ast_hash": "6e0101069a23c1240925d48f760747ed",
|
||||||
|
"semantic_hash": "6e0101069a23c1240925d48f760747ed"
|
||||||
|
},
|
||||||
|
"app/api/rest/system.py": {
|
||||||
|
"mtime": 1780930863.645398,
|
||||||
|
"ast_hash": "a02d09f012974d5efc89f4304bf43d06",
|
||||||
|
"semantic_hash": "a02d09f012974d5efc89f4304bf43d06"
|
||||||
|
},
|
||||||
|
"app/api/schemas/__init__.py": {
|
||||||
|
"mtime": 1780740461.456606,
|
||||||
|
"ast_hash": "f5b068aa85d5a85bb2cb4d76f6b12c7c",
|
||||||
|
"semantic_hash": "f5b068aa85d5a85bb2cb4d76f6b12c7c"
|
||||||
|
},
|
||||||
|
"app/api/schemas/album.py": {
|
||||||
|
"mtime": 1781341102.6767592,
|
||||||
|
"ast_hash": "1135cc7fd89b74d1b1b009a1234ca40a",
|
||||||
|
"semantic_hash": "1135cc7fd89b74d1b1b009a1234ca40a"
|
||||||
|
},
|
||||||
|
"app/api/schemas/artist.py": {
|
||||||
|
"mtime": 1780838998.0720186,
|
||||||
|
"ast_hash": "07609fc29b19aba31c64b4391c7e3238",
|
||||||
|
"semantic_hash": "07609fc29b19aba31c64b4391c7e3238"
|
||||||
|
},
|
||||||
|
"app/api/schemas/auth.py": {
|
||||||
|
"mtime": 1781089084.274189,
|
||||||
|
"ast_hash": "f6e452b57b1016f1f7ad285714389873",
|
||||||
|
"semantic_hash": "f6e452b57b1016f1f7ad285714389873"
|
||||||
|
},
|
||||||
|
"app/api/schemas/download.py": {
|
||||||
|
"mtime": 1781431405.8818817,
|
||||||
|
"ast_hash": "79adf8ba098e7a75afce6a8b151670cc",
|
||||||
|
"semantic_hash": "79adf8ba098e7a75afce6a8b151670cc"
|
||||||
|
},
|
||||||
|
"app/api/schemas/external_search.py": {
|
||||||
|
"mtime": 1781449314.5044124,
|
||||||
|
"ast_hash": "78e706b30dcfb16e9758a8f0db9dbfce",
|
||||||
|
"semantic_hash": "78e706b30dcfb16e9758a8f0db9dbfce"
|
||||||
|
},
|
||||||
|
"app/api/schemas/history.py": {
|
||||||
|
"mtime": 1780839013.147961,
|
||||||
|
"ast_hash": "0b029c13fbac7c5da0aabfb7ebf506c0",
|
||||||
|
"semantic_hash": "0b029c13fbac7c5da0aabfb7ebf506c0"
|
||||||
|
},
|
||||||
|
"app/api/schemas/like.py": {
|
||||||
|
"mtime": 1780839008.1339302,
|
||||||
|
"ast_hash": "289e06d44ea2190b16811787c23889d7",
|
||||||
|
"semantic_hash": "289e06d44ea2190b16811787c23889d7"
|
||||||
|
},
|
||||||
|
"app/api/schemas/pagination.py": {
|
||||||
|
"mtime": 1780839157.662396,
|
||||||
|
"ast_hash": "d0e9ebaed23d8ac06e05f579e7de8e2a",
|
||||||
|
"semantic_hash": "d0e9ebaed23d8ac06e05f579e7de8e2a"
|
||||||
|
},
|
||||||
|
"app/api/schemas/playlist.py": {
|
||||||
|
"mtime": 1780839003.2429,
|
||||||
|
"ast_hash": "839674c0de60636e8a9ac5ff31ce6c6d",
|
||||||
|
"semantic_hash": "839674c0de60636e8a9ac5ff31ce6c6d"
|
||||||
|
},
|
||||||
|
"app/api/schemas/search.py": {
|
||||||
|
"mtime": 1780839016.3552911,
|
||||||
|
"ast_hash": "493d80c078f10f7e4bdc0f4d8944ef65",
|
||||||
|
"semantic_hash": "493d80c078f10f7e4bdc0f4d8944ef65"
|
||||||
|
},
|
||||||
|
"app/api/schemas/source.py": {
|
||||||
|
"mtime": 1780937403.7201283,
|
||||||
|
"ast_hash": "0a28ce7d702317a454049186eb7499f5",
|
||||||
|
"semantic_hash": "0a28ce7d702317a454049186eb7499f5"
|
||||||
|
},
|
||||||
|
"app/api/schemas/storage.py": {
|
||||||
|
"mtime": 1781388359.2668393,
|
||||||
|
"ast_hash": "c1af884cb9d39c53c228b16c8c7724e1",
|
||||||
|
"semantic_hash": "c1af884cb9d39c53c228b16c8c7724e1"
|
||||||
|
},
|
||||||
|
"app/api/schemas/subsonic.py": {
|
||||||
|
"mtime": 1780930628.6366568,
|
||||||
|
"ast_hash": "8a26a76e8101ce5211ec49fc4f68f4a9",
|
||||||
|
"semantic_hash": "8a26a76e8101ce5211ec49fc4f68f4a9"
|
||||||
|
},
|
||||||
|
"app/api/schemas/track.py": {
|
||||||
|
"mtime": 1781449513.7865171,
|
||||||
|
"ast_hash": "5181fcb243da810597aa4602a06dbc95",
|
||||||
|
"semantic_hash": "5181fcb243da810597aa4602a06dbc95"
|
||||||
|
},
|
||||||
|
"app/api/schemas/upload.py": {
|
||||||
|
"mtime": 1780834632.33776,
|
||||||
|
"ast_hash": "e1766947f0ac1ecd8b4c1af953d9e832",
|
||||||
|
"semantic_hash": "e1766947f0ac1ecd8b4c1af953d9e832"
|
||||||
|
},
|
||||||
|
"app/api/schemas/user.py": {
|
||||||
|
"mtime": 1780740461.4569318,
|
||||||
|
"ast_hash": "bea6686386c3158a05280e7d877d05ed",
|
||||||
|
"semantic_hash": "bea6686386c3158a05280e7d877d05ed"
|
||||||
|
},
|
||||||
|
"app/api/v1/__init__.py": {
|
||||||
|
"mtime": 1781090194.2184844,
|
||||||
|
"ast_hash": "6ced4a4da683aae586951e5e6f95a394",
|
||||||
|
"semantic_hash": "6ced4a4da683aae586951e5e6f95a394"
|
||||||
|
},
|
||||||
|
"app/api/v1/admin.py": {
|
||||||
|
"mtime": 1780930705.8733003,
|
||||||
|
"ast_hash": "1598ceee05fa9e03b398114bb92d937b",
|
||||||
|
"semantic_hash": "1598ceee05fa9e03b398114bb92d937b"
|
||||||
|
},
|
||||||
|
"app/api/v1/albums.py": {
|
||||||
|
"mtime": 1781341143.0523643,
|
||||||
|
"ast_hash": "7f729e1ca01e04260929583549fd13fe",
|
||||||
|
"semantic_hash": "7f729e1ca01e04260929583549fd13fe"
|
||||||
|
},
|
||||||
|
"app/api/v1/artists.py": {
|
||||||
|
"mtime": 1780839091.0064104,
|
||||||
|
"ast_hash": "a901cd191407450aec07740501c5995e",
|
||||||
|
"semantic_hash": "a901cd191407450aec07740501c5995e"
|
||||||
|
},
|
||||||
|
"app/api/v1/auth.py": {
|
||||||
|
"mtime": 1781089104.6619034,
|
||||||
|
"ast_hash": "ddb416255ee6a0ec26cee994d94d5370",
|
||||||
|
"semantic_hash": "ddb416255ee6a0ec26cee994d94d5370"
|
||||||
|
},
|
||||||
|
"app/api/v1/downloads.py": {
|
||||||
|
"mtime": 1781431416.617589,
|
||||||
|
"ast_hash": "23b9e823c58b0e32e93cf99f3d824bcd",
|
||||||
|
"semantic_hash": "23b9e823c58b0e32e93cf99f3d824bcd"
|
||||||
|
},
|
||||||
|
"app/api/v1/history.py": {
|
||||||
|
"mtime": 1780839145.5493972,
|
||||||
|
"ast_hash": "8608a5fe6f3e4eb770509030e35d5c4c",
|
||||||
|
"semantic_hash": "8608a5fe6f3e4eb770509030e35d5c4c"
|
||||||
|
},
|
||||||
|
"app/api/v1/likes.py": {
|
||||||
|
"mtime": 1780839161.6975977,
|
||||||
|
"ast_hash": "b8c512d4a06c659f13aa7bc8ecf7c98b",
|
||||||
|
"semantic_hash": "b8c512d4a06c659f13aa7bc8ecf7c98b"
|
||||||
|
},
|
||||||
|
"app/api/v1/playlists.py": {
|
||||||
|
"mtime": 1780839394.9126005,
|
||||||
|
"ast_hash": "86bffcd4dea7ad8c495cccbae64ff0f6",
|
||||||
|
"semantic_hash": "86bffcd4dea7ad8c495cccbae64ff0f6"
|
||||||
|
},
|
||||||
|
"app/api/v1/radio.py": {
|
||||||
|
"mtime": 1780740461.4577572,
|
||||||
|
"ast_hash": "69ba86aa40df5b076d13fd7d0b36524c",
|
||||||
|
"semantic_hash": "69ba86aa40df5b076d13fd7d0b36524c"
|
||||||
|
},
|
||||||
|
"app/api/v1/search.py": {
|
||||||
|
"mtime": 1781449336.6463625,
|
||||||
|
"ast_hash": "43aa43df0113dfe5655f78fc433eb2d9",
|
||||||
|
"semantic_hash": "43aa43df0113dfe5655f78fc433eb2d9"
|
||||||
|
},
|
||||||
|
"app/api/v1/sources.py": {
|
||||||
|
"mtime": 1781449353.2667253,
|
||||||
|
"ast_hash": "1cde3ba696f0261f78a66281d608bcbd",
|
||||||
|
"semantic_hash": "1cde3ba696f0261f78a66281d608bcbd"
|
||||||
|
},
|
||||||
|
"app/api/v1/storage.py": {
|
||||||
|
"mtime": 1781431426.4429922,
|
||||||
|
"ast_hash": "ebb050d36f56d10631cf3886a2b8ad50",
|
||||||
|
"semantic_hash": "ebb050d36f56d10631cf3886a2b8ad50"
|
||||||
|
},
|
||||||
|
"app/api/v1/streaming.py": {
|
||||||
|
"mtime": 1780926258.7220879,
|
||||||
|
"ast_hash": "b672cf588e8a063df2692fe061db4c96",
|
||||||
|
"semantic_hash": "b672cf588e8a063df2692fe061db4c96"
|
||||||
|
},
|
||||||
|
"app/api/v1/sync.py": {
|
||||||
|
"mtime": 1780740461.4583004,
|
||||||
|
"ast_hash": "2f77599b361d440553e41405d6af94ce",
|
||||||
|
"semantic_hash": "2f77599b361d440553e41405d6af94ce"
|
||||||
|
},
|
||||||
|
"app/api/v1/tracks.py": {
|
||||||
|
"mtime": 1781449546.9320607,
|
||||||
|
"ast_hash": "cf68eb7ce19bea5076f1fb491337cffa",
|
||||||
|
"semantic_hash": "cf68eb7ce19bea5076f1fb491337cffa"
|
||||||
|
},
|
||||||
|
"app/api/v1/upload.py": {
|
||||||
|
"mtime": 1780834664.0559208,
|
||||||
|
"ast_hash": "b65d1a4f925427db4e00563900331f37",
|
||||||
|
"semantic_hash": "b65d1a4f925427db4e00563900331f37"
|
||||||
|
},
|
||||||
|
"app/api/v1/user_settings.py": {
|
||||||
|
"mtime": 1780740461.45858,
|
||||||
|
"ast_hash": "84266cce4665fecb32b4fc771467531c",
|
||||||
|
"semantic_hash": "84266cce4665fecb32b4fc771467531c"
|
||||||
|
},
|
||||||
|
"app/api/v1/users.py": {
|
||||||
|
"mtime": 1780930688.1892889,
|
||||||
|
"ast_hash": "f228bc4772f7790628cc20d5023a0d68",
|
||||||
|
"semantic_hash": "f228bc4772f7790628cc20d5023a0d68"
|
||||||
|
},
|
||||||
|
"app/application/__init__.py": {
|
||||||
|
"mtime": 1780740461.4587204,
|
||||||
|
"ast_hash": "a3ebf6c96ead21082e289b8340c5e7d5",
|
||||||
|
"semantic_hash": "a3ebf6c96ead21082e289b8340c5e7d5"
|
||||||
|
},
|
||||||
|
"app/application/auth_service.py": {
|
||||||
|
"mtime": 1780740461.4587867,
|
||||||
|
"ast_hash": "c298ecfcda9314a92c39fb1ad7ecc2b6",
|
||||||
|
"semantic_hash": "c298ecfcda9314a92c39fb1ad7ecc2b6"
|
||||||
|
},
|
||||||
|
"app/application/download_service.py": {
|
||||||
|
"mtime": 1781431149.3235486,
|
||||||
|
"ast_hash": "565328e38b3ad4fd4fba58d23a650068",
|
||||||
|
"semantic_hash": "565328e38b3ad4fd4fba58d23a650068"
|
||||||
|
},
|
||||||
|
"app/application/import_service.py": {
|
||||||
|
"mtime": 1780995930.881823,
|
||||||
|
"ast_hash": "88848eb147b60f979a191ef7b386ebb6",
|
||||||
|
"semantic_hash": "88848eb147b60f979a191ef7b386ebb6"
|
||||||
|
},
|
||||||
|
"app/application/metadata_service.py": {
|
||||||
|
"mtime": 1781436734.362444,
|
||||||
|
"ast_hash": "21d2bc3331882ed52b4827851eb37763",
|
||||||
|
"semantic_hash": "21d2bc3331882ed52b4827851eb37763"
|
||||||
|
},
|
||||||
|
"app/application/remote_library_service.py": {
|
||||||
|
"mtime": 1781449395.8587532,
|
||||||
|
"ast_hash": "27625fadcbed021d49368e67f7b86107",
|
||||||
|
"semantic_hash": "27625fadcbed021d49368e67f7b86107"
|
||||||
|
},
|
||||||
|
"app/application/streaming_service.py": {
|
||||||
|
"mtime": 1781436734.3625712,
|
||||||
|
"ast_hash": "ec4f2295cb985962472e16ff0772884b",
|
||||||
|
"semantic_hash": "ec4f2295cb985962472e16ff0772884b"
|
||||||
|
},
|
||||||
|
"app/application/subsonic_auth_service.py": {
|
||||||
|
"mtime": 1780930622.6054742,
|
||||||
|
"ast_hash": "239be1e292014a82ecd1e2290c0e930b",
|
||||||
|
"semantic_hash": "239be1e292014a82ecd1e2290c0e930b"
|
||||||
|
},
|
||||||
|
"app/application/upload_service.py": {
|
||||||
|
"mtime": 1780995861.3204799,
|
||||||
|
"ast_hash": "c96616ad8eb0e2f8a2f333377c5019b9",
|
||||||
|
"semantic_hash": "c96616ad8eb0e2f8a2f333377c5019b9"
|
||||||
|
},
|
||||||
|
"app/application/user_service.py": {
|
||||||
|
"mtime": 1780740461.458876,
|
||||||
|
"ast_hash": "0998e504ad3cecde056b56d284442779",
|
||||||
|
"semantic_hash": "0998e504ad3cecde056b56d284442779"
|
||||||
|
},
|
||||||
|
"app/cli.py": {
|
||||||
|
"mtime": 1780740461.458932,
|
||||||
|
"ast_hash": "2da4dd372d17ce55b03251d79202b36d",
|
||||||
|
"semantic_hash": "2da4dd372d17ce55b03251d79202b36d"
|
||||||
|
},
|
||||||
|
"app/core/__init__.py": {
|
||||||
|
"mtime": 1780740461.4590154,
|
||||||
|
"ast_hash": "e4dace249cfdc8d4a332fa381cbae73f",
|
||||||
|
"semantic_hash": "e4dace249cfdc8d4a332fa381cbae73f"
|
||||||
|
},
|
||||||
|
"app/core/config.py": {
|
||||||
|
"mtime": 1781431156.0778322,
|
||||||
|
"ast_hash": "4454a82c01de92aecb7728bc589ad798",
|
||||||
|
"semantic_hash": "4454a82c01de92aecb7728bc589ad798"
|
||||||
|
},
|
||||||
|
"app/core/hashing.py": {
|
||||||
|
"mtime": 1780834527.6103039,
|
||||||
|
"ast_hash": "b9268177b6d2be10595090211c67d4ea",
|
||||||
|
"semantic_hash": "b9268177b6d2be10595090211c67d4ea"
|
||||||
|
},
|
||||||
|
"app/core/logging.py": {
|
||||||
|
"mtime": 1780740461.4593058,
|
||||||
|
"ast_hash": "dfa018745efc7c68da04c64e46252f09",
|
||||||
|
"semantic_hash": "dfa018745efc7c68da04c64e46252f09"
|
||||||
|
},
|
||||||
|
"app/core/security.py": {
|
||||||
|
"mtime": 1780930500.7200966,
|
||||||
|
"ast_hash": "5e00c7006bc19100590efaa23af8929f",
|
||||||
|
"semantic_hash": "5e00c7006bc19100590efaa23af8929f"
|
||||||
|
},
|
||||||
|
"app/domain/__init__.py": {
|
||||||
|
"mtime": 1780740461.4595265,
|
||||||
|
"ast_hash": "204e4e6058bc6c9e215ed32feaf6ecd4",
|
||||||
|
"semantic_hash": "204e4e6058bc6c9e215ed32feaf6ecd4"
|
||||||
|
},
|
||||||
|
"app/domain/entities/__init__.py": {
|
||||||
|
"mtime": 1781430839.896553,
|
||||||
|
"ast_hash": "eab275a89c1dd0ce8c4c16a8c7eae6f5",
|
||||||
|
"semantic_hash": "eab275a89c1dd0ce8c4c16a8c7eae6f5"
|
||||||
|
},
|
||||||
|
"app/domain/entities/album.py": {
|
||||||
|
"mtime": 1781436734.3627071,
|
||||||
|
"ast_hash": "6b394148ed2c3ebac3c0a459ea24b4f9",
|
||||||
|
"semantic_hash": "6b394148ed2c3ebac3c0a459ea24b4f9"
|
||||||
|
},
|
||||||
|
"app/domain/entities/cover.py": {
|
||||||
|
"mtime": 1781287934.099114,
|
||||||
|
"ast_hash": "105b9970457106b019bf34e8807505c3",
|
||||||
|
"semantic_hash": "105b9970457106b019bf34e8807505c3"
|
||||||
|
},
|
||||||
|
"app/domain/entities/download.py": {
|
||||||
|
"mtime": 1781430831.3794842,
|
||||||
|
"ast_hash": "d4f719d1309c50b41b410129fdd6777c",
|
||||||
|
"semantic_hash": "d4f719d1309c50b41b410129fdd6777c"
|
||||||
|
},
|
||||||
|
"app/domain/entities/history.py": {
|
||||||
|
"mtime": 1780838847.9384992,
|
||||||
|
"ast_hash": "1181a2b5ffefc2f07d05a7b21f80b78d",
|
||||||
|
"semantic_hash": "1181a2b5ffefc2f07d05a7b21f80b78d"
|
||||||
|
},
|
||||||
|
"app/domain/entities/like.py": {
|
||||||
|
"mtime": 1780838843.0107362,
|
||||||
|
"ast_hash": "ced74c7d64b27565dd8e0fa4e139c1b2",
|
||||||
|
"semantic_hash": "ced74c7d64b27565dd8e0fa4e139c1b2"
|
||||||
|
},
|
||||||
|
"app/domain/entities/metadata.py": {
|
||||||
|
"mtime": 1781287975.5111291,
|
||||||
|
"ast_hash": "da6f072e8a17cf08c18316ea7842ffd1",
|
||||||
|
"semantic_hash": "da6f072e8a17cf08c18316ea7842ffd1"
|
||||||
|
},
|
||||||
|
"app/domain/entities/playlist.py": {
|
||||||
|
"mtime": 1780838839.6122868,
|
||||||
|
"ast_hash": "b307cf60e198ccbd835116344f3675cf",
|
||||||
|
"semantic_hash": "b307cf60e198ccbd835116344f3675cf"
|
||||||
|
},
|
||||||
|
"app/domain/entities/storage.py": {
|
||||||
|
"mtime": 1781388279.3534985,
|
||||||
|
"ast_hash": "6f36be03a6468830927caaa9a631d85f",
|
||||||
|
"semantic_hash": "6f36be03a6468830927caaa9a631d85f"
|
||||||
|
},
|
||||||
|
"app/domain/entities/track.py": {
|
||||||
|
"mtime": 1781436734.3628123,
|
||||||
|
"ast_hash": "f46725c282763ed244aa4721f98db4c4",
|
||||||
|
"semantic_hash": "f46725c282763ed244aa4721f98db4c4"
|
||||||
|
},
|
||||||
|
"app/domain/entities/user.py": {
|
||||||
|
"mtime": 1780930519.1665258,
|
||||||
|
"ast_hash": "0914436598028d9fc8baba37475aed90",
|
||||||
|
"semantic_hash": "0914436598028d9fc8baba37475aed90"
|
||||||
|
},
|
||||||
|
"app/domain/errors.py": {
|
||||||
|
"mtime": 1780834537.2983005,
|
||||||
|
"ast_hash": "20146f58be5a7da51d747420316b9430",
|
||||||
|
"semantic_hash": "20146f58be5a7da51d747420316b9430"
|
||||||
|
},
|
||||||
|
"app/domain/ports.py": {
|
||||||
|
"mtime": 1781436734.3629692,
|
||||||
|
"ast_hash": "ef28972b7f6d869eb4509d6a767ff246",
|
||||||
|
"semantic_hash": "ef28972b7f6d869eb4509d6a767ff246"
|
||||||
|
},
|
||||||
|
"app/domain/sources.py": {
|
||||||
|
"mtime": 1781430820.3668802,
|
||||||
|
"ast_hash": "665301f08df7401f123cfd44e06bafe3",
|
||||||
|
"semantic_hash": "665301f08df7401f123cfd44e06bafe3"
|
||||||
|
},
|
||||||
|
"app/domain/tokens.py": {
|
||||||
|
"mtime": 1780740461.4599028,
|
||||||
|
"ast_hash": "443bcb23c86b334c8ba755f356c5a8a6",
|
||||||
|
"semantic_hash": "443bcb23c86b334c8ba755f356c5a8a6"
|
||||||
|
},
|
||||||
|
"app/infrastructure/__init__.py": {
|
||||||
|
"mtime": 1780740461.4599905,
|
||||||
|
"ast_hash": "ddff47d8c005f5836e5b44f8e1ff6df8",
|
||||||
|
"semantic_hash": "ddff47d8c005f5836e5b44f8e1ff6df8"
|
||||||
|
},
|
||||||
|
"app/infrastructure/cache/__init__.py": {
|
||||||
|
"mtime": 1780740461.4600825,
|
||||||
|
"ast_hash": "2a7cf9760b59ac60df249af0e1a7f04e",
|
||||||
|
"semantic_hash": "2a7cf9760b59ac60df249af0e1a7f04e"
|
||||||
|
},
|
||||||
|
"app/infrastructure/cache/redis.py": {
|
||||||
|
"mtime": 1780740461.4601371,
|
||||||
|
"ast_hash": "094b598f8e4546492044da13ecf360aa",
|
||||||
|
"semantic_hash": "094b598f8e4546492044da13ecf360aa"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/__init__.py": {
|
||||||
|
"mtime": 1780740461.4602199,
|
||||||
|
"ast_hash": "1e13eb59f4d77a900c55b11f1742274a",
|
||||||
|
"semantic_hash": "1e13eb59f4d77a900c55b11f1742274a"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/base.py": {
|
||||||
|
"mtime": 1780740461.4602711,
|
||||||
|
"ast_hash": "c606e414709931d0ab1315bdf0532b13",
|
||||||
|
"semantic_hash": "c606e414709931d0ab1315bdf0532b13"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/engine.py": {
|
||||||
|
"mtime": 1780740461.460324,
|
||||||
|
"ast_hash": "e7e3d4acdaf0b816699984fa9a0deee6",
|
||||||
|
"semantic_hash": "e7e3d4acdaf0b816699984fa9a0deee6"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/models/__init__.py": {
|
||||||
|
"mtime": 1780832495.2844996,
|
||||||
|
"ast_hash": "df0f2f6c08984a710c59bf91d3a79c51",
|
||||||
|
"semantic_hash": "df0f2f6c08984a710c59bf91d3a79c51"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/models/album.py": {
|
||||||
|
"mtime": 1781436734.3631139,
|
||||||
|
"ast_hash": "194e7120a3adf5cd555d5c77371a5fa2",
|
||||||
|
"semantic_hash": "194e7120a3adf5cd555d5c77371a5fa2"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/models/artist.py": {
|
||||||
|
"mtime": 1781436734.3632703,
|
||||||
|
"ast_hash": "5ba8f63246dc5fd1311989d173af425b",
|
||||||
|
"semantic_hash": "5ba8f63246dc5fd1311989d173af425b"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/models/download_job.py": {
|
||||||
|
"mtime": 1781430879.7262104,
|
||||||
|
"ast_hash": "1c271f4018a6eecdc5040464e2ddbb91",
|
||||||
|
"semantic_hash": "1c271f4018a6eecdc5040464e2ddbb91"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/models/enums.py": {
|
||||||
|
"mtime": 1781436734.363405,
|
||||||
|
"ast_hash": "27426487188c45a80aed89e0a626b614",
|
||||||
|
"semantic_hash": "27426487188c45a80aed89e0a626b614"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/models/like.py": {
|
||||||
|
"mtime": 1780831691.5652637,
|
||||||
|
"ast_hash": "863d77a73e16776864fcf8450c5dfdef",
|
||||||
|
"semantic_hash": "863d77a73e16776864fcf8450c5dfdef"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/models/lyrics.py": {
|
||||||
|
"mtime": 1780831717.8586154,
|
||||||
|
"ast_hash": "579fc88c11040a9d08d3b26b19f15f6b",
|
||||||
|
"semantic_hash": "579fc88c11040a9d08d3b26b19f15f6b"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/models/mixins.py": {
|
||||||
|
"mtime": 1780740461.4604566,
|
||||||
|
"ast_hash": "ba040ff21f15d5e7959c16798e7f324a",
|
||||||
|
"semantic_hash": "ba040ff21f15d5e7959c16798e7f324a"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/models/play_history.py": {
|
||||||
|
"mtime": 1780831839.1464818,
|
||||||
|
"ast_hash": "ab65903ccb068679fd61c81d7634c963",
|
||||||
|
"semantic_hash": "ab65903ccb068679fd61c81d7634c963"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/models/playlist.py": {
|
||||||
|
"mtime": 1780831673.9721408,
|
||||||
|
"ast_hash": "16a8cd4eb488c78b7329738bfd60a027",
|
||||||
|
"semantic_hash": "16a8cd4eb488c78b7329738bfd60a027"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/models/track.py": {
|
||||||
|
"mtime": 1781436734.3635309,
|
||||||
|
"ast_hash": "45e5c20918fdea968b425b524fefe13b",
|
||||||
|
"semantic_hash": "45e5c20918fdea968b425b524fefe13b"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/models/user.py": {
|
||||||
|
"mtime": 1780930571.6752625,
|
||||||
|
"ast_hash": "fdfa780f2c6cc30030959cc37536bde1",
|
||||||
|
"semantic_hash": "fdfa780f2c6cc30030959cc37536bde1"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/repositories/__init__.py": {
|
||||||
|
"mtime": 1781430953.233821,
|
||||||
|
"ast_hash": "9c18194d03928fd77ca12568e63d4b0f",
|
||||||
|
"semantic_hash": "9c18194d03928fd77ca12568e63d4b0f"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/repositories/album_repository.py": {
|
||||||
|
"mtime": 1781436734.363677,
|
||||||
|
"ast_hash": "d29135884c764b9db09408fa06195bd6",
|
||||||
|
"semantic_hash": "d29135884c764b9db09408fa06195bd6"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/repositories/artist_repository.py": {
|
||||||
|
"mtime": 1781436734.3637936,
|
||||||
|
"ast_hash": "8e16c984f3c8385af92b8ceb88c0222f",
|
||||||
|
"semantic_hash": "8e16c984f3c8385af92b8ceb88c0222f"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/repositories/download_job_repository.py": {
|
||||||
|
"mtime": 1781430943.1835248,
|
||||||
|
"ast_hash": "ef50c63870d11133f9ab0b1dff47ec84",
|
||||||
|
"semantic_hash": "ef50c63870d11133f9ab0b1dff47ec84"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/repositories/history_repository.py": {
|
||||||
|
"mtime": 1780839183.174615,
|
||||||
|
"ast_hash": "4d45c1e6aea1ba42ea5f8098340793d3",
|
||||||
|
"semantic_hash": "4d45c1e6aea1ba42ea5f8098340793d3"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/repositories/like_repository.py": {
|
||||||
|
"mtime": 1781436734.3639176,
|
||||||
|
"ast_hash": "cb68314bab1157ac113358eacdfa8f90",
|
||||||
|
"semantic_hash": "cb68314bab1157ac113358eacdfa8f90"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/repositories/playlist_repository.py": {
|
||||||
|
"mtime": 1781436734.36407,
|
||||||
|
"ast_hash": "91588a1751e3fb0ae2865559c36116a4",
|
||||||
|
"semantic_hash": "91588a1751e3fb0ae2865559c36116a4"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/repositories/refresh_token_repository.py": {
|
||||||
|
"mtime": 1780740461.4606497,
|
||||||
|
"ast_hash": "73bdd351b9f0cfb0b3edd0d290a405df",
|
||||||
|
"semantic_hash": "73bdd351b9f0cfb0b3edd0d290a405df"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/repositories/track_repository.py": {
|
||||||
|
"mtime": 1781436734.3644211,
|
||||||
|
"ast_hash": "59c7a0f2ff9d80d00ae5f50a89091f65",
|
||||||
|
"semantic_hash": "59c7a0f2ff9d80d00ae5f50a89091f65"
|
||||||
|
},
|
||||||
|
"app/infrastructure/db/repositories/user_repository.py": {
|
||||||
|
"mtime": 1780930585.3081222,
|
||||||
|
"ast_hash": "7b7ef8313fda16e6da58dbf89fc0da90",
|
||||||
|
"semantic_hash": "7b7ef8313fda16e6da58dbf89fc0da90"
|
||||||
|
},
|
||||||
|
"app/infrastructure/metadata/__init__.py": {
|
||||||
|
"mtime": 1780995718.4465241,
|
||||||
|
"ast_hash": "648aa61f319edc6eed6bc7f6f03d7c05",
|
||||||
|
"semantic_hash": "648aa61f319edc6eed6bc7f6f03d7c05"
|
||||||
|
},
|
||||||
|
"app/infrastructure/metadata/acoustid.py": {
|
||||||
|
"mtime": 1781431426.4437006,
|
||||||
|
"ast_hash": "9a6f7c4f550e1f032cba16dd951ed39c",
|
||||||
|
"semantic_hash": "9a6f7c4f550e1f032cba16dd951ed39c"
|
||||||
|
},
|
||||||
|
"app/infrastructure/metadata/cover_extractor.py": {
|
||||||
|
"mtime": 1781341199.814208,
|
||||||
|
"ast_hash": "6b0da33509b35f430f9b0a0cba46dd26",
|
||||||
|
"semantic_hash": "6b0da33509b35f430f9b0a0cba46dd26"
|
||||||
|
},
|
||||||
|
"app/infrastructure/metadata/coverart.py": {
|
||||||
|
"mtime": 1781288063.5966833,
|
||||||
|
"ast_hash": "6b168ad4c7c8c0bfef29e4ee1916266e",
|
||||||
|
"semantic_hash": "6b168ad4c7c8c0bfef29e4ee1916266e"
|
||||||
|
},
|
||||||
|
"app/infrastructure/metadata/fingerprint.py": {
|
||||||
|
"mtime": 1781341143.0524707,
|
||||||
|
"ast_hash": "8cb7b7182ffa55a18e746975cd3423a3",
|
||||||
|
"semantic_hash": "8cb7b7182ffa55a18e746975cd3423a3"
|
||||||
|
},
|
||||||
|
"app/infrastructure/metadata/tags.py": {
|
||||||
|
"mtime": 1780995734.1162078,
|
||||||
|
"ast_hash": "297a9b0b3dcc87ec3f2690bf1b3e9298",
|
||||||
|
"semantic_hash": "297a9b0b3dcc87ec3f2690bf1b3e9298"
|
||||||
|
},
|
||||||
|
"app/infrastructure/sources/__init__.py": {
|
||||||
|
"mtime": 1780937219.5395026,
|
||||||
|
"ast_hash": "40617f61ec696fce620d92a59a3d3670",
|
||||||
|
"semantic_hash": "40617f61ec696fce620d92a59a3d3670"
|
||||||
|
},
|
||||||
|
"app/infrastructure/sources/local_folder.py": {
|
||||||
|
"mtime": 1780937232.9471898,
|
||||||
|
"ast_hash": "32159f72a25f847965acdca60fb0686d",
|
||||||
|
"semantic_hash": "32159f72a25f847965acdca60fb0686d"
|
||||||
|
},
|
||||||
|
"app/infrastructure/sources/registry.py": {
|
||||||
|
"mtime": 1781431036.8494833,
|
||||||
|
"ast_hash": "a9a0e5fc8cf634612762ece739e33ff6",
|
||||||
|
"semantic_hash": "a9a0e5fc8cf634612762ece739e33ff6"
|
||||||
|
},
|
||||||
|
"app/infrastructure/sources/youtube.py": {
|
||||||
|
"mtime": 1781434868.4958549,
|
||||||
|
"ast_hash": "59fa690e81e5269fac76c1343a0e5d1e",
|
||||||
|
"semantic_hash": "59fa690e81e5269fac76c1343a0e5d1e"
|
||||||
|
},
|
||||||
|
"app/infrastructure/storage/__init__.py": {
|
||||||
|
"mtime": 1780834566.4983473,
|
||||||
|
"ast_hash": "876e501d1510d99e1cec7ff01b5cbce7",
|
||||||
|
"semantic_hash": "876e501d1510d99e1cec7ff01b5cbce7"
|
||||||
|
},
|
||||||
|
"app/infrastructure/storage/local.py": {
|
||||||
|
"mtime": 1781388304.3666453,
|
||||||
|
"ast_hash": "214b4850c0af018e0d63549072bba839",
|
||||||
|
"semantic_hash": "214b4850c0af018e0d63549072bba839"
|
||||||
|
},
|
||||||
|
"app/infrastructure/storage/provider.py": {
|
||||||
|
"mtime": 1780907524.476896,
|
||||||
|
"ast_hash": "a5c646414d91be1c0dced010a12aa82a",
|
||||||
|
"semantic_hash": "a5c646414d91be1c0dced010a12aa82a"
|
||||||
|
},
|
||||||
|
"app/infrastructure/storage/s3.py": {
|
||||||
|
"mtime": 1781388314.5144532,
|
||||||
|
"ast_hash": "905716dede55172184107f7c9ad1ab42",
|
||||||
|
"semantic_hash": "905716dede55172184107f7c9ad1ab42"
|
||||||
|
},
|
||||||
|
"app/main.py": {
|
||||||
|
"mtime": 1781122845.512842,
|
||||||
|
"ast_hash": "feba2855f136c1345b5a0191acd1a821",
|
||||||
|
"semantic_hash": "feba2855f136c1345b5a0191acd1a821"
|
||||||
|
},
|
||||||
|
"app/workers/__init__.py": {
|
||||||
|
"mtime": 1780740461.4608312,
|
||||||
|
"ast_hash": "bb576ace361f34cd704134733472ef1d",
|
||||||
|
"semantic_hash": "bb576ace361f34cd704134733472ef1d"
|
||||||
|
},
|
||||||
|
"app/workers/arq_worker.py": {
|
||||||
|
"mtime": 1781449445.6491823,
|
||||||
|
"ast_hash": "cb9bf15a45ddfdde228c674248bac4f0",
|
||||||
|
"semantic_hash": "cb9bf15a45ddfdde228c674248bac4f0"
|
||||||
|
},
|
||||||
|
"app/workers/queue.py": {
|
||||||
|
"mtime": 1781449433.3385477,
|
||||||
|
"ast_hash": "eb2179bb7c7e441ecde554167b92a41f",
|
||||||
|
"semantic_hash": "eb2179bb7c7e441ecde554167b92a41f"
|
||||||
|
},
|
||||||
|
"app/workers/tasks/__init__.py": {
|
||||||
|
"mtime": 1780937297.4590938,
|
||||||
|
"ast_hash": "2c1261d302dfa537318793be9cf997d7",
|
||||||
|
"semantic_hash": "2c1261d302dfa537318793be9cf997d7"
|
||||||
|
},
|
||||||
|
"app/workers/tasks/download_task.py": {
|
||||||
|
"mtime": 1781431446.1692648,
|
||||||
|
"ast_hash": "e714382e7f3faf3e4dbd72db65a1699d",
|
||||||
|
"semantic_hash": "e714382e7f3faf3e4dbd72db65a1699d"
|
||||||
|
},
|
||||||
|
"app/workers/tasks/enrich_task.py": {
|
||||||
|
"mtime": 1781345874.286687,
|
||||||
|
"ast_hash": "e1074756583f94102c667b476ca22075",
|
||||||
|
"semantic_hash": "e1074756583f94102c667b476ca22075"
|
||||||
|
},
|
||||||
|
"app/workers/tasks/import_task.py": {
|
||||||
|
"mtime": 1780996020.0745654,
|
||||||
|
"ast_hash": "9fa8463990fdc3219b357da592deb645",
|
||||||
|
"semantic_hash": "9fa8463990fdc3219b357da592deb645"
|
||||||
|
},
|
||||||
|
"app/workers/tasks/materialize_task.py": {
|
||||||
|
"mtime": 1781449420.1624177,
|
||||||
|
"ast_hash": "2e21e1234938b0521d9a5cd4571a17a6",
|
||||||
|
"semantic_hash": "2e21e1234938b0521d9a5cd4571a17a6"
|
||||||
|
},
|
||||||
|
"pyproject.toml": {
|
||||||
|
"mtime": 1781431069.1959672,
|
||||||
|
"ast_hash": "9a0efc9dfb92208e0864a5f265930cf5",
|
||||||
|
"semantic_hash": "9a0efc9dfb92208e0864a5f265930cf5"
|
||||||
|
},
|
||||||
|
"tests/__init__.py": {
|
||||||
|
"mtime": 1780740461.4611318,
|
||||||
|
"ast_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||||
|
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||||
|
},
|
||||||
|
"tests/conftest.py": {
|
||||||
|
"mtime": 1781431426.4438598,
|
||||||
|
"ast_hash": "6a6342fbca4b28e2d64850779a4b9de9",
|
||||||
|
"semantic_hash": "6a6342fbca4b28e2d64850779a4b9de9"
|
||||||
|
},
|
||||||
|
"tests/fakes.py": {
|
||||||
|
"mtime": 1780930595.1828375,
|
||||||
|
"ast_hash": "a45897ae6b4bbc328a9288b31c721769",
|
||||||
|
"semantic_hash": "a45897ae6b4bbc328a9288b31c721769"
|
||||||
|
},
|
||||||
|
"tests/test_acoustid_parse.py": {
|
||||||
|
"mtime": 1781341319.114388,
|
||||||
|
"ast_hash": "1434d89e5f3aacd5924410a01d7b834a",
|
||||||
|
"semantic_hash": "1434d89e5f3aacd5924410a01d7b834a"
|
||||||
|
},
|
||||||
|
"tests/test_auth_api.py": {
|
||||||
|
"mtime": 1781089132.7353735,
|
||||||
|
"ast_hash": "3d0461598e5fa7692e22e623321c74f2",
|
||||||
|
"semantic_hash": "3d0461598e5fa7692e22e623321c74f2"
|
||||||
|
},
|
||||||
|
"tests/test_auth_service.py": {
|
||||||
|
"mtime": 1780740461.461472,
|
||||||
|
"ast_hash": "ee2ebdf67cc12288307154b94c1d77a2",
|
||||||
|
"semantic_hash": "ee2ebdf67cc12288307154b94c1d77a2"
|
||||||
|
},
|
||||||
|
"tests/test_cover_api.py": {
|
||||||
|
"mtime": 1781341370.4818933,
|
||||||
|
"ast_hash": "534f3de94bdf868f9220d94b50eaf052",
|
||||||
|
"semantic_hash": "534f3de94bdf868f9220d94b50eaf052"
|
||||||
|
},
|
||||||
|
"tests/test_download_service.py": {
|
||||||
|
"mtime": 1781436972.7492154,
|
||||||
|
"ast_hash": "f94fd2ef12538492cce0c11da56e5c09",
|
||||||
|
"semantic_hash": "f94fd2ef12538492cce0c11da56e5c09"
|
||||||
|
},
|
||||||
|
"tests/test_downloads_api.py": {
|
||||||
|
"mtime": 1781449674.0453305,
|
||||||
|
"ast_hash": "1a0c8935a52fddcb095971ca669f3cbd",
|
||||||
|
"semantic_hash": "1a0c8935a52fddcb095971ca669f3cbd"
|
||||||
|
},
|
||||||
|
"tests/test_health.py": {
|
||||||
|
"mtime": 1780740461.4615204,
|
||||||
|
"ast_hash": "a85e04325b27e8e42ac793a613674be2",
|
||||||
|
"semantic_hash": "a85e04325b27e8e42ac793a613674be2"
|
||||||
|
},
|
||||||
|
"tests/test_import_service.py": {
|
||||||
|
"mtime": 1781436734.3647668,
|
||||||
|
"ast_hash": "30277630cfb70fdffc8754b1897f2809",
|
||||||
|
"semantic_hash": "30277630cfb70fdffc8754b1897f2809"
|
||||||
|
},
|
||||||
|
"tests/test_lazy_materialization_repos.py": {
|
||||||
|
"mtime": 1781436933.1312878,
|
||||||
|
"ast_hash": "84e1fd163ddad20f0efaad0315ac7dd4",
|
||||||
|
"semantic_hash": "84e1fd163ddad20f0efaad0315ac7dd4"
|
||||||
|
},
|
||||||
|
"tests/test_local_folder_source.py": {
|
||||||
|
"mtime": 1781431666.225472,
|
||||||
|
"ast_hash": "d607afef67cc5cd95bd9fb3972a943ba",
|
||||||
|
"semantic_hash": "d607afef67cc5cd95bd9fb3972a943ba"
|
||||||
|
},
|
||||||
|
"tests/test_metadata_api.py": {
|
||||||
|
"mtime": 1781431426.4436262,
|
||||||
|
"ast_hash": "2381976f3ebee5e02a35f78255b7e8cb",
|
||||||
|
"semantic_hash": "2381976f3ebee5e02a35f78255b7e8cb"
|
||||||
|
},
|
||||||
|
"tests/test_metadata_service.py": {
|
||||||
|
"mtime": 1781436978.9477534,
|
||||||
|
"ast_hash": "d961c60caf9e08d0940f03237a75e3aa",
|
||||||
|
"semantic_hash": "d961c60caf9e08d0940f03237a75e3aa"
|
||||||
|
},
|
||||||
|
"tests/test_real_file_enrichment.py": {
|
||||||
|
"mtime": 1781436937.3161519,
|
||||||
|
"ast_hash": "c5f04df6c05e2d2c69e72bc2b3b581ec",
|
||||||
|
"semantic_hash": "c5f04df6c05e2d2c69e72bc2b3b581ec"
|
||||||
|
},
|
||||||
|
"tests/test_remote_library_service.py": {
|
||||||
|
"mtime": 1781449616.408479,
|
||||||
|
"ast_hash": "6f36486f250863e7dadfa604b2b8e48e",
|
||||||
|
"semantic_hash": "6f36486f250863e7dadfa604b2b8e48e"
|
||||||
|
},
|
||||||
|
"tests/test_security.py": {
|
||||||
|
"mtime": 1780740461.4615712,
|
||||||
|
"ast_hash": "4d1ee06cf9892de10ff328eaa8164a45",
|
||||||
|
"semantic_hash": "4d1ee06cf9892de10ff328eaa8164a45"
|
||||||
|
},
|
||||||
|
"tests/test_sources_api.py": {
|
||||||
|
"mtime": 1780937522.9603732,
|
||||||
|
"ast_hash": "6d9225aab4d98042cb7b787377cda534",
|
||||||
|
"semantic_hash": "6d9225aab4d98042cb7b787377cda534"
|
||||||
|
},
|
||||||
|
"tests/test_storage_local.py": {
|
||||||
|
"mtime": 1780834907.743654,
|
||||||
|
"ast_hash": "abd5c3e4a0fefc0d74106e039c11b2e5",
|
||||||
|
"semantic_hash": "abd5c3e4a0fefc0d74106e039c11b2e5"
|
||||||
|
},
|
||||||
|
"tests/test_storage_s3.py": {
|
||||||
|
"mtime": 1780927766.421468,
|
||||||
|
"ast_hash": "3edfe2074a9308cda4c49e4f5c8aeb33",
|
||||||
|
"semantic_hash": "3edfe2074a9308cda4c49e4f5c8aeb33"
|
||||||
|
},
|
||||||
|
"tests/test_storage_stats_api.py": {
|
||||||
|
"mtime": 1781388459.713271,
|
||||||
|
"ast_hash": "cba0faa4ea0e8c4863ea3aef367bbb1d",
|
||||||
|
"semantic_hash": "cba0faa4ea0e8c4863ea3aef367bbb1d"
|
||||||
|
},
|
||||||
|
"tests/test_subsonic_api.py": {
|
||||||
|
"mtime": 1780931816.090567,
|
||||||
|
"ast_hash": "fd61f750dc156550a4fe71cf0b2fb4b2",
|
||||||
|
"semantic_hash": "fd61f750dc156550a4fe71cf0b2fb4b2"
|
||||||
|
},
|
||||||
|
"tests/test_subsonic_auth.py": {
|
||||||
|
"mtime": 1780931848.5757399,
|
||||||
|
"ast_hash": "79e1e37a8fdf79ea590e23058ed0b4c1",
|
||||||
|
"semantic_hash": "79e1e37a8fdf79ea590e23058ed0b4c1"
|
||||||
|
},
|
||||||
|
"tests/test_subsonic_envelope.py": {
|
||||||
|
"mtime": 1780931353.2366834,
|
||||||
|
"ast_hash": "9226b8b1b5ed2730a3d926df207cffaf",
|
||||||
|
"semantic_hash": "9226b8b1b5ed2730a3d926df207cffaf"
|
||||||
|
},
|
||||||
|
"tests/test_subsonic_security.py": {
|
||||||
|
"mtime": 1780931340.8507338,
|
||||||
|
"ast_hash": "35c9fb41134b4225b2a57d8a9ff9f4d2",
|
||||||
|
"semantic_hash": "35c9fb41134b4225b2a57d8a9ff9f4d2"
|
||||||
|
},
|
||||||
|
"tests/test_tag_reader.py": {
|
||||||
|
"mtime": 1780996752.6904151,
|
||||||
|
"ast_hash": "73c627eb8af8b1aeea2b106953535c36",
|
||||||
|
"semantic_hash": "73c627eb8af8b1aeea2b106953535c36"
|
||||||
|
},
|
||||||
|
"tests/test_upload_stream_api.py": {
|
||||||
|
"mtime": 1781389972.731485,
|
||||||
|
"ast_hash": "bb507ed1123c86c42c5b2427a94744a0",
|
||||||
|
"semantic_hash": "bb507ed1123c86c42c5b2427a94744a0"
|
||||||
|
},
|
||||||
|
"tests/test_user_service.py": {
|
||||||
|
"mtime": 1780740461.461619,
|
||||||
|
"ast_hash": "cfdf8cecc64a72bb17496137a038dbda",
|
||||||
|
"semantic_hash": "cfdf8cecc64a72bb17496137a038dbda"
|
||||||
|
},
|
||||||
|
"tests/test_youtube_source.py": {
|
||||||
|
"mtime": 1781434907.5737243,
|
||||||
|
"ast_hash": "94dc86444b23f8558ed115913ff03c9d",
|
||||||
|
"semantic_hash": "94dc86444b23f8558ed115913ff03c9d"
|
||||||
|
},
|
||||||
|
".gitea/workflows/docker-publish.yml": {
|
||||||
|
"mtime": 1780857428.1328378,
|
||||||
|
"ast_hash": "4c246a658ede903b51922790aed22148",
|
||||||
|
"semantic_hash": "4c246a658ede903b51922790aed22148"
|
||||||
|
},
|
||||||
|
"CLAUDE.md": {
|
||||||
|
"mtime": 1780740461.4548063,
|
||||||
|
"ast_hash": "fb6ecf024e4f5c9835a24bd4d27738ba",
|
||||||
|
"semantic_hash": "fb6ecf024e4f5c9835a24bd4d27738ba"
|
||||||
|
},
|
||||||
|
"README.md": {
|
||||||
|
"mtime": 1780937569.3996856,
|
||||||
|
"ast_hash": "35c153c1e3883075a3764f22f502086f",
|
||||||
|
"semantic_hash": "35c153c1e3883075a3764f22f502086f"
|
||||||
|
},
|
||||||
|
"tests/fixtures/README.md": {
|
||||||
|
"mtime": 1781346233.849243,
|
||||||
|
"ast_hash": "20b3f386a311601cd9b030977cf21939",
|
||||||
|
"semantic_hash": "20b3f386a311601cd9b030977cf21939"
|
||||||
|
},
|
||||||
|
"tests/fixtures/scarlet_fire_otis_mcdonald.mp3": {
|
||||||
|
"mtime": 1781346212.4539704,
|
||||||
|
"ast_hash": "43afbf0d60b7bfd568cb1b3db6be186d",
|
||||||
|
"semantic_hash": "43afbf0d60b7bfd568cb1b3db6be186d"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
# Graph Report - mcma-backend (2026-07-11)
|
||||||
|
|
||||||
|
## Corpus Check
|
||||||
|
- 180 files · ~52,130 words
|
||||||
|
- Verdict: corpus is large enough that graph structure adds value.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- 1994 nodes · 5692 edges · 133 communities (89 shown, 44 thin omitted)
|
||||||
|
- Extraction: 90% EXTRACTED · 10% INFERRED · 0% AMBIGUOUS · INFERRED: 573 edges (avg confidence: 0.55)
|
||||||
|
- Token cost: 0 input · 0 output
|
||||||
|
|
||||||
|
## Graph Freshness
|
||||||
|
- Built from commit: `fb5ce3c7`
|
||||||
|
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||||
|
- Run `graphify update .` after code changes (no API cost).
|
||||||
|
|
||||||
|
## Community Hubs (Navigation)
|
||||||
|
- Storage Backend & Disk Usage
|
||||||
|
- Auth Schemas & Tokens
|
||||||
|
- Error Mapping & Search Schemas
|
||||||
|
- Download Service Orchestration
|
||||||
|
- Playlist Schemas & API
|
||||||
|
- Album Repository
|
||||||
|
- ORM Base & Models
|
||||||
|
- Correlation ID Middleware & Config
|
||||||
|
- Auth Services (native + Subsonic)
|
||||||
|
- Audio Tags & Metadata Test Fakes
|
||||||
|
- YouTube Source Backend
|
||||||
|
- Dependency Wiring (deps.py)
|
||||||
|
- Library Stats & Track Availability
|
||||||
|
- Library Import Service
|
||||||
|
- Subsonic Response Envelope
|
||||||
|
- Chromaprint Fingerprinting
|
||||||
|
- Album/Search Response Schemas
|
||||||
|
- Pagination & Likes Endpoints
|
||||||
|
- Metadata Enrichment Service
|
||||||
|
- Subsonic ID Encoding
|
||||||
|
- Track & Artist Domain Entities
|
||||||
|
- Source Port Protocols
|
||||||
|
- User Management Service
|
||||||
|
- Metadata Enrichment Value Objects
|
||||||
|
- DB Session & Health Checks
|
||||||
|
- Download Service Tests
|
||||||
|
- Download Job Domain Entity
|
||||||
|
- Credentials & User Repository
|
||||||
|
- Streaming Auth Dependencies
|
||||||
|
- Subsonic Browsing Endpoints
|
||||||
|
- Remote Library Service Tests
|
||||||
|
- Worker Session Scope & Download Task
|
||||||
|
- Metadata Enrichment Pipeline
|
||||||
|
- Album Domain & Cover Value Object
|
||||||
|
- Downloads API Tests
|
||||||
|
- Subsonic Search Encoding
|
||||||
|
- In-Memory Auth Repository Fakes
|
||||||
|
- Storage Stats Response Schemas
|
||||||
|
- Subsonic App-Password Crypto
|
||||||
|
- Artist Schemas & Endpoints
|
||||||
|
- Download Request/Response Schemas
|
||||||
|
- Settings & MusicBrainz User-Agent
|
||||||
|
- Subsonic API Integration Tests
|
||||||
|
- Playlist Endpoints
|
||||||
|
- Play History Domain & Repository
|
||||||
|
- DB Engine Lifecycle
|
||||||
|
- Shared Query Param Annotations
|
||||||
|
- Local Folder Source Backend
|
||||||
|
- Track Metadata Match Schemas
|
||||||
|
- Like Domain Entity (event log)
|
||||||
|
- Auth API Integration Tests
|
||||||
|
- Cover API Tests
|
||||||
|
- Upload/Stream API Tests
|
||||||
|
- Tag Parsing Helpers
|
||||||
|
- Metadata API Tests
|
||||||
|
- Subsonic Auth Service Tests
|
||||||
|
- Cover Art Serving
|
||||||
|
- Play History Schemas
|
||||||
|
- Refresh Token Repository
|
||||||
|
- Radio Endpoints
|
||||||
|
- Subsonic Legacy Password Decoding
|
||||||
|
- Management CLI
|
||||||
|
- Sources API Tests
|
||||||
|
- Hexagonal Architecture Layers (doc)
|
||||||
|
- Test DB Fixtures (conftest)
|
||||||
|
- Upload Response Schemas
|
||||||
|
- Cover Art Extraction (Vorbis/MP4)
|
||||||
|
- Storage Stats API Tests
|
||||||
|
- AcoustID Response Parser Tests
|
||||||
|
- Like Request/Response Schemas
|
||||||
|
- Streaming Endpoint
|
||||||
|
- Auth Token Issue/Refresh Flow
|
||||||
|
- Generic Job/Result Store Protocol
|
||||||
|
- Remote Library Materialization
|
||||||
|
- CI Docker Publish Workflow
|
||||||
|
- User Scrobbling Settings
|
||||||
|
- Domain Invariants (dedup, manual overwrite, offline tags)
|
||||||
|
- Alembic Migration Environment
|
||||||
|
- Cover Art Archive Client
|
||||||
|
- Health Endpoint Smoke Tests
|
||||||
|
- ASGI Middleware Protocol
|
||||||
|
- File Hashing Utilities
|
||||||
|
- Package: app.api
|
||||||
|
- Package: app.api.schemas
|
||||||
|
- Package: app.application
|
||||||
|
- Package: app.core
|
||||||
|
- Package: app.domain
|
||||||
|
- Package: app.infrastructure
|
||||||
|
- Package: metadata-enrichment adapters
|
||||||
|
- Package: source backends
|
||||||
|
- Package: storage adapters
|
||||||
|
- Package: app root (mcma-backend)
|
||||||
|
- Package: arq worker
|
||||||
|
- Package: arq tasks
|
||||||
|
- Config Conventions (doc)
|
||||||
|
- Subsonic Adapter Convention (doc)
|
||||||
|
- DB Session Convention (doc)
|
||||||
|
- Health Convention (doc)
|
||||||
|
- Invariant: Graceful Degradation
|
||||||
|
- Invariant: Likes Are Append-Only
|
||||||
|
- Invariant: Stable Track content_id
|
||||||
|
- Logging Convention (doc)
|
||||||
|
- Python 3.14 Lazy Annotations Note
|
||||||
|
- App-Passwords Mechanism (doc)
|
||||||
|
- Local Dev Setup (doc)
|
||||||
|
- Tooling Section (doc)
|
||||||
|
- app/application — use cases / services
|
||||||
|
- Composition roots (app/main.py, app/api/deps.py)
|
||||||
|
- app/core — cross-cutting concerns
|
||||||
|
- app/domain — pure business core
|
||||||
|
- Error handling convention (domain errors → HTTP in app/api/errors.py)
|
||||||
|
- Hexagonal architecture (ports & adapters)
|
||||||
|
- app/infrastructure — driven adapters
|
||||||
|
- Invariant: dedup on (source, source_id) and acoustid_fingerprint
|
||||||
|
- Invariant: no heavy work in the request cycle (goes to arq workers)
|
||||||
|
- Invariant: never overwrite metadata_status=manual
|
||||||
|
- Migrations convention (async, settings-driven Alembic)
|
||||||
|
- app/workers — arq background tasks
|
||||||
|
- Build (Docker) section
|
||||||
|
- Hexagonal architecture (ports & adapters) — README
|
||||||
|
- Database migrations (Alembic) section
|
||||||
|
- Subsonic API (/rest) section
|
||||||
|
- acoustid_trust_score (trust high-confidence AcoustID over junk tags)
|
||||||
|
- Offline tag reader (deterministic, always runs)
|
||||||
|
- scarlet_fire_otis_mcdonald.mp3 test fixture
|
||||||
|
|
||||||
|
## God Nodes (most connected - your core abstractions)
|
||||||
|
1. `NotFoundError` - 95 edges
|
||||||
|
2. `UserService` - 57 edges
|
||||||
|
3. `Track` - 50 edges
|
||||||
|
4. `AuthenticationError` - 49 edges
|
||||||
|
5. `TrackRepository` - 49 edges
|
||||||
|
6. `SourceInfo` - 49 edges
|
||||||
|
7. `MetadataEnrichmentService` - 47 edges
|
||||||
|
8. `DownloadResult` - 47 edges
|
||||||
|
9. `get_settings()` - 46 edges
|
||||||
|
10. `session_scope()` - 45 edges
|
||||||
|
|
||||||
|
## Surprising Connections (you probably didn't know these)
|
||||||
|
- `test_id_malformed_rejected()` --indirect_call--> `NotFoundError` [INFERRED]
|
||||||
|
tests/test_subsonic_security.py → app/domain/errors.py
|
||||||
|
- `test_id_wrong_prefix_rejected()` --indirect_call--> `NotFoundError` [INFERRED]
|
||||||
|
tests/test_subsonic_security.py → app/domain/errors.py
|
||||||
|
- `FakeAcoustId` --uses--> `MetadataEnrichmentService` [INFERRED]
|
||||||
|
tests/test_metadata_service.py → app/application/metadata_service.py
|
||||||
|
- `FakeAlbumRepo` --uses--> `MetadataEnrichmentService` [INFERRED]
|
||||||
|
tests/test_metadata_service.py → app/application/metadata_service.py
|
||||||
|
- `FakeArtistRepo` --uses--> `MetadataEnrichmentService` [INFERRED]
|
||||||
|
tests/test_metadata_service.py → app/application/metadata_service.py
|
||||||
|
|
||||||
|
## Import Cycles
|
||||||
|
- None detected.
|
||||||
|
|
||||||
|
## Hyperedges (group relationships)
|
||||||
|
- **Cross-cutting conventions (errors, config, logging, db sessions, migrations, health)** — claude_error_handling_convention, claude_config_convention, claude_logging_convention, claude_db_sessions_convention, claude_migrations_convention, claude_health_convention [EXTRACTED 1.00]
|
||||||
|
- **Non-negotiable domain invariants (for future sync + ML)** — claude_invariant_likes_event_log, claude_invariant_track_id_stable, claude_invariant_dedup, claude_invariant_graceful_degradation, claude_invariant_no_manual_overwrite, claude_invariant_no_heavy_work_in_request, claude_invariant_subsonic_compatibility [EXTRACTED 1.00]
|
||||||
|
- **Hexagonal architecture layers (domain/application/infrastructure/api/core/workers)** — claude_domain_layer, claude_application_layer, claude_infrastructure_layer, claude_api_layer, claude_core_layer, claude_workers_layer [EXTRACTED 1.00]
|
||||||
|
|
||||||
|
## Communities (133 total, 44 thin omitted)
|
||||||
|
|
||||||
|
### Community 0 - "Storage Backend & Disk Usage"
|
||||||
|
Cohesion: 0.05
|
||||||
|
Nodes (54): DiskUsage, ObjectStat, Value objects for file storage., Capacity of the volume backing the media store. ``None`` for backends (e.g., File storage operation failed., StorageError, Capacity of the volume backing the store, or ``None`` when the backend h, LocalFileStorage (+46 more)
|
||||||
|
|
||||||
|
### Community 1 - "Auth Schemas & Tokens"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (59): LoginRequest, BaseModel, Auth request/response schemas. Tokens are returned in the body (the client store, RefreshRequest, RegisterRequest, TokenResponse, BaseModel, Schemas for Subsonic app-password self-service (native /api/v1 surface). The Su (+51 more)
|
||||||
|
|
||||||
|
### Community 2 - "Error Mapping & Search Schemas"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (39): _is_subsonic(), FastAPI, Maps domain exceptions to HTTP responses. The only place that knows both. Two s, register_exception_handlers(), ExternalSearchResponse, ExternalSearchResultOut, BaseModel, Flat list of hits across one or more searchable sources, plus the names of s (+31 more)
|
||||||
|
|
||||||
|
### Community 3 - "Download Service Orchestration"
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (20): DownloadRequest, EnrichEnqueuer, DownloadService — request external downloads and import their results. Two role, Outcome of asking for a download. Exactly one of the three states holds: th, EnrichEnqueuer, Path, Protocol, UploadService — handles user file uploads. (+12 more)
|
||||||
|
|
||||||
|
### Community 4 - "Playlist Schemas & API"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (28): PlaylistAddTrack, PlaylistCreate, PlaylistOut, PlaylistReorder, PlaylistUpdate, BaseModel, Playlist request/response schemas., add_playlist_track() (+20 more)
|
||||||
|
|
||||||
|
### Community 5 - "Album Repository"
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (11): AlbumRepository, DownloadJobRepository, LikeRepository, PlaylistRepository, datetime, UUID, Resolve/create an album bound to a remote ``(source, source_id)`` (lazy, Persistence for download jobs (plan §6.1). Drives the §A5 download manager a (+3 more)
|
||||||
|
|
||||||
|
### Community 6 - "ORM Base & Models"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (31): Base, Declarative base with a fixed naming convention. The naming convention makes Al, Base for all ORM models. Import models so Alembic sees their metadata., ORM model for albums., ORM model for artists., ORM model for download jobs (plan §6.1). Tracks a queued download through its l, LikeValue, LyricsStatus (+23 more)
|
||||||
|
|
||||||
|
### Community 7 - "Correlation ID Middleware & Config"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (17): CorrelationIdMiddleware, HTTP middleware: bind a correlation id and log each request., Pure-ASGI middleware: reuse inbound ``X-Correlation-Id`` or mint one, bind i, app_version(), User-Agent sent to MusicBrainz/AcoustID: ``MCMA/<version> ( <contact> )``., _add_correlation_id(), configure_logging(), create_app() (+9 more)
|
||||||
|
|
||||||
|
### Community 8 - "Auth Services (native + Subsonic)"
|
||||||
|
Cohesion: 0.09
|
||||||
|
Nodes (42): EnrichmentResult, AcoustIdClient, AudioFingerprinter, AudioTagReader, CoverArtExtractor, CoverArtProvider, FetchableSource, HistoryRepository (+34 more)
|
||||||
|
|
||||||
|
### Community 9 - "Audio Tags & Metadata Test Fakes"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (28): AudioTags, Embedded tags read from the file itself (ID3 / Vorbis / MP4 …). Every field, _cover_service(), FakeAlbumRepo, FakeArtistRepo, FakeCoverExtractor, FakeCoverProvider, FakeTagReader (+20 more)
|
||||||
|
|
||||||
|
### Community 10 - "YouTube Source Backend"
|
||||||
|
Cohesion: 0.08
|
||||||
|
Nodes (32): One hit from a searchable source (plan §5), shown on the discover screen. `, SearchResult, Which backend imported a track. Drives ``is_replaceable`` (plan §6.6)., TrackSource, _default_download(), _default_search(), _libs_available(), Any (+24 more)
|
||||||
|
|
||||||
|
### Community 11 - "Dependency Wiring (deps.py)"
|
||||||
|
Cohesion: 0.11
|
||||||
|
Nodes (29): get_album_repository(), get_artist_repository(), get_auth_service(), get_download_service(), get_history_repository(), get_like_repository(), get_metadata_service(), get_password_hasher() (+21 more)
|
||||||
|
|
||||||
|
### Community 12 - "Library Stats & Track Availability"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (18): FormatBreakdown, LibraryStats, Per-container-format slice of the library (e.g. ``flac`` → 312 tracks)., Aggregate facts about everything the instance has stored. Computed from the, Track, Whether a track's audio is on local storage or still a remote placeholder (p, TrackAvailability, TrackModel (+10 more)
|
||||||
|
|
||||||
|
### Community 13 - "Library Import Service"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (19): ImportSummary, LibraryImportService, UUID, LibraryImportService — imports files discovered by an indexable source. Batch s, Source-backend value objects — framework-free. A *source* is a place tracks com, A single importable file discovered by an indexable source. ``source_id`` i, SourceFile, ``local`` source — indexes audio files from a mounted folder. Walks a configure (+11 more)
|
||||||
|
|
||||||
|
### Community 14 - "Subsonic Response Envelope"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (30): _build_xml(), _is_json(), Any, Element, Response, The Subsonic response envelope — one serializer, two wire formats. Every Subson, A ``status="failed"`` envelope carrying a Subsonic ``<error>``., Recursively drop ``None`` values so JSON output matches XML (no empty attrs). (+22 more)
|
||||||
|
|
||||||
|
### Community 15 - "Chromaprint Fingerprinting"
|
||||||
|
Cohesion: 0.09
|
||||||
|
Nodes (24): AcoustIdHttpClient, Implements :class:`app.domain.ports.AcoustIdClient`., FpcalcFingerprinter, Path, FpcalcFingerprinter — Chromaprint fingerprint via the ``fpcalc`` binary. ``fpca, Implements :class:`app.domain.ports.AudioFingerprinter`., MutagenTagReader, Implements :class:`app.domain.ports.AudioTagReader`. (+16 more)
|
||||||
|
|
||||||
|
### Community 16 - "Album/Search Response Schemas"
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (33): AlbumOut, BaseModel, Album request/response schemas., ArtistOut, BaseModel, Artist request/response schemas., PagedResponse, BaseModel (+25 more)
|
||||||
|
|
||||||
|
### Community 17 - "Pagination & Likes Endpoints"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (39): MaterializeResponse, MetadataApply, MetadataMatch, MetadataMatchesOut, BaseModel, Track request/response schemas., One AcoustID candidate for the metadata editor's match picker (§A7)., Manual edits / accepted match applied via ``PUT /tracks/{id}/metadata``. Se (+31 more)
|
||||||
|
|
||||||
|
### Community 18 - "Metadata Enrichment Service"
|
||||||
|
Cohesion: 0.11
|
||||||
|
Nodes (11): _first_int(), MetadataEnrichmentService, _opt_str(), UUID, MetadataEnrichmentService — the §6.2 pipeline orchestrator. Order (tag-first):, Explain a ``failed`` (no-identity) run in terms a user can act on: which, AcoustID candidates for the metadata editor's match picker (§A7). Read-, Fill in an album cover when it has none. Source order mirrors the tag-fi (+3 more)
|
||||||
|
|
||||||
|
### Community 19 - "Subsonic ID Encoding"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (29): Subsonic annotation endpoints: star/unstar, rating, scrobble. * ``star``/``unst, decode_album(), decode_artist(), _decode_as(), decode_track(), encode(), encode_track(), IdKind (+21 more)
|
||||||
|
|
||||||
|
### Community 20 - "Track & Artist Domain Entities"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (17): Schemas for searching external (fetch) sources — the §A4 discover screen., Artist, Track and Artist domain entities., Resolve/create an artist bound to a remote ``(source, source_id)`` (lazy, ArtistModel, AsyncSession, UUID, Artist repository — adapter over ``AsyncSession``. (+9 more)
|
||||||
|
|
||||||
|
### Community 21 - "Source Port Protocols"
|
||||||
|
Cohesion: 0.18
|
||||||
|
Nodes (11): Playlist, Playlist domain entity., PlaylistModel, PlaylistTrackModel, A track's membership in a playlist. ``position`` is a float so a track can, AsyncSession, UUID, Playlist repository — adapter over ``AsyncSession``. (+3 more)
|
||||||
|
|
||||||
|
### Community 22 - "User Management Service"
|
||||||
|
Cohesion: 0.11
|
||||||
|
Nodes (36): AuthService, _hash_token(), UUID, Authentication use cases: login, token refresh (rotation), logout, and access-to, At-rest hash of a refresh token. A signed JWT is high-entropy, so a fast SHA, User-management use cases: admin CRUD plus self-service password change. Deleti, UserService, _cmd_create_admin() (+28 more)
|
||||||
|
|
||||||
|
### Community 23 - "Metadata Enrichment Value Objects"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (10): Domain entities and value objects — pure, framework-free., Fingerprint, Value objects for the metadata-enrichment pipeline (plan §6.2). Pure data carri, Chromaprint fingerprint plus the decoded duration (both needed by AcoustID)., A single AcoustID result, flattened to the fields enrichment cares about. `, RecordingMatch, _parse_matches(), _parse_one() (+2 more)
|
||||||
|
|
||||||
|
### Community 24 - "DB Session & Health Checks"
|
||||||
|
Cohesion: 0.16
|
||||||
|
Nodes (17): _check_db(), _check_ml(), _check_redis(), health(), HealthResponse, BaseModel, Response, Health & readiness endpoints — used by compose healthchecks and the admin UI. * (+9 more)
|
||||||
|
|
||||||
|
### Community 25 - "Download Service Tests"
|
||||||
|
Cohesion: 0.16
|
||||||
|
Nodes (14): DownloadService, FakeArtistRepo, FakeJobRepo, FakeStorage, FakeTrackRepo, Path, UUID, Unit tests for DownloadService — DB-free, in-memory fakes. (+6 more)
|
||||||
|
|
||||||
|
### Community 26 - "Download Job Domain Entity"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (10): DownloadJob, Download job domain entity (plan §6.1). A queued fetch from an external source,, An unfinished (queued/downloading/enriching) job for the same item, if a, DownloadJobModel, DownloadStatus, Lifecycle of a download job (plan §6.1)., datetime, UUID (+2 more)
|
||||||
|
|
||||||
|
### Community 27 - "Credentials & User Repository"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (20): get_current_superuser(), get_current_user(), get_streaming_user(), AuthServiceDep, CurrentUser, Authenticate a stream request. The browser ``<audio>`` element cannot send, Credentials, User entity. Admin is a single ``is_superuser`` flag — no role system in Phase (+12 more)
|
||||||
|
|
||||||
|
### Community 28 - "Streaming Auth Dependencies"
|
||||||
|
Cohesion: 0.31
|
||||||
|
Nodes (4): UUID, Admin-driven password reset. Revokes all sessions., Soft delete: disable the account, keep the row for referential history., Self-service change: verify the current password first.
|
||||||
|
|
||||||
|
### Community 29 - "Subsonic Browsing Endpoints"
|
||||||
|
Cohesion: 0.26
|
||||||
|
Nodes (26): _albums_for_artist(), _artists_index(), _decorate_albums(), get_album(), get_album_list(), get_album_list2(), get_artist(), get_artists() (+18 more)
|
||||||
|
|
||||||
|
### Community 30 - "Remote Library Service Tests"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (17): RemoteLibraryService, Input is well-formed but violates a business rule., ValidationError, FakeArtistRepo, FakeJobRepo, FakeTrackRepo, _local_track(), UUID (+9 more)
|
||||||
|
|
||||||
|
### Community 31 - "Worker Session Scope & Download Task"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (24): Transactional session for workers/scripts: commit on success, rollback on error., session_scope(), AsyncSession, SqlAlchemyDownloadJobRepository, build_source_registry(), download_track(), _handle_failure(), _import_result() (+16 more)
|
||||||
|
|
||||||
|
### Community 33 - "Album Domain & Cover Value Object"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (10): Album, AlbumModel, AsyncSession, UUID, Album repository — adapter over ``AsyncSession``., Resolve an album by ``(title, artist_id)``, creating it if absent. Backf, Resolve an album by ``(source, source_id)`` first (re-browse/save dedup), SqlAlchemyAlbumRepository (+2 more)
|
||||||
|
|
||||||
|
### Community 34 - "Downloads API Tests"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (19): MonkeyPatch, api(), _db_reachable(), FakeFetchSource, _login(), Any, AsyncClient, Path (+11 more)
|
||||||
|
|
||||||
|
### Community 35 - "Subsonic Search Encoding"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (22): encode_album(), encode_artist(), Subsonic-compatible API layer mounted at /rest., AlbumRepoDep, ArtistRepoDep, ge, le, Query (+14 more)
|
||||||
|
|
||||||
|
### Community 36 - "In-Memory Auth Repository Fakes"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (8): InMemoryRefreshTokenRepository, InMemoryUserRepository, datetime, UUID, In-memory port implementations for fast, DB-free unit tests., _Stored, _Token, env()
|
||||||
|
|
||||||
|
### Community 37 - "Storage Stats Response Schemas"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (20): DiskUsageOut, FormatBreakdownOut, GenreCountOut, BaseModel, Storage / library statistics response schemas (§A6)., Everything the Storage screen needs in a single call., StorageStatsOut, get_broken_files() (+12 more)
|
||||||
|
|
||||||
|
### Community 38 - "Subsonic App-Password Crypto"
|
||||||
|
Cohesion: 0.19
|
||||||
|
Nodes (10): generate_subsonic_password(), A fresh, high-entropy Subsonic app-password (url-safe, ~24 chars)., Unit tests for Subsonic crypto + id helpers (no DB, no network)., The decrypted app-password must reproduce a client's t=md5(password+salt)., test_cipher_roundtrip(), test_cipher_token_then_md5_matches(), test_cipher_wrong_key_fails(), test_generate_subsonic_password_is_long_and_unique() (+2 more)
|
||||||
|
|
||||||
|
### Community 39 - "Artist Schemas & Endpoints"
|
||||||
|
Cohesion: 0.19
|
||||||
|
Nodes (14): enqueue(), enqueue_download(), enqueue_enrich(), enqueue_materialize(), Any, UUID, Enqueue helper — submit a job to the arq queue from the request cycle. A short-, Enqueue ``function`` by name, returning the job id. Raises :class:`Dependenc (+6 more)
|
||||||
|
|
||||||
|
### Community 40 - "Download Request/Response Schemas"
|
||||||
|
Cohesion: 0.23
|
||||||
|
Nodes (17): DownloadCreate, DownloadCreateResponse, DownloadJobOut, BaseModel, Schemas for the download job endpoints (§A5 download manager)., Request to download an item discovered on a fetch source., Result of requesting a download. ``already_in_library`` → the item was alre, cancel_download() (+9 more)
|
||||||
|
|
||||||
|
### Community 41 - "Settings & MusicBrainz User-Agent"
|
||||||
|
Cohesion: 0.16
|
||||||
|
Nodes (14): Settings, Argon2PasswordHasher, JwtTokenService, Security adapters: password hashing (argon2 via pwdlib) and JWT (pyjwt). These, argon2id hasher with sensible defaults from pwdlib., Issues and verifies HS256 JWTs for access + refresh tokens. TTLs come from, BaseSettings, Unit tests for the security adapters (no DB, no network). (+6 more)
|
||||||
|
|
||||||
|
### Community 42 - "Subsonic API Integration Tests"
|
||||||
|
Cohesion: 0.28
|
||||||
|
Nodes (18): _auth_params(), _login(), AsyncClient, Integration tests for the Subsonic /rest layer (happy path per endpoint group)., _seed_track(), _setup(), _subsonic_password(), test_get_album_list2() (+10 more)
|
||||||
|
|
||||||
|
### Community 43 - "Playlist Endpoints"
|
||||||
|
Cohesion: 0.31
|
||||||
|
Nodes (19): decode_playlist(), encode_playlist(), create_playlist(), delete_playlist(), get_playlist(), get_playlists(), _owned_playlist(), _playlist_dict() (+11 more)
|
||||||
|
|
||||||
|
### Community 44 - "Play History Domain & Repository"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (9): PlayHistoryEntry, Play history domain entity — append-only scrobble log entry., PlayHistoryModel, AsyncSession, datetime, UUID, Play history repository — adapter over ``AsyncSession``., SqlAlchemyHistoryRepository (+1 more)
|
||||||
|
|
||||||
|
### Community 45 - "DB Engine Lifecycle"
|
||||||
|
Cohesion: 0.17
|
||||||
|
Nodes (17): dispose_engine(), get_engine(), get_sessionmaker(), AsyncSession, Async engine + session factory, created lazily from settings. The engine is pro, Dispose the pooled engine on shutdown. Safe to call if never initialized., Database adapter: declarative base, async engine, session factory., async_sessionmaker (+9 more)
|
||||||
|
|
||||||
|
### Community 46 - "Shared Query Param Annotations"
|
||||||
|
Cohesion: 0.31
|
||||||
|
Nodes (13): ge, HistoryRepoDep, le, LikeRepoDep, Query, Response, SubsonicFormat, SubsonicUser (+5 more)
|
||||||
|
|
||||||
|
### Community 47 - "Local Folder Source Backend"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (12): LocalFolderSource, Path, Implements :class:`app.domain.ports.IndexableSource`., Path, Unit tests for the local-folder source + registry (no DB, no network)., _settings(), test_info_reports_kind_and_availability(), test_is_available_false_when_missing() (+4 more)
|
||||||
|
|
||||||
|
### Community 48 - "Track Metadata Match Schemas"
|
||||||
|
Cohesion: 0.45
|
||||||
|
Nodes (13): _create_playlist(), _login(), Any, AsyncClient, Integration tests for the v1 playlist API. Requires a reachable Postgres; skips, test_add_track_idempotent(), test_playlist_lifecycle(), test_remove_track() (+5 more)
|
||||||
|
|
||||||
|
### Community 49 - "Like Domain Entity (event log)"
|
||||||
|
Cohesion: 0.27
|
||||||
|
Nodes (8): Like, Like domain entity — append-only event log entry., LikeModel, AsyncSession, UUID, Like repository — adapter over ``AsyncSession``. Likes are an append-only event, SqlAlchemyLikeRepository, _to_entity()
|
||||||
|
|
||||||
|
### Community 50 - "Auth API Integration Tests"
|
||||||
|
Cohesion: 0.24
|
||||||
|
Nodes (17): api(), _db_reachable(), _login(), AsyncClient, Integration tests for the auth + admin HTTP surface. These require a reachable, test_admin_create_duplicate_conflicts(), test_admin_creates_user_and_nonadmin_forbidden(), test_deactivated_user_cannot_login() (+9 more)
|
||||||
|
|
||||||
|
### Community 51 - "Cover API Tests"
|
||||||
|
Cohesion: 0.30
|
||||||
|
Nodes (14): api(), _db_reachable(), _login(), AsyncClient, Path, UUID, Integration tests for the native cover-art endpoints. Seeds an album with a sto, Create an artist + album (+ optional cover file) + track. Returns ``(album_i (+6 more)
|
||||||
|
|
||||||
|
### Community 52 - "Upload/Stream API Tests"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (14): api(), _db_reachable(), _login(), AsyncClient, Path, Integration tests for upload and streaming endpoints. Requires a reachable Post, test_list_tracks_filters_by_source(), test_stream_full() (+6 more)
|
||||||
|
|
||||||
|
### Community 53 - "Tag Parsing Helpers"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (10): _first(), _parse_track_number(), _parse_year(), Path, MutagenTagReader — reads embedded tags from a local audio file. The offline fir, EasyXxx tags expose values as lists; take the first non-empty string., Unit tests for the mutagen tag-parsing helpers — pure, no files., test_first_takes_head_of_list() (+2 more)
|
||||||
|
|
||||||
|
### Community 54 - "Metadata API Tests"
|
||||||
|
Cohesion: 0.35
|
||||||
|
Nodes (13): api(), _db_reachable(), _login(), AsyncClient, Path, Integration tests for the metadata-editor endpoints (§A7, §1H). Requires a reac, test_apply_metadata_not_found(), test_apply_metadata_partial_update() (+5 more)
|
||||||
|
|
||||||
|
### Community 55 - "Subsonic Auth Service Tests"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (18): UUID, Symmetric encrypt/decrypt for the recoverable Subsonic app-password. Subson, SubsonicPasswordCipher, _md5(), Unit tests for SubsonicAuthService — verification + app-password lifecycle. DB-, _service_with_user(), test_authenticate_enc_password_success(), test_authenticate_inactive_user_fails() (+10 more)
|
||||||
|
|
||||||
|
### Community 56 - "Cover Art Serving"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (16): _content_type_for(), StreamingResponse, UUID, Shared cover-art serving helper (presentation). Streams a stored cover image fr, Stream a stored cover by its storage key. Raises ``NotFoundError`` if the ob, The album that owns a track (cover lives on the album), or ``None``., resolve_album_for_track(), stream_cover() (+8 more)
|
||||||
|
|
||||||
|
### Community 57 - "Play History Schemas"
|
||||||
|
Cohesion: 0.24
|
||||||
|
Nodes (11): HistoryIn, HistoryOut, BaseModel, Play history request/response schemas., get_history(), CurrentUser, HistoryRepoDep, Response (+3 more)
|
||||||
|
|
||||||
|
### Community 58 - "Refresh Token Repository"
|
||||||
|
Cohesion: 0.27
|
||||||
|
Nodes (5): AsyncSession, datetime, UUID, Refresh-token repository — adapter implementing ``app.domain.ports.RefreshTokenR, SqlAlchemyRefreshTokenRepository
|
||||||
|
|
||||||
|
### Community 59 - "Radio Endpoints"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (9): Native REST API, version 1. Aggregates feature routers under ``/api/v1``., next_radio_track(), Any, Radio / continuous-mix endpoints. Degrades gracefully when ML service is down., start_radio(), get_changes(), push_changes(), Any (+1 more)
|
||||||
|
|
||||||
|
### Community 60 - "Subsonic Legacy Password Decoding"
|
||||||
|
Cohesion: 0.23
|
||||||
|
Nodes (9): _decode_legacy_password(), _md5_hex(), UUID, SubsonicAuthService — app-password lifecycle + Subsonic auth verification. The, Decode a Subsonic ``p`` param: ``enc:<hex>`` (hex-encoded) or plaintext., Resolve Subsonic query auth params to a domain :class:`User`. Raises :c, Generate a fresh app-password, store it encrypted, return the plaintext., Return the current app-password, generating one on first access. (+1 more)
|
||||||
|
|
||||||
|
### Community 61 - "Management CLI"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (4): PasswordHasher, True iff a row exists for ``jti`` that is neither revoked nor expired., Verify ``password`` against ``password_hash``. Returns ``(is_valid, upda, RefreshTokenRepository
|
||||||
|
|
||||||
|
### Community 62 - "Sources API Tests"
|
||||||
|
Cohesion: 0.36
|
||||||
|
Nodes (9): api(), _db_reachable(), _login(), AsyncClient, Path, Integration tests for sources: enumeration + the real import path. Requires a r, test_list_sources_includes_local(), test_local_import_creates_streamable_tracks() (+1 more)
|
||||||
|
|
||||||
|
### Community 64 - "Test DB Fixtures (conftest)"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (11): _base_database_url(), client(), _create_test_db_if_missing(), _ensure_test_database(), AsyncClient, Shared test fixtures. The ASGI app is driven in-process via httpx + asgi-lifesp, Resolve the DB URL the app *would* use, mirroring pydantic-settings preceden, Return ``url`` with its database name swapped for ``name``. (+3 more)
|
||||||
|
|
||||||
|
### Community 65 - "Upload Response Schemas"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (9): BaseModel, Schemas for upload responses., UploadResponse, CurrentUser, Local file upload endpoint., upload_file(), File, UploadFile (+1 more)
|
||||||
|
|
||||||
|
### Community 66 - "Cover Art Extraction (Vorbis/MP4)"
|
||||||
|
Cohesion: 0.23
|
||||||
|
Nodes (10): _decode_vorbis_picture(), _from_picture(), _front_or_first(), MutagenCoverExtractor, Any, Path, MutagenCoverExtractor — pulls embedded cover art from a local audio file. The o, Prefer the front-cover picture (type 3), else the first available. (+2 more)
|
||||||
|
|
||||||
|
### Community 67 - "Storage Stats API Tests"
|
||||||
|
Cohesion: 0.35
|
||||||
|
Nodes (10): api(), _db_reachable(), _login(), AsyncClient, Path, Integration tests for the storage statistics endpoint (§A6). Requires a reachab, test_storage_stats_counts_uploads(), test_storage_stats_empty_library() (+2 more)
|
||||||
|
|
||||||
|
### Community 68 - "AcoustID Response Parser Tests"
|
||||||
|
Cohesion: 0.42
|
||||||
|
Nodes (9): _parse_best_match(), _payload_with_results(), Unit tests for the AcoustID response parser — pure, no network., test_empty_results_returns_none(), test_error_status_returns_none(), test_non_dict_payload_returns_none(), test_parses_full_recording(), test_picks_highest_score() (+1 more)
|
||||||
|
|
||||||
|
### Community 69 - "Like Request/Response Schemas"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (12): LikeEvent, LikeState, BaseModel, Like request/response schemas., add_like(), get_likes(), get_likes_state(), AlbumRepoDep (+4 more)
|
||||||
|
|
||||||
|
### Community 70 - "Streaming Endpoint"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (8): alias, Header, StreamingResponse, StreamingServiceDep, StreamUser, UUID, Audio streaming endpoint — direct stream with Range support., stream_track()
|
||||||
|
|
||||||
|
### Community 71 - "Auth Token Issue/Refresh Flow"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (3): FakeFingerprinter, FakeStorage, Path
|
||||||
|
|
||||||
|
### Community 72 - "Generic Job/Result Store Protocol"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (3): UUID, Remove the job record. True mid-flight cancellation of an in-progress yt, Store a freshly downloaded file and create a minimal ``pending`` track.
|
||||||
|
|
||||||
|
### Community 73 - "Remote Library Materialization"
|
||||||
|
Cohesion: 0.25
|
||||||
|
Nodes (6): MaterializeOutcome, UUID, RemoteLibraryService — save-to-library + materialize for remote browse hits (pla, Result of requesting materialization. ``job`` is ``None`` when the track is, Persist a placeholder for a remote browse hit. Idempotent: a hit already, Kick off (or report on) materializing a placeholder track. Already-loca
|
||||||
|
|
||||||
|
### Community 74 - "CI Docker Publish Workflow"
|
||||||
|
Cohesion: 0.33
|
||||||
|
Nodes (7): Docker Buildx, dockerfiles/Dockerfile.prod, Gitea container registry, build job (Docker Build & Publish), cleanup job (Prune old image versions), KEEP_VERSIONS env (retention policy), push job (Docker Build & Publish)
|
||||||
|
|
||||||
|
### Community 75 - "User Scrobbling Settings"
|
||||||
|
Cohesion: 0.43
|
||||||
|
Nodes (6): get_scrobbling_settings(), get_settings(), Any, User settings endpoints, including scrobbling configuration., set_scrobbling_settings(), update_settings()
|
||||||
|
|
||||||
|
### Community 76 - "Domain Invariants (dedup, manual overwrite, offline tags)"
|
||||||
|
Cohesion: 0.18
|
||||||
|
Nodes (10): App-passwords, Architecture — hexagonal (ports & adapters), Build (Docker), Configuration, Database migrations (Alembic), Local dev (without Docker), mcma-backend, Sources & importing music (+2 more)
|
||||||
|
|
||||||
|
### Community 77 - "Alembic Migration Environment"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (17): Alembic environment — async, settings-driven, model-aware. The DB URL comes fro, _run_migrations(), run_migrations_online(), get_source_registry(), get_settings(), Application settings — single source of truth, sourced from environment. Nothin, Cached settings singleton. Patch the cache in tests via ``get_settings.cache_cle, get_file_storage() (+9 more)
|
||||||
|
|
||||||
|
### Community 78 - "Cover Art Archive Client"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (6): get_logger(), Structured logging via structlog. Emits key=value (dev) or JSON (prod) with a p, CoverArtArchiveClient, CoverArtArchiveClient — fetches front cover art from the Cover Art Archive. The, Implements :class:`app.domain.ports.CoverArtProvider`., FilteringBoundLogger
|
||||||
|
|
||||||
|
### Community 79 - "Health Endpoint Smoke Tests"
|
||||||
|
Cohesion: 0.47
|
||||||
|
Nodes (5): AsyncClient, Smoke tests for the health endpoints., test_correlation_id_echoed(), test_liveness_ok(), test_readiness_reports_checks()
|
||||||
|
|
||||||
|
### Community 80 - "ASGI Middleware Protocol"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (7): Architecture — hexagonal (ports & adapters), Commands, Cross-cutting conventions, graphify, Non-negotiable domain invariants, Python 3.14 note, What this is
|
||||||
|
|
||||||
|
### Community 81 - "File Hashing Utilities"
|
||||||
|
Cohesion: 0.50
|
||||||
|
Nodes (3): Path, File hashing utilities., sha256_of_file()
|
||||||
|
|
||||||
|
### Community 110 - "App-Passwords Mechanism (doc)"
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (8): AlbumRepoDep, ArtistRepoDep, CurrentUser, SourceRegistryDep, TrackRepoDep, Search every available fetch source and merge the hits (§A4 discover). A so, search(), search_library()
|
||||||
|
|
||||||
|
## Knowledge Gaps
|
||||||
|
- **32 isolated node(s):** `WorkerSettings`, `What this is`, `Commands`, `Cross-cutting conventions`, `Non-negotiable domain invariants` (+27 more)
|
||||||
|
These have ≤1 connection - possible missing edges or undocumented components.
|
||||||
|
- **44 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||||
|
|
||||||
|
## Suggested Questions
|
||||||
|
_Questions this graph is uniquely positioned to answer:_
|
||||||
|
|
||||||
|
- **Why does `NotFoundError` connect `Subsonic ID Encoding` to `Error Mapping & Search Schemas`, `Download Service Orchestration`, `Playlist Schemas & API`, `Auth Services (native + Subsonic)`, `Library Stats & Track Availability`, `Album/Search Response Schemas`, `Pagination & Likes Endpoints`, `Source Port Protocols`, `User Management Service`, `Download Service Tests`, `Credentials & User Repository`, `Streaming Auth Dependencies`, `Subsonic Browsing Endpoints`, `Remote Library Service Tests`, `Worker Session Scope & Download Task`, `Subsonic App-Password Crypto`, `Playlist Endpoints`, `Shared Query Param Annotations`, `Cover Art Serving`, `Play History Schemas`, `Subsonic Legacy Password Decoding`, `Generic Job/Result Store Protocol`, `Remote Library Materialization`?**
|
||||||
|
_High betweenness centrality (0.117) - this node is a cross-community bridge._
|
||||||
|
- **Why does `get_settings()` connect `Alembic Migration Environment` to `Auth Schemas & Tokens`, `Downloads API Tests`, `Storage Stats API Tests`, `Correlation ID Middleware & Config`, `Artist Schemas & Endpoints`, `Settings & MusicBrainz User-Agent`, `Subsonic API Integration Tests`, `Dependency Wiring (deps.py)`, `DB Engine Lifecycle`, `Chromaprint Fingerprinting`, `Track Metadata Match Schemas`, `Auth API Integration Tests`, `Cover API Tests`, `Upload/Stream API Tests`, `Metadata API Tests`, `DB Session & Health Checks`, `Sources API Tests`, `Worker Session Scope & Download Task`?**
|
||||||
|
_High betweenness centrality (0.069) - this node is a cross-community bridge._
|
||||||
|
- **Why does `UserService` connect `User Management Service` to `Downloads API Tests`, `Storage Stats API Tests`, `In-Memory Auth Repository Fakes`, `Album Repository`, `Subsonic API Integration Tests`, `Dependency Wiring (deps.py)`, `DB Engine Lifecycle`, `Track Metadata Match Schemas`, `Auth API Integration Tests`, `Subsonic ID Encoding`, `Cover API Tests`, `Upload/Stream API Tests`, `Metadata API Tests`, `Credentials & User Repository`, `Streaming Auth Dependencies`, `Management CLI`, `Sources API Tests`?**
|
||||||
|
_High betweenness centrality (0.044) - this node is a cross-community bridge._
|
||||||
|
- **Are the 22 inferred relationships involving `NotFoundError` (e.g. with `IdKind` and `DownloadRequest`) actually correct?**
|
||||||
|
_`NotFoundError` has 22 INFERRED edges - model-reasoned connections that need verification._
|
||||||
|
- **Are the 7 inferred relationships involving `UserService` (e.g. with `AlreadyExistsError` and `AuthenticationError`) actually correct?**
|
||||||
|
_`UserService` has 7 INFERRED edges - model-reasoned connections that need verification._
|
||||||
|
- **Are the 25 inferred relationships involving `AuthenticationError` (e.g. with `AuthService` and `SubsonicAuthService`) actually correct?**
|
||||||
|
_`AuthenticationError` has 25 INFERRED edges - model-reasoned connections that need verification._
|
||||||
|
- **What connects `Alembic environment — async, settings-driven, model-aware. The DB URL comes fro`, `mcma-backend — self-hosted, offline-first music service. Hexagonal (ports & ada`, `Driving adapter — FastAPI routers, schemas, dependency wiring.` to the rest of the system?**
|
||||||
|
_376 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||||
graphify-out/cache/ast/v0.9.12/00419db7cb0435ec28854e13d74468f5e88f292fa1176c80c80d69dfc65285ff.json
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_app_core_init_py", "label": "__init__.py", "file_type": "code", "source_file": "app/core/__init__.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_core_init_rationale_1", "label": "Cross-cutting concerns: configuration, logging, security.", "file_type": "rationale", "source_file": "app/core/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_app_core_init_rationale_1", "target": "users_senko_work_mcma_compose_mcma_backend_app_core_init_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/core/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||||
graphify-out/cache/ast/v0.9.12/00b6b2fb10e6e0a2fd98120c8447117bcbece825b9387ec17017f8d4a38f16a9.json
Vendored
+1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user