Compare commits

...

9 Commits

Author SHA1 Message Date
Senko-san df580578f6 stuff
Docker Build & Publish / build (push) Successful in 1m29s
Docker Build & Publish / push (push) Failing after 9s
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-07-23 12:01:55 +03:00
Senko-san fb5ce3c708 chore: graphify!
Docker Build & Publish / build (push) Successful in 1m11s
Docker Build & Publish / push (push) Failing after 7s
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-07-11 16:21:34 +03:00
Senko-san e45e578f54 feat(library): remote browse status + save/materialize API (§Phase2-3)
Docker Build & Publish / build (push) Successful in 1m11s
Docker Build & Publish / push (push) Failing after 6s
Docker Build & Publish / Prune old image versions (push) Has been skipped
Search results now report whether a hit is already saved (in_library,
track_id, availability). New RemoteLibraryService backs POST
/tracks/remote (idempotent placeholder save) and POST
/tracks/{id}/materialize (on-demand fetch via a new materialize_track
arq task, reusing in-flight jobs).

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:19:53 +03:00
Senko-san 636820afb8 fix: invalid Python 2 except syntax in AcoustID client
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
The except clause used the Python 2 multi-exception syntax, which is
a SyntaxError under Python 3.14 and broke import of this module.
2026-06-14 01:01:33 +03:00
Senko-san 63c7d05eca feat(metadata): implement single-track metadata editor API (§A7/§1H)
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Adds inline AcoustID match-finding (multiple ranked candidates via
lookup_all) and PUT /tracks/{id}/metadata for manual edits, resolving
artist/album and setting metadata_status=manual. Extends TrackOut with
genre/year/track_number.
2026-06-13 14:34:43 +03:00
267 changed files with 168760 additions and 864 deletions
+24
View File
@@ -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"
}
]
}
]
}
}
+10
View File
@@ -74,3 +74,13 @@ These exist for future sync + ML and are easy to violate by accident:
## 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.
## 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")
@@ -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 ###
+54 -1
View File
@@ -15,6 +15,9 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.auth_service import AuthService
from app.application.download_service import DownloadService
from app.application.metadata_service import MetadataEnrichmentService
from app.application.remote_library_service import RemoteLibraryService
from app.application.streaming_service import StreamingService
from app.application.subsonic_auth_service import SubsonicAuthService
from app.application.upload_service import UploadService
@@ -28,6 +31,7 @@ from app.infrastructure.db import get_sessionmaker
from app.infrastructure.db.repositories import (
SqlAlchemyAlbumRepository,
SqlAlchemyArtistRepository,
SqlAlchemyDownloadJobRepository,
SqlAlchemyHistoryRepository,
SqlAlchemyLikeRepository,
SqlAlchemyPlaylistRepository,
@@ -35,9 +39,12 @@ from app.infrastructure.db.repositories import (
SqlAlchemyTrackRepository,
SqlAlchemyUserRepository,
)
from app.infrastructure.metadata.acoustid import AcoustIdHttpClient
from app.infrastructure.metadata.fingerprint import FpcalcFingerprinter
from app.infrastructure.metadata.tags import MutagenTagReader
from app.infrastructure.sources.registry import SourceRegistry, build_source_registry
from app.infrastructure.storage.provider import get_file_storage
from app.workers.queue import enqueue_enrich
from app.workers.queue import enqueue_download, enqueue_enrich, enqueue_materialize
async def get_session() -> AsyncIterator[AsyncSession]:
@@ -132,8 +139,54 @@ def get_streaming_service(session: SessionDep, storage: FileStorageDep) -> Strea
)
def get_metadata_service(session: SessionDep, storage: FileStorageDep) -> MetadataEnrichmentService:
"""Wires the §6.2 fingerprint/AcoustID adapters for read-only, inline use
(the metadata editor's "find matches" — §A7). The full pipeline (incl.
cover art) stays in the worker (`tasks/enrich_task.py`)."""
settings = get_settings()
api_key = settings.acoustid_api_key.get_secret_value() if settings.acoustid_api_key else None
acoustid = AcoustIdHttpClient(
api_key=api_key,
user_agent=settings.musicbrainz_user_agent,
api_url=settings.acoustid_api_url,
)
return MetadataEnrichmentService(
tracks=SqlAlchemyTrackRepository(session),
artists=SqlAlchemyArtistRepository(session),
albums=SqlAlchemyAlbumRepository(session),
storage=storage,
tag_reader=MutagenTagReader(),
fingerprinter=FpcalcFingerprinter(settings.fpcalc_path),
acoustid=acoustid,
acoustid_trust_score=settings.acoustid_trust_score,
)
def get_download_service(session: SessionDep, storage: FileStorageDep) -> DownloadService:
return DownloadService(
jobs=SqlAlchemyDownloadJobRepository(session),
tracks=SqlAlchemyTrackRepository(session),
artists=SqlAlchemyArtistRepository(session),
storage=storage,
enqueue_download=enqueue_download,
enqueue_enrich=enqueue_enrich,
)
def get_remote_library_service(session: SessionDep) -> RemoteLibraryService:
return RemoteLibraryService(
tracks=SqlAlchemyTrackRepository(session),
artists=SqlAlchemyArtistRepository(session),
jobs=SqlAlchemyDownloadJobRepository(session),
enqueue_materialize=enqueue_materialize,
)
UploadServiceDep = Annotated[UploadService, Depends(get_upload_service)]
StreamingServiceDep = Annotated[StreamingService, Depends(get_streaming_service)]
MetadataServiceDep = Annotated[MetadataEnrichmentService, Depends(get_metadata_service)]
DownloadServiceDep = Annotated[DownloadService, Depends(get_download_service)]
RemoteLibraryServiceDep = Annotated[RemoteLibraryService, Depends(get_remote_library_service)]
# -- library repository deps ---------------------------------------------------
+1 -1
View File
@@ -65,7 +65,7 @@ async def download(
if track is None:
raise NotFoundError("Song not found.")
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 = {
"Content-Length": str(result.content_length),
"Content-Disposition": f'attachment; filename="{filename}"',
+2 -2
View File
@@ -80,8 +80,8 @@ def song_dict(
"albumId": encode_album(track.album_id) if track.album_id is not None else None,
"artistId": encode_artist(track.artist_id),
"coverArt": cover,
"size": track.file_size,
"contentType": content_type_for(track.file_format),
"size": track.file_size or 0,
"contentType": content_type_for(track.file_format or ""),
"suffix": track.file_format,
"duration": track.duration_seconds,
"year": track.year,
+59
View File
@@ -0,0 +1,59 @@
"""Schemas for the download job endpoints (§A5 download manager)."""
import datetime as dt
import uuid
from pydantic import BaseModel, Field
from app.domain.entities.download import DownloadJob
class DownloadCreate(BaseModel):
"""Request to download an item discovered on a fetch source."""
source: str
source_id: str = Field(min_length=1)
# Optional free-text the result came from — stored for display only.
query: str | None = None
class DownloadJobOut(BaseModel):
id: uuid.UUID
source: str
source_id: str | None
query: str | None
status: str
progress: float
error_message: str | None
retry_count: int
track_id: uuid.UUID | None
created_at: dt.datetime
updated_at: dt.datetime
@classmethod
def from_entity(cls, job: DownloadJob) -> DownloadJobOut:
return cls(
id=job.id,
source=job.source,
source_id=job.source_id,
query=job.query,
status=job.status,
progress=job.progress,
error_message=job.error_message,
retry_count=job.retry_count,
track_id=job.track_id,
created_at=job.created_at,
updated_at=job.updated_at,
)
class DownloadCreateResponse(BaseModel):
"""Result of requesting a download.
``already_in_library`` → the item was already imported (``track_id`` set, no
job). Otherwise ``job`` describes the queued (or already in-flight) download.
"""
already_in_library: bool
track_id: uuid.UUID | None
job: DownloadJobOut | None
+48
View File
@@ -0,0 +1,48 @@
"""Schemas for searching external (fetch) sources — the §A4 discover screen."""
import uuid
from pydantic import BaseModel
from app.domain.entities.track import Track
from app.domain.sources import SearchResult
class ExternalSearchResultOut(BaseModel):
source: str
source_id: str
title: str
artist: str | None
album: str | None
duration_seconds: int | None
thumbnail_url: str | None
# Remote browse (plan: Model C) — set when this hit is already saved in the
# library, so the UI can show "Play"/"Saved" instead of "Save to library".
in_library: bool
track_id: uuid.UUID | None
availability: str | None
@classmethod
def from_entity(
cls, r: SearchResult, *, existing: Track | None = None
) -> ExternalSearchResultOut:
return cls(
source=r.source,
source_id=r.source_id,
title=r.title,
artist=r.artist,
album=r.album,
duration_seconds=r.duration_seconds,
thumbnail_url=r.thumbnail_url,
in_library=existing is not None,
track_id=existing.id if existing is not None else None,
availability=existing.availability if existing is not None else None,
)
class ExternalSearchResponse(BaseModel):
"""Flat list of hits across one or more searchable sources, plus the names of
sources that were unavailable (so the UI can show a soft warning)."""
results: list[ExternalSearchResultOut]
searched_sources: list[str]
+4
View File
@@ -29,3 +29,7 @@ class PlaylistUpdate(BaseModel):
class PlaylistAddTrack(BaseModel):
track_id: uuid.UUID
position: float | None = None
class PlaylistReorder(BaseModel):
track_ids: list[uuid.UUID]
+45
View File
@@ -0,0 +1,45 @@
"""Storage / library statistics response schemas (§A6)."""
import datetime as dt
from pydantic import BaseModel
class DiskUsageOut(BaseModel):
total: int
used: int
free: int
class FormatBreakdownOut(BaseModel):
file_format: str
track_count: int
total_size: int
class GenreCountOut(BaseModel):
genre: str
track_count: int
class StorageStatsOut(BaseModel):
"""Everything the Storage screen needs in a single call."""
# library catalogue
total_tracks: int
total_artists: int
total_albums: int
total_size: int
total_duration_seconds: int
largest_track_size: int
earliest_added: dt.datetime | None
latest_added: dt.datetime | None
# breakdowns
by_format: list[FormatBreakdownOut]
by_metadata_status: dict[str, int]
by_source: dict[str, int]
top_genres: list[GenreCountOut]
# backing volume (``None`` for object-store backends)
disk: DiskUsageOut | None
+60 -3
View File
@@ -3,7 +3,9 @@
import datetime as dt
import uuid
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.api.schemas.download import DownloadJobOut
class TrackOut(BaseModel):
@@ -14,11 +16,15 @@ class TrackOut(BaseModel):
album_id: uuid.UUID | None
album_title: str | None
duration_seconds: int | None
file_format: str
file_size: int
file_format: str | None
file_size: int | None
genre: str | None
year: int | None
track_number: int | None
metadata_status: str
metadata_error: str | None
enriched_at: dt.datetime | None
availability: str
source: str
has_cover: bool
created_at: dt.datetime
@@ -28,3 +34,54 @@ class TrackUpdate(BaseModel):
title: str | None = None
genre: str | 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
+60 -18
View File
@@ -1,36 +1,78 @@
"""Download job endpoints. Heavy work is dispatched to arq workers."""
"""Download job endpoints (§A5). Heavy work is dispatched to arq workers — these
handlers only create/inspect/cancel/retry job records."""
import uuid
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.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("")
async def create_download() -> Any: ...
@router.post("", status_code=202)
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}")
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}")
async def cancel_download(job_id: uuid.UUID) -> Any: ...
@router.delete("/{job_id}", status_code=204)
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")
async def retry_download(job_id: uuid.UUID) -> Any: ...
@router.post("/pause")
async def pause_downloads() -> Any: ...
@router.post("/resume")
async def resume_downloads() -> Any: ...
async def retry_download(
job_id: uuid.UUID, service: DownloadServiceDep, _: CurrentUser
) -> DownloadJobOut:
job = await service.retry(job_id)
return DownloadJobOut.from_entity(job)
+34 -3
View File
@@ -13,11 +13,17 @@ from app.api.deps import (
TrackRepoDep,
)
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.v1.tracks import _build_track_out
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
router = APIRouter(prefix="/playlists", tags=["playlists"])
@@ -182,7 +188,32 @@ async def remove_playlist_track(
@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")
+27 -4
View File
@@ -1,12 +1,11 @@
"""Search endpoints: global and library-scoped."""
from typing import Any
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.artist import ArtistOut
from app.api.schemas.external_search import ExternalSearchResponse, ExternalSearchResultOut
from app.api.schemas.search import LibrarySearchResponse
from app.api.schemas.track import TrackOut
from app.api.v1.albums import _build_album_out
@@ -16,7 +15,31 @@ router = APIRouter(prefix="/search", tags=["search"])
@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")
+23 -10
View File
@@ -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
admin action and runs in a worker — the endpoint only enqueues it.
Listing/health/search are read-only (any authenticated user). Scanning a source
is an admin action and runs in a worker — the endpoint only enqueues it.
"""
from typing import Any
from fastapi import APIRouter, Query
from fastapi import APIRouter
from app.api.deps import CurrentUser, SourceRegistryDep, SuperUser
from app.api.deps import CurrentUser, SourceRegistryDep, SuperUser, TrackRepoDep
from app.api.schemas.external_search import ExternalSearchResponse, ExternalSearchResultOut
from app.api.schemas.source import ScanResponse, SourceHealthOut, SourceInfoOut
from app.domain.errors import DependencyUnavailableError
from app.workers.queue import enqueue
@@ -39,6 +38,20 @@ async def source_health(
@router.get("/{source}/search")
async def search_source(source: str, _: CurrentUser) -> Any:
# Search is for fetch-style sources (youtube, …) — not yet implemented.
...
async def search_source(
source: str,
_: CurrentUser,
registry: SourceRegistryDep,
track_repo: TrackRepoDep,
q: str = Query(min_length=1),
limit: int = Query(20, ge=1, le=50),
) -> ExternalSearchResponse:
backend = registry.searchable(source) # 404 if unknown, 422 if not searchable
if not backend.is_available():
raise DependencyUnavailableError(f"Source {source!r} is not available.")
results = await backend.search(q, limit=limit)
out: list[ExternalSearchResultOut] = []
for r in results:
existing = await track_repo.get_by_source(r.source, r.source_id)
out.append(ExternalSearchResultOut.from_entity(r, existing=existing))
return ExternalSearchResponse(results=out, searched_sources=[source])
+59 -1
View File
@@ -4,11 +4,69 @@ from typing import Any
from fastapi import APIRouter
from app.api.deps import (
AlbumRepoDep,
ArtistRepoDep,
CurrentUser,
FileStorageDep,
TrackRepoDep,
)
from app.api.schemas.storage import (
DiskUsageOut,
FormatBreakdownOut,
GenreCountOut,
StorageStatsOut,
)
router = APIRouter(prefix="/storage", tags=["storage"])
# How many of the most common genres the dashboard surfaces.
_TOP_GENRES = 8
@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")
+148 -4
View File
@@ -12,11 +12,22 @@ from app.api.deps import (
ArtistRepoDep,
CurrentUser,
FileStorageDep,
MetadataServiceDep,
RemoteLibraryServiceDep,
StreamUser,
TrackRepoDep,
)
from app.api.schemas.download import DownloadJobOut
from app.api.schemas.pagination import PagedResponse
from app.api.schemas.track import TrackOut, TrackUpdate
from app.api.schemas.track import (
MaterializeResponse,
MetadataApply,
MetadataMatch,
MetadataMatchesOut,
RemoteTrackSave,
TrackOut,
TrackUpdate,
)
from app.domain.entities.album import Album
from app.domain.entities.track import Artist, Track
from app.domain.errors import NotFoundError
@@ -41,9 +52,13 @@ async def _build_track_out(
duration_seconds=t.duration_seconds,
file_format=t.file_format,
file_size=t.file_size,
genre=t.genre,
year=t.year,
track_number=t.track_number,
metadata_status=t.metadata_status,
metadata_error=t.metadata_error,
enriched_at=t.enriched_at,
availability=t.availability,
source=t.source,
has_cover=bool(t.album_id and albums.get(t.album_id) and albums[t.album_id].cover_path),
created_at=t.created_at,
@@ -61,6 +76,7 @@ async def list_tracks(
artist_id: uuid.UUID | None = None,
album_id: uuid.UUID | 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)$"),
order: str = Query("desc", pattern="^(asc|desc)$"),
limit: int = Query(50, ge=1, le=200),
@@ -70,12 +86,13 @@ async def list_tracks(
artist_id=artist_id,
album_id=album_id,
q=q,
source=source,
sort_by=sort_by,
order=order,
limit=limit,
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})
album_ids = list({t.album_id for t in tracks if t.album_id is not None})
@@ -86,6 +103,57 @@ async def list_tracks(
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}")
async def get_track(
track_id: uuid.UUID,
@@ -143,6 +211,7 @@ async def delete_track(
if track is None:
raise NotFoundError(f"Track {track_id} not found.")
await track_repo.delete(track_id)
if track.storage_uri is not None:
await storage.delete(track.storage_uri)
return Response(status_code=204)
@@ -187,8 +256,83 @@ async def enrich_metadata(
@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")
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]
+183
View File
@@ -0,0 +1,183 @@
"""DownloadService — request external downloads and import their results.
Two roles (plan §6.1):
* **Request side** (HTTP): validate + dedup a download request, create a
``queued`` job, and enqueue the worker. Dedup is on ``(source, source_id)``
against both the library (already imported) and in-flight jobs (a double-click
must not queue twice) — idempotency per CLAUDE.md.
* **Worker side**: ``store_result`` turns a backend's :class:`DownloadResult`
into a managed file + minimal ``pending`` track (sibling of
:class:`~app.application.import_service.LibraryImportService`); enrichment
(§6.2) fills the rest.
The fingerprint-level dedup (a different id that turns out to be the same audio)
happens later in enrichment, where the fingerprint is computed.
"""
import contextlib
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import anyio
from app.core.logging import get_logger
from app.domain.entities.download import DownloadJob
from app.domain.errors import NotFoundError, ValidationError
from app.domain.ports import (
ArtistRepository,
DownloadJobRepository,
FileStorage,
TrackRepository,
)
from app.domain.sources import DownloadResult
log = get_logger(__name__)
_UNKNOWN_ARTIST = "Unknown Artist"
# (job_id) -> None — enqueue the download worker, deferred so the job row is
# committed before the worker reads it (same pattern as enrich).
DownloadEnqueuer = Callable[[uuid.UUID], Awaitable[None]]
EnrichEnqueuer = Callable[[uuid.UUID], Awaitable[None]]
@dataclass(frozen=True)
class DownloadRequest:
"""Outcome of asking for a download.
Exactly one of the three states holds: the item is already in the library
(``track_id`` set, ``already_in_library``), a job already covers it / was
just created (``job`` set), so the UI can route to the download manager.
"""
job: DownloadJob | None
track_id: uuid.UUID | None
already_in_library: bool
class DownloadService:
def __init__(
self,
*,
jobs: DownloadJobRepository,
tracks: TrackRepository,
artists: ArtistRepository,
storage: FileStorage,
enqueue_download: DownloadEnqueuer | None = None,
enqueue_enrich: EnrichEnqueuer | None = None,
) -> None:
self._jobs = jobs
self._tracks = tracks
self._artists = artists
self._storage = storage
self._enqueue_download = enqueue_download
self._enqueue_enrich = enqueue_enrich
# -- request side ---------------------------------------------------------
async def request(
self,
*,
source: str,
source_id: str,
query: str | None,
requested_by: uuid.UUID | None,
) -> DownloadRequest:
source_id = source_id.strip()
if not source_id:
raise ValidationError("A source_id is required to download.")
existing = await self._tracks.get_by_source(source, source_id)
if existing is not None:
return DownloadRequest(job=None, track_id=existing.id, already_in_library=True)
active = await self._jobs.get_active_for_source(source, source_id)
if active is not None:
return DownloadRequest(job=active, track_id=None, already_in_library=False)
job = await self._jobs.add(
source=source,
source_id=source_id,
query=query,
requested_by=requested_by,
)
if self._enqueue_download is not None:
await self._enqueue_download(job.id)
return DownloadRequest(job=job, track_id=None, already_in_library=False)
async def list(
self,
*,
requested_by: uuid.UUID | None,
status: str | None,
limit: int,
offset: int,
) -> tuple[list[DownloadJob], int]:
jobs = await self._jobs.list(
requested_by=requested_by, status=status, limit=limit, offset=offset
)
total = await self._jobs.count(requested_by=requested_by, status=status)
return jobs, total
async def get(self, job_id: uuid.UUID) -> DownloadJob:
job = await self._jobs.get_by_id(job_id)
if job is None:
raise NotFoundError(f"Download job {job_id} not found.")
return job
async def cancel(self, job_id: uuid.UUID) -> None:
"""Remove the job record. True mid-flight cancellation of an in-progress
yt-dlp download is out of scope (MVP); the worker tolerates a vanished
job row (its status writes become no-ops)."""
job = await self._jobs.get_by_id(job_id)
if job is None:
raise NotFoundError(f"Download job {job_id} not found.")
await self._jobs.delete(job_id)
async def retry(self, job_id: uuid.UUID) -> DownloadJob:
job = await self.get(job_id)
await self._jobs.set_status(job_id, status="queued", error_message=None)
if self._enqueue_download is not None:
await self._enqueue_download(job_id)
refreshed = await self._jobs.get_by_id(job_id)
return refreshed if refreshed is not None else job
# -- worker side ----------------------------------------------------------
async def store_result(
self,
*,
source: str,
result: DownloadResult,
requested_by: uuid.UUID | None,
) -> uuid.UUID:
"""Store a freshly downloaded file and create a minimal ``pending`` track.
Returns the new track id (the caller enqueues enrichment after commit).
The temp file produced by the backend is always removed."""
track_id = uuid.uuid4()
key = f"tracks/{str(track_id)[:2]}/{track_id}.{result.file_format}"
try:
await self._storage.save_file(key, result.path)
try:
artist = await self._artists.get_or_create(_UNKNOWN_ARTIST)
await self._tracks.add(
id=track_id,
title=result.suggested_title,
artist_id=artist.id,
storage_uri=key,
file_format=result.file_format,
file_size=result.file_size,
source=source,
source_id=result.source_id,
metadata_status="pending",
added_by=requested_by,
)
except Exception:
with contextlib.suppress(Exception):
await self._storage.delete(key)
raise
finally:
with contextlib.suppress(Exception):
await anyio.Path(result.path).unlink(missing_ok=True)
return track_id
+32 -3
View File
@@ -79,9 +79,13 @@ class MetadataEnrichmentService:
if track.metadata_status == "manual":
log.info("enrich_skip_manual", track_id=str(track_id))
return EnrichmentResult(track_id=track_id, status="skipped")
storage_uri = track.storage_uri
if storage_uri is None:
log.info("enrich_skip_remote", track_id=str(track_id))
return EnrichmentResult(track_id=track_id, status="skipped")
tags = await self._read_local(track.storage_uri)
match = await self._identify(track.storage_uri)
tags = await self._read_local(storage_uri)
match = await self._identify(storage_uri)
# Merge order is tag-first by default — embedded tags fix the common
# well-tagged offline case. But a *high-confidence* AcoustID match is the
@@ -125,7 +129,7 @@ class MetadataEnrichmentService:
if album is not None:
await self._resolve_cover(
album,
storage_uri=track.storage_uri,
storage_uri=storage_uri,
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 found in tags or AcoustID."
async def find_matches(self, track_id: uuid.UUID) -> list[RecordingMatch]:
"""AcoustID candidates for the metadata editor's match picker (§A7).
Read-only — unlike :meth:`enrich`, never touches the track. Runs
inline (single track, user-triggered) rather than via the worker.
Degrades to ``[]`` whenever fingerprinting/AcoustID is unavailable or
the file can't be read, same as the enrichment pipeline.
"""
track = await self._tracks.get_by_id(track_id)
if track is None:
return []
if not self._acoustid.is_available() or not self._fingerprinter.is_available():
return []
if track.storage_uri is None:
return []
try:
async with self._storage.as_local_path(track.storage_uri) as path:
fingerprint = await self._fingerprinter.calculate(path)
if fingerprint is None:
return []
return await self._acoustid.lookup_all(fingerprint)
except Exception:
log.warning("find_matches_failed", track_id=str(track_id))
return []
async def _read_local(self, storage_uri: str) -> AudioTags | None:
try:
async with self._storage.as_local_path(storage_uri) as path:
+122
View File
@@ -0,0 +1,122 @@
"""RemoteLibraryService — save-to-library + materialize for remote browse hits
(plan: Model C, on-demand YTM library).
Two operations:
* ``save_remote`` persists a placeholder ``Track`` (``availability="remote"``,
``storage_uri=None``) for a remote browse hit. Idempotent on
``(source, source_id)`` — CLAUDE.md dedup.
* ``request_materialize`` lazily fills a placeholder's audio in place: it
creates (or reuses) a ``DownloadJob`` pointing at the existing track and
enqueues the materialize worker, which calls ``TrackRepository.materialize``
on completion. ``track.id`` never changes (CLAUDE.md), so likes/playlists/
queue entries referencing the placeholder keep working once it's filled in.
"""
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from app.domain.entities.download import DownloadJob
from app.domain.entities.track import Track
from app.domain.errors import NotFoundError, ValidationError
from app.domain.ports import ArtistRepository, DownloadJobRepository, TrackRepository
_UNKNOWN_ARTIST = "Unknown Artist"
# (job_id) -> None — enqueue the materialize worker, same deferred pattern as
# download/enrich enqueuers.
MaterializeEnqueuer = Callable[[uuid.UUID], Awaitable[None]]
@dataclass(frozen=True)
class MaterializeOutcome:
"""Result of requesting materialization.
``job`` is ``None`` when the track is already ``local`` — nothing to do,
the caller can stream immediately. Otherwise it's the (new or already
in-flight) job filling the placeholder."""
track: Track
job: DownloadJob | None
class RemoteLibraryService:
def __init__(
self,
*,
tracks: TrackRepository,
artists: ArtistRepository,
jobs: DownloadJobRepository,
enqueue_materialize: MaterializeEnqueuer | None = None,
) -> None:
self._tracks = tracks
self._artists = artists
self._jobs = jobs
self._enqueue_materialize = enqueue_materialize
async def save_remote(
self,
*,
source: str,
source_id: str,
title: str,
artist: str | None,
added_by: uuid.UUID | None,
) -> Track:
"""Persist a placeholder for a remote browse hit. Idempotent: a hit
already saved (by ``(source, source_id)``) is returned as-is."""
source_id = source_id.strip()
if not source_id:
raise ValidationError("A source_id is required to save.")
existing = await self._tracks.get_by_source(source, source_id)
if existing is not None:
return existing
artist_entity = await self._artists.get_or_create(artist or _UNKNOWN_ARTIST)
return await self._tracks.add(
id=uuid.uuid4(),
title=title,
artist_id=artist_entity.id,
storage_uri=None,
file_format=None,
file_size=None,
source=source,
source_id=source_id,
metadata_status="pending",
added_by=added_by,
availability="remote",
)
async def request_materialize(
self, track_id: uuid.UUID, *, requested_by: uuid.UUID | None
) -> MaterializeOutcome:
"""Kick off (or report on) materializing a placeholder track.
Already-local tracks are a no-op (``job=None``). A track with no
remote ``source_id`` (e.g. a deleted upload row reused for something
else) can't be materialized."""
track = await self._tracks.get_by_id(track_id)
if track is None:
raise NotFoundError(f"Track {track_id} not found.")
if track.availability == "local":
return MaterializeOutcome(track=track, job=None)
if track.source_id is None:
raise ValidationError("Track has no remote source to materialize from.")
active = await self._jobs.get_active_for_source(track.source, track.source_id)
if active is not None:
return MaterializeOutcome(track=track, job=active)
job = await self._jobs.add(
source=track.source,
source_id=track.source_id,
query=None,
requested_by=requested_by,
)
await self._jobs.set_status(job.id, status="queued", track_id=track.id)
if self._enqueue_materialize is not None:
await self._enqueue_materialize(job.id)
refreshed = await self._jobs.get_by_id(job.id)
return MaterializeOutcome(track=track, job=refreshed if refreshed is not None else job)
+6 -3
View File
@@ -72,16 +72,19 @@ class StreamingService:
track = await self._tracks.get_by_id(track_id)
if track is None:
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
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)
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
content_length = actual_end - start + 1
+8
View File
@@ -71,6 +71,9 @@ class Settings(BaseSettings):
media_path: Path = Path("/data/media")
transcode_cache_path: Path = Path("/data/transcode-cache")
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"
upload_tmp_dir: Path | None = None
@@ -100,6 +103,11 @@ class Settings(BaseSettings):
# deployments should set their own contact email; see
# ``musicbrainz_user_agent`` below for how it's used.
musicbrainz_owner_email: str | None = None
# ``youtube`` fetch source (search + download via ytmusicapi/yt-dlp). Enabled
# by default; the source still reports unavailable if the libs aren't present.
youtube_enabled: bool = True
# Optional cookies file (Netscape format) for yt-dlp — lets it fetch
# age-restricted / region-locked items via an authenticated session.
youtube_cookies_path: Path | None = None
# -- enrichment -------------------------------------------------------
+11 -1
View File
@@ -2,11 +2,17 @@
from app.domain.entities.album import Album
from app.domain.entities.cover import CoverArt
from app.domain.entities.download import DownloadJob
from app.domain.entities.history import PlayHistoryEntry
from app.domain.entities.like import Like
from app.domain.entities.metadata import AudioTags, Fingerprint, RecordingMatch
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.user import Credentials, SubsonicCredentials, User
@@ -16,7 +22,11 @@ __all__ = [
"AudioTags",
"CoverArt",
"Credentials",
"DiskUsage",
"DownloadJob",
"Fingerprint",
"FormatBreakdown",
"LibraryStats",
"Like",
"ObjectStat",
"PlayHistoryEntry",
+2
View File
@@ -13,5 +13,7 @@ class Album:
year: int | None
cover_path: str | None
musicbrainz_id: str | None
source: str | None
source_id: str | None
created_at: dt.datetime
updated_at: dt.datetime
+26
View File
@@ -0,0 +1,26 @@
"""Download job domain entity (plan §6.1).
A queued fetch from an external source, tracked through its lifecycle so the UI
download manager (screen §A5) can show progress, errors, and retries. The
``status`` strings mirror :class:`~app.infrastructure.db.models.enums.DownloadStatus`.
"""
import datetime as dt
import uuid
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class DownloadJob:
id: uuid.UUID
source: str
source_id: str | None
query: str | None
requested_by: uuid.UUID | None
status: str
progress: float
error_message: str | None
retry_count: int
track_id: uuid.UUID | None
created_at: dt.datetime
updated_at: dt.datetime
+37
View File
@@ -1,5 +1,6 @@
"""Value objects for file storage."""
import datetime as dt
from dataclasses import dataclass
@@ -7,3 +8,39 @@ from dataclasses import dataclass
class ObjectStat:
size: int
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
+7 -3
View File
@@ -9,6 +9,8 @@ from dataclasses import dataclass
class Artist:
id: uuid.UUID
name: str
source: str | None
source_id: str | None
created_at: dt.datetime
updated_at: dt.datetime
@@ -19,16 +21,18 @@ class Track:
title: str
artist_id: uuid.UUID
album_id: uuid.UUID | None
storage_uri: str
file_format: str
file_size: int
storage_uri: str | None
file_format: str | None
file_size: int | None
source: str
source_id: str
duration_seconds: int | None
genre: str | None
year: int | None
track_number: int | None
metadata_status: str
metadata_error: str | None
enriched_at: dt.datetime | None
availability: str
created_at: dt.datetime
updated_at: dt.datetime
+135 -8
View File
@@ -7,7 +7,7 @@ are bound to these ports at the composition root (``app.api.deps``).
import datetime as dt
import uuid
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
from contextlib import AbstractAsyncContextManager
from pathlib import Path
from typing import Protocol
@@ -17,7 +17,10 @@ from app.domain.entities import (
AudioTags,
CoverArt,
Credentials,
DiskUsage,
DownloadJob,
Fingerprint,
LibraryStats,
Like,
ObjectStat,
PlayHistoryEntry,
@@ -27,9 +30,14 @@ from app.domain.entities import (
User,
)
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
# 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):
async def get_by_id(self, user_id: uuid.UUID) -> User | None: ...
@@ -98,10 +106,19 @@ class FileStorage(Protocol):
async def exists(self, key: str) -> bool: ...
async def delete(self, key: str) -> None: ...
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):
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_many(self, ids: list[uuid.UUID]) -> list[Artist]: ...
async def list(self, *, q: str | None, limit: int, offset: int) -> list[Artist]: ...
@@ -119,24 +136,41 @@ class TrackRepository(Protocol):
id: uuid.UUID,
title: str,
artist_id: uuid.UUID,
storage_uri: str,
file_format: str,
file_size: int,
storage_uri: str | None,
file_format: str | None,
file_size: int | None,
source: str,
source_id: str,
metadata_status: str,
added_by: uuid.UUID | None,
availability: str = ...,
) -> 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: ...
# genres must come before ``list`` — the method named ``list`` shadows the
# builtin in later annotations (same pattern as AlbumRepository below).
# genres / library_stats must come before ``list`` — the method named
# ``list`` shadows the builtin in later annotations (same pattern as
# AlbumRepository below).
async def genres(self) -> list[tuple[str, int]]: ...
async def library_stats(self) -> LibraryStats: ...
async def list(
self,
*,
artist_id: uuid.UUID | None,
album_id: uuid.UUID | None,
q: str | None,
source: str | None = None,
sort_by: str,
order: str,
limit: int,
@@ -148,6 +182,7 @@ class TrackRepository(Protocol):
artist_id: uuid.UUID | None,
album_id: uuid.UUID | None,
q: str | None,
source: str | None = None,
) -> int: ...
async def update(
self,
@@ -196,6 +231,20 @@ class AlbumRepository(Protocol):
year: int | None,
musicbrainz_id: str | None,
) -> Album: ...
async def get_or_create_remote(
self,
*,
title: str,
artist_id: uuid.UUID,
year: int | None,
musicbrainz_id: str | None,
source: str,
source_id: str,
) -> Album:
"""Resolve/create an album bound to a remote ``(source, source_id)``
(lazy materialization save-to-library)."""
...
async def set_cover_path(self, album_id: uuid.UUID, cover_path: str) -> None: ...
async def get_by_id(self, album_id: uuid.UUID) -> Album | None: ...
async def get_many(self, ids: list[uuid.UUID]) -> list[Album]: ...
@@ -229,11 +278,15 @@ class PlaylistRepository(Protocol):
self, playlist_id: uuid.UUID, *, limit: int, offset: int
) -> list[Track]: ...
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(
self, playlist_id: uuid.UUID, track_id: uuid.UUID, *, position: float
) -> 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 reorder_tracks(
self, playlist_id: uuid.UUID, ordered_track_ids: list[uuid.UUID]
) -> None: ...
# 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]: ...
@@ -265,6 +318,54 @@ class HistoryRepository(Protocol):
async def count(self, *, user_id: uuid.UUID) -> int: ...
class DownloadJobRepository(Protocol):
"""Persistence for download jobs (plan §6.1). Drives the §A5 download manager
and the worker's retry/backoff loop."""
async def add(
self,
*,
source: str,
source_id: str | None,
query: str | None,
requested_by: uuid.UUID | None,
) -> DownloadJob: ...
async def get_by_id(self, job_id: uuid.UUID) -> DownloadJob | None: ...
async def get_active_for_source(self, source: str, source_id: str) -> DownloadJob | None:
"""An unfinished (queued/downloading/enriching) job for the same item, if
any — used to dedup before enqueuing so a double-click can't queue twice."""
...
async def list(
self,
*,
requested_by: uuid.UUID | None,
status: str | None,
limit: int,
offset: int,
) -> list[DownloadJob]: ...
async def count(self, *, requested_by: uuid.UUID | None, status: str | None) -> int: ...
async def set_status(
self,
job_id: uuid.UUID,
*,
status: str,
error_message: str | None = None,
track_id: uuid.UUID | None = None,
) -> None: ...
async def set_progress(self, job_id: uuid.UUID, progress: float) -> None: ...
async def increment_retry(self, job_id: uuid.UUID) -> int:
"""Bump ``retry_count`` and return the new value."""
...
async def delete(self, job_id: uuid.UUID) -> None: ...
async def failure_rate(self, source: str, *, since: dt.datetime) -> float:
"""Fraction of jobs for ``source`` created since ``since`` that ended
``failed`` (0.0 when there are none) — drives the §A5 "source unhealthy"
banner."""
...
class SourceBackend(Protocol):
"""A registered source of tracks (mounted folder, YouTube, …).
@@ -283,6 +384,29 @@ class IndexableSource(SourceBackend, Protocol):
def scan(self) -> Iterator[SourceFile]: ...
class SearchableSource(SourceBackend, Protocol):
"""A source that can be searched by free text (e.g. YouTube Music).
Returns ``[]`` (never raises) on no results / the service being down — the
discover screen degrades to "nothing found" rather than erroring."""
async def search(self, query: str, *, limit: int) -> list[SearchResult]: ...
class FetchableSource(SourceBackend, Protocol):
"""A source that can download a previously-discovered item to local disk.
``fetch`` resolves a ``source_id`` (from a :class:`SearchResult`) into a file
and reports progress through ``on_progress``. It runs only in a worker (heavy
I/O) and raises on failure so the download task can retry with backoff."""
async def fetch(
self, source_id: str, *, on_progress: ProgressCallback | None = None
) -> DownloadResult: ...
async def get_metadata(self, source_id: str) -> RawMetadata | None: ...
# -- metadata enrichment (plan §6.2) -----------------------------------------
class AudioTagReader(Protocol):
"""Reads embedded tags from a local audio file. Returns ``None`` only when
@@ -302,10 +426,13 @@ class AudioFingerprinter(Protocol):
class AcoustIdClient(Protocol):
"""AcoustID lookup. ``is_available`` is False without an API key (the whole
fingerprint path is then skipped). ``lookup`` returns the best match or
``None`` (no result / service down), never raising."""
``None`` (no result / service down), never raising. ``lookup_all`` returns
the same candidates ranked by confidence (``[]`` on no result / unavailable
/ error), for the metadata editor's match picker."""
def is_available(self) -> bool: ...
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None: ...
async def lookup_all(self, fingerprint: Fingerprint) -> list[RecordingMatch]: ...
class CoverArtExtractor(Protocol):
+58 -2
View File
@@ -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
business logic)."""
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# A source's ``kind`` describes which ports it satisfies, so the UI/admin can
# tell an indexed folder from a searchable fetch-source. A backend may be both.
KIND_INDEXABLE = "indexable" # enumerates files already on disk (local folder)
KIND_FETCH = "fetch" # searches + downloads from an external service (YTM, …)
@dataclass(frozen=True, slots=True)
@@ -20,7 +26,7 @@ class SourceInfo:
name: str
label: str
kind: str # "indexable" (more kinds — search/download — arrive with youtube)
kind: str # KIND_INDEXABLE | KIND_FETCH
available: bool
@@ -37,3 +43,53 @@ class SourceFile:
suggested_title: str
file_format: str
file_size: int
@dataclass(frozen=True, slots=True)
class SearchResult:
"""One hit from a searchable source (plan §5), shown on the discover screen.
``source_id`` is the stable handle the same backend later resolves in
``fetch`` — it must round-trip a download request without re-searching.
``raw`` carries the backend's untouched payload for debugging / future use.
"""
source: str
source_id: str
title: str
artist: str | None
album: str | None
duration_seconds: int | None
thumbnail_url: str | None
raw: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RawMetadata:
"""Metadata a fetch-source can offer about an item *before* enrichment.
Best-effort and source-shaped — the canonical metadata still comes from the
enrichment pipeline (plan §6.2). Used to seed a more useful provisional
title than a bare id while a download is queued."""
title: str | None
artist: str | None
album: str | None
year: int | None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DownloadResult:
"""A file a fetch-source produced on local disk (plan §5).
``path`` is a temp file the caller owns: it is stored into managed storage
and then removed (same lifecycle as an upload). ``source_id`` is echoed back
because some backends only learn the canonical id during the download."""
source_id: str
path: Path
file_format: str
file_size: int
bitrate: int | None
suggested_title: str
+11 -1
View File
@@ -2,7 +2,7 @@
import uuid
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
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):
__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)
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)
cover_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
musicbrainz_id: Mapped[str | None] = mapped_column(String(36), index=True, nullable=True)
# -- remote identity (lazy materialization) --------------------------
source: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_id: Mapped[str | None] = mapped_column(String(512), nullable=True)
+11 -1
View File
@@ -1,6 +1,6 @@
"""ORM model for artists."""
from sqlalchemy import String
from sqlalchemy import String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
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):
__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)
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)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Set once the download finishes and the track is imported — lets the UI
# link a completed job to its library track.
track_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("tracks.id", ondelete="SET NULL"),
nullable=True,
)
+9
View File
@@ -64,3 +64,12 @@ class LyricsStatus(enum.StrEnum):
FOUND = "found"
NOT_FOUND = "not_found"
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"
+13 -4
View File
@@ -13,7 +13,7 @@ from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
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
@@ -41,11 +41,20 @@ class TrackModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
year: Mapped[int | None] = mapped_column(Integer, nullable=True)
# -- file (original, stored as-is) -----------------------------------
storage_uri: Mapped[str] = mapped_column(String(2048), nullable=False)
file_format: Mapped[str] = mapped_column(String(32), nullable=False)
file_size: Mapped[int] = mapped_column(Integer, nullable=False)
# NULL on a remote placeholder (not yet materialized) — see ``availability``.
storage_uri: Mapped[str | None] = mapped_column(String(2048), nullable=True)
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)
# ``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 --------------------------------------------
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)
@@ -2,6 +2,9 @@
from app.infrastructure.db.repositories.album_repository import SqlAlchemyAlbumRepository
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.like_repository import SqlAlchemyLikeRepository
from app.infrastructure.db.repositories.playlist_repository import SqlAlchemyPlaylistRepository
@@ -14,6 +17,7 @@ from app.infrastructure.db.repositories.user_repository import SqlAlchemyUserRep
__all__ = [
"SqlAlchemyAlbumRepository",
"SqlAlchemyArtistRepository",
"SqlAlchemyDownloadJobRepository",
"SqlAlchemyHistoryRepository",
"SqlAlchemyLikeRepository",
"SqlAlchemyPlaylistRepository",
@@ -18,6 +18,8 @@ def _to_entity(row: AlbumModel) -> Album:
year=row.year,
cover_path=row.cover_path,
musicbrainz_id=row.musicbrainz_id,
source=row.source,
source_id=row.source_id,
created_at=row.created_at,
updated_at=row.updated_at,
)
@@ -63,6 +65,58 @@ class SqlAlchemyAlbumRepository:
await self._session.refresh(row)
return _to_entity(row)
async def get_or_create_remote(
self,
*,
title: str,
artist_id: uuid.UUID,
year: int | None,
musicbrainz_id: str | None,
source: str,
source_id: str,
) -> Album:
"""Resolve an album by ``(source, source_id)`` first (re-browse/save
dedup), falling back to ``(title, artist_id)`` and gap-filling the
remote ids onto an existing row, else creating a new remote-bound row."""
row = (
await self._session.execute(
select(AlbumModel).where(
AlbumModel.source == source,
AlbumModel.source_id == source_id,
)
)
).scalar_one_or_none()
if row is None:
row = (
await self._session.execute(
select(AlbumModel).where(
AlbumModel.title == title,
AlbumModel.artist_id == artist_id,
)
)
).scalar_one_or_none()
if row is None:
row = AlbumModel(
title=title,
artist_id=artist_id,
year=year,
musicbrainz_id=musicbrainz_id,
source=source,
source_id=source_id,
)
self._session.add(row)
else:
if row.year is None and year is not None:
row.year = year
if row.musicbrainz_id is None and musicbrainz_id is not None:
row.musicbrainz_id = musicbrainz_id
if row.source is None and row.source_id is None:
row.source = source
row.source_id = source_id
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def set_cover_path(self, album_id: uuid.UUID, cover_path: str) -> None:
row = await self._session.get(AlbumModel, album_id)
if row is not None:
@@ -15,6 +15,8 @@ def _to_entity(row: ArtistModel) -> Artist:
return Artist(
id=row.id,
name=row.name,
source=row.source,
source_id=row.source_id,
created_at=row.created_at,
updated_at=row.updated_at,
)
@@ -35,6 +37,32 @@ class SqlAlchemyArtistRepository:
await self._session.refresh(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:
row = await self._session.get(ArtistModel, artist_id)
return _to_entity(row) if row is not None else None
@@ -0,0 +1,164 @@
"""Download job repository — adapter over ``AsyncSession`` (plan §6.1)."""
import datetime as dt
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.download import DownloadJob
from app.infrastructure.db.models.download_job import DownloadJobModel
from app.infrastructure.db.models.enums import DownloadStatus
# Jobs that are not yet finished — used to dedup an in-flight download.
_ACTIVE_STATUSES = (
DownloadStatus.QUEUED.value,
DownloadStatus.DOWNLOADING.value,
DownloadStatus.ENRICHING.value,
)
def _to_entity(row: DownloadJobModel) -> DownloadJob:
return DownloadJob(
id=row.id,
source=row.source,
source_id=row.source_id,
query=row.query,
requested_by=row.requested_by,
status=row.status,
progress=row.progress,
error_message=row.error_message,
retry_count=row.retry_count,
track_id=row.track_id,
created_at=row.created_at,
updated_at=row.updated_at,
)
class SqlAlchemyDownloadJobRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def add(
self,
*,
source: str,
source_id: str | None,
query: str | None,
requested_by: uuid.UUID | None,
) -> DownloadJob:
row = DownloadJobModel(
source=source,
source_id=source_id,
query=query,
requested_by=requested_by,
status=DownloadStatus.QUEUED.value,
progress=0.0,
retry_count=0,
)
self._session.add(row)
await self._session.flush()
await self._session.refresh(row)
return _to_entity(row)
async def get_by_id(self, job_id: uuid.UUID) -> DownloadJob | None:
row = await self._session.get(DownloadJobModel, job_id)
return _to_entity(row) if row is not None else None
async def get_active_for_source(self, source: str, source_id: str) -> DownloadJob | None:
row = (
await self._session.execute(
select(DownloadJobModel)
.where(
DownloadJobModel.source == source,
DownloadJobModel.source_id == source_id,
DownloadJobModel.status.in_(_ACTIVE_STATUSES),
)
.order_by(DownloadJobModel.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
return _to_entity(row) if row is not None else None
async def list(
self,
*,
requested_by: uuid.UUID | None,
status: str | None,
limit: int,
offset: int,
) -> list[DownloadJob]:
stmt = select(DownloadJobModel)
if requested_by is not None:
stmt = stmt.where(DownloadJobModel.requested_by == requested_by)
if status is not None:
stmt = stmt.where(DownloadJobModel.status == status)
stmt = stmt.order_by(DownloadJobModel.created_at.desc()).limit(limit).offset(offset)
rows = (await self._session.execute(stmt)).scalars().all()
return [_to_entity(r) for r in rows]
async def count(self, *, requested_by: uuid.UUID | None, status: str | None) -> int:
stmt = select(func.count()).select_from(DownloadJobModel)
if requested_by is not None:
stmt = stmt.where(DownloadJobModel.requested_by == requested_by)
if status is not None:
stmt = stmt.where(DownloadJobModel.status == status)
return (await self._session.execute(stmt)).scalar_one()
async def set_status(
self,
job_id: uuid.UUID,
*,
status: str,
error_message: str | None = None,
track_id: uuid.UUID | None = None,
) -> None:
row = await self._session.get(DownloadJobModel, job_id)
if row is None:
return
row.status = status
# ``error_message`` is always written: a successful transition clears a
# stale reason from an earlier failed attempt.
row.error_message = error_message
if track_id is not None:
row.track_id = track_id
if status == DownloadStatus.DONE.value:
row.progress = 1.0
await self._session.flush()
async def set_progress(self, job_id: uuid.UUID, progress: float) -> None:
row = await self._session.get(DownloadJobModel, job_id)
if row is None:
return
row.progress = max(0.0, min(1.0, progress))
await self._session.flush()
async def increment_retry(self, job_id: uuid.UUID) -> int:
row = await self._session.get(DownloadJobModel, job_id)
if row is None:
return 0
row.retry_count += 1
await self._session.flush()
return row.retry_count
async def delete(self, job_id: uuid.UUID) -> None:
row = await self._session.get(DownloadJobModel, job_id)
if row is not None:
await self._session.delete(row)
await self._session.flush()
async def failure_rate(self, source: str, *, since: dt.datetime) -> float:
total, failed = (
await self._session.execute(
select(
func.count(),
func.count().filter(DownloadJobModel.status == DownloadStatus.FAILED.value),
)
.select_from(DownloadJobModel)
.where(
DownloadJobModel.source == source,
DownloadJobModel.created_at >= since,
)
)
).one()
return (failed / total) if total else 0.0
@@ -38,9 +38,11 @@ def _track_to_entity(row: TrackModel) -> Track:
duration_seconds=row.duration_seconds,
genre=row.genre,
year=row.year,
track_number=row.track_number,
metadata_status=row.metadata_status,
metadata_error=row.metadata_error,
enriched_at=row.enriched_at,
availability=row.availability,
created_at=row.created_at,
updated_at=row.updated_at,
)
@@ -37,9 +37,11 @@ def _track_to_entity(row: TrackModel) -> Track:
duration_seconds=row.duration_seconds,
genre=row.genre,
year=row.year,
track_number=row.track_number,
metadata_status=row.metadata_status,
metadata_error=row.metadata_error,
enriched_at=row.enriched_at,
availability=row.availability,
created_at=row.created_at,
updated_at=row.updated_at,
)
@@ -136,9 +138,22 @@ class SqlAlchemyPlaylistRepository:
async def get_track_total(self, playlist_id: uuid.UUID) -> int:
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(
self, playlist_id: uuid.UUID, track_id: uuid.UUID, *, position: float
) -> None:
if await self.has_track(playlist_id, track_id):
return
row = PlaylistTrackModel(playlist_id=playlist_id, track_id=track_id, position=position)
self._session.add(row)
playlist = await self._session.get(PlaylistModel, playlist_id)
@@ -146,6 +161,26 @@ class SqlAlchemyPlaylistRepository:
playlist.version = playlist.version + 1
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:
row = (
await self._session.execute(
@@ -6,9 +6,11 @@ import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.storage import FormatBreakdown, LibraryStats
from app.domain.entities.track import Track
from app.domain.errors import NotFoundError
from app.infrastructure.db.models.artist import ArtistModel
from app.infrastructure.db.models.enums import TrackAvailability
from app.infrastructure.db.models.track import TrackModel
@@ -26,9 +28,11 @@ def _to_entity(row: TrackModel) -> Track:
duration_seconds=row.duration_seconds,
genre=row.genre,
year=row.year,
track_number=row.track_number,
metadata_status=row.metadata_status,
metadata_error=row.metadata_error,
enriched_at=row.enriched_at,
availability=row.availability,
created_at=row.created_at,
updated_at=row.updated_at,
)
@@ -59,13 +63,14 @@ class SqlAlchemyTrackRepository:
id: uuid.UUID,
title: str,
artist_id: uuid.UUID,
storage_uri: str,
file_format: str,
file_size: int,
storage_uri: str | None,
file_format: str | None,
file_size: int | None,
source: str,
source_id: str,
metadata_status: str,
added_by: uuid.UUID | None,
availability: str = TrackAvailability.LOCAL.value,
) -> Track:
row = TrackModel(
id=id,
@@ -78,12 +83,38 @@ class SqlAlchemyTrackRepository:
source_id=source_id,
metadata_status=metadata_status,
added_by=added_by,
availability=availability,
)
self._session.add(row)
await self._session.flush()
await self._session.refresh(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:
row = await self._session.get(TrackModel, track_id)
if row is not None:
@@ -105,12 +136,71 @@ class SqlAlchemyTrackRepository:
).all()
return [(row.genre, row.cnt) for row in rows]
async def library_stats(self) -> LibraryStats:
"""One-shot aggregate over the whole catalogue (no pagination). Defined
before ``list`` for the same shadowing reason as ``genres``."""
totals = (
await self._session.execute(
select(
func.count(TrackModel.id),
func.coalesce(func.sum(TrackModel.file_size), 0),
func.coalesce(func.sum(TrackModel.duration_seconds), 0),
func.coalesce(func.max(TrackModel.file_size), 0),
func.min(TrackModel.created_at),
func.max(TrackModel.created_at),
)
)
).one()
fmt_rows = (
await self._session.execute(
select(
TrackModel.file_format,
func.count(TrackModel.id),
func.coalesce(func.sum(TrackModel.file_size), 0),
)
.where(TrackModel.file_format.is_not(None))
.group_by(TrackModel.file_format)
.order_by(func.sum(TrackModel.file_size).desc())
)
).all()
status_rows = (
await self._session.execute(
select(TrackModel.metadata_status, func.count(TrackModel.id)).group_by(
TrackModel.metadata_status
)
)
).all()
source_rows = (
await self._session.execute(
select(TrackModel.source, func.count(TrackModel.id)).group_by(TrackModel.source)
)
).all()
return LibraryStats(
total_tracks=totals[0],
total_size=totals[1],
total_duration_seconds=totals[2],
largest_track_size=totals[3],
earliest_added=totals[4],
latest_added=totals[5],
by_format=[
FormatBreakdown(file_format=fmt, track_count=cnt, total_size=size)
for fmt, cnt, size in fmt_rows
],
by_metadata_status={status: cnt for status, cnt in status_rows},
by_source={source: cnt for source, cnt in source_rows},
)
async def list(
self,
*,
artist_id: uuid.UUID | None,
album_id: uuid.UUID | None,
q: str | None,
source: str | None = None,
sort_by: str = "created_at",
order: str = "desc",
limit: int = 50,
@@ -121,6 +211,8 @@ class SqlAlchemyTrackRepository:
stmt = stmt.where(TrackModel.artist_id == artist_id)
if album_id is not None:
stmt = stmt.where(TrackModel.album_id == album_id)
if source is not None:
stmt = stmt.where(TrackModel.source == source)
if q:
stmt = stmt.where(TrackModel.title.ilike(f"%{q}%"))
@@ -145,12 +237,15 @@ class SqlAlchemyTrackRepository:
artist_id: uuid.UUID | None,
album_id: uuid.UUID | None,
q: str | None,
source: str | None = None,
) -> int:
stmt = select(func.count()).select_from(TrackModel)
if artist_id is not None:
stmt = stmt.where(TrackModel.artist_id == artist_id)
if album_id is not None:
stmt = stmt.where(TrackModel.album_id == album_id)
if source is not None:
stmt = stmt.where(TrackModel.source == source)
if q:
stmt = stmt.where(TrackModel.title.ilike(f"%{q}%"))
return (await self._session.execute(stmt)).scalar_one()
@@ -162,6 +257,9 @@ class SqlAlchemyTrackRepository:
title: str | None,
genre: str | None,
year: int | None,
artist_id: uuid.UUID | None = None,
album_id: uuid.UUID | None = None,
track_number: int | None = None,
) -> Track:
row = await self._session.get(TrackModel, track_id)
if row is None:
@@ -172,6 +270,12 @@ class SqlAlchemyTrackRepository:
row.genre = genre
if year is not None:
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"
await self._session.flush()
await self._session.refresh(row)
+39 -12
View File
@@ -46,6 +46,18 @@ class AcoustIdHttpClient:
return bool(self._api_key)
async def lookup(self, fingerprint: Fingerprint) -> RecordingMatch | None:
payload = await self._lookup_raw(fingerprint)
if payload is None:
return None
return _parse_best_match(payload)
async def lookup_all(self, fingerprint: Fingerprint) -> list[RecordingMatch]:
payload = await self._lookup_raw(fingerprint)
if payload is None:
return []
return _parse_matches(payload)
async def _lookup_raw(self, fingerprint: Fingerprint) -> object | None:
if not self._api_key:
return None
try:
@@ -65,13 +77,11 @@ class AcoustIdHttpClient:
},
)
resp.raise_for_status()
payload = resp.json()
return resp.json() # type: ignore[no-any-return]
except httpx.HTTPError, ValueError:
log.warning("acoustid_lookup_failed")
return None
return _parse_best_match(payload)
@classmethod
async def _throttle(cls) -> None:
async with cls._throttle_lock:
@@ -82,22 +92,39 @@ class AcoustIdHttpClient:
cls._last_call_monotonic = time.monotonic()
_MAX_MATCHES = 5
def _parse_best_match(payload: object) -> RecordingMatch | None:
matches = _parse_matches(payload)
return matches[0] if matches else None
def _parse_matches(payload: object) -> list[RecordingMatch]:
if not isinstance(payload, dict) or payload.get("status") != "ok":
return None
return []
results = payload.get("results")
if not isinstance(results, list) or not results:
return None
return []
# Results are returned best-score-first; take the top scoring one.
best = max(results, key=lambda r: r.get("score", 0.0) if isinstance(r, dict) else 0.0)
if not isinstance(best, dict):
return None
# Results are returned best-score-first, but sort defensively and cap the
# number of candidates surfaced to the editor.
candidates = [r for r in results if isinstance(r, dict)]
candidates.sort(key=lambda r: r.get("score", 0.0), reverse=True)
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):
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
release_group_mbid: str | None = None
@@ -105,7 +132,7 @@ def _parse_best_match(payload: object) -> RecordingMatch | None:
artist: 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):
rec = recordings[0]
recording_mbid = rec.get("id") if isinstance(rec.get("id"), str) else None
+27 -2
View File
@@ -2,16 +2,18 @@
Built from settings at the composition root. Only sources that are configured
are registered (e.g. ``local`` appears only when ``LOCAL_MEDIA_IMPORT_PATH`` is
set), 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 app.core.config import Settings
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.infrastructure.sources.local_folder import LocalFolderSource
from app.infrastructure.sources.youtube import YouTubeMusicSource
class SourceRegistry:
@@ -30,6 +32,22 @@ class SourceRegistry:
raise ValidationError(f"Source {name!r} cannot be indexed.")
return cast(IndexableSource, backend)
def searchable(self, name: str) -> SearchableSource:
backend = self.get(name)
if not hasattr(backend, "search"):
raise ValidationError(f"Source {name!r} cannot be searched.")
return cast(SearchableSource, backend)
def fetchable(self, name: str) -> FetchableSource:
backend = self.get(name)
if not hasattr(backend, "fetch"):
raise ValidationError(f"Source {name!r} cannot download.")
return cast(FetchableSource, backend)
def searchables(self) -> list[SearchableSource]:
"""Every registered source that supports search (for cross-source search)."""
return [cast(SearchableSource, b) for b in self._by_name.values() if hasattr(b, "search")]
def infos(self) -> list[SourceInfo]:
return [backend.info() for backend in self._by_name.values()]
@@ -38,4 +56,11 @@ def build_source_registry(settings: Settings) -> SourceRegistry:
backends: list[SourceBackend] = []
if settings.local_media_import_path is not None:
backends.append(LocalFolderSource(settings.local_media_import_path))
if settings.youtube_enabled:
backends.append(
YouTubeMusicSource(
cookies_path=settings.youtube_cookies_path,
tmp_dir=settings.upload_tmp_dir,
)
)
return SourceRegistry(backends)
+207
View File
@@ -0,0 +1,207 @@
"""``youtube`` source — YouTube Music search + download (plan §5).
A *fetch* source: it searches YouTube Music (via ``ytmusicapi``, which returns
clean song/artist/album/duration rows) and downloads the chosen item with
``yt-dlp``. The two libraries are synchronous, so every call is bounced to a
worker thread (``anyio.to_thread``); the sync yt-dlp progress hook bridges back
to the async progress callback via ``anyio.from_thread``.
Both libraries are optional dependencies — if either is missing the source is
simply *unavailable* (it never crashes import or the registry; graceful
degradation per CLAUDE.md). The audio stream is stored **as-is** (YouTube serves
lossy Opus/AAC; re-encoding would be lossy→lossy, plan §6.6).
``source_id`` is the YouTube ``videoId`` — stable, so a re-download of the same
id is idempotent and dedups against an existing track.
"""
import functools
import tempfile
from collections.abc import Callable
from pathlib import Path
from typing import Any
import anyio
from app.core.logging import get_logger
from app.domain.ports import ProgressCallback
from app.domain.sources import (
KIND_FETCH,
DownloadResult,
RawMetadata,
SearchResult,
SourceInfo,
)
from app.infrastructure.db.models.enums import TrackSource
log = get_logger(__name__)
# Functions a caller may inject for testing (defaults do the real library work).
SearchFn = Callable[[str, int], list[dict[str, Any]]]
# (video_id, tmp_dir, progress_hook, cookies_path) -> normalized download dict
DownloadFn = Callable[[str, Path, Callable[[dict[str, Any]], None], Path | None], dict[str, Any]]
def _libs_available() -> bool:
try:
import yt_dlp # noqa: F401
import ytmusicapi # noqa: F401
except ImportError:
return False
return True
def _watch_url(video_id: str) -> str:
return f"https://music.youtube.com/watch?v={video_id}"
class YouTubeMusicSource:
"""Implements :class:`app.domain.ports.SearchableSource` and
:class:`~app.domain.ports.FetchableSource`."""
name = TrackSource.YOUTUBE.value
def __init__(
self,
*,
cookies_path: Path | None = None,
tmp_dir: Path | None = None,
search_fn: SearchFn | None = None,
download_fn: DownloadFn | None = None,
) -> None:
self._cookies_path = cookies_path
self._tmp_dir = tmp_dir
self._search_fn = search_fn or _default_search
self._download_fn = download_fn or _default_download
# Only the real library path needs the deps; an injected fn is self-contained.
self._injected = search_fn is not None or download_fn is not None
def info(self) -> SourceInfo:
return SourceInfo(
name=self.name,
label="YouTube Music",
kind=KIND_FETCH,
available=self.is_available(),
)
def is_available(self) -> bool:
return True if self._injected else _libs_available()
async def search(self, query: str, *, limit: int) -> list[SearchResult]:
query = query.strip()
if not query:
return []
try:
rows = await anyio.to_thread.run_sync(functools.partial(self._search_fn, query, limit))
except Exception:
# No results / service down → degrade to empty (plan §5, CLAUDE.md).
log.warning("ytm_search_failed", query=query)
return []
return [r for r in (self._to_result(row) for row in rows) if r is not None]
async def fetch(
self, source_id: str, *, on_progress: ProgressCallback | None = None
) -> DownloadResult:
tmp_dir = self._tmp_dir or Path(tempfile.gettempdir())
def hook(d: dict[str, Any]) -> None:
if on_progress is None or d.get("status") != "downloading":
return
total = d.get("total_bytes") or d.get("total_bytes_estimate")
done = d.get("downloaded_bytes")
if not total or done is None:
return
# Cap below 1.0 — the job only reaches 1.0 once stored + imported.
frac = min(done / total, 0.99)
# Bridge sync hook (worker thread) → async callback (event loop).
anyio.from_thread.run(on_progress, frac)
def _run() -> dict[str, Any]:
return self._download_fn(source_id, tmp_dir, hook, self._cookies_path)
info = await anyio.to_thread.run_sync(_run)
path = Path(info["filepath"])
stat = await anyio.Path(path).stat()
return DownloadResult(
source_id=source_id,
path=path,
file_format=info["file_format"],
file_size=stat.st_size,
bitrate=info.get("bitrate"),
suggested_title=info.get("title") or source_id,
)
async def get_metadata(self, source_id: str) -> RawMetadata | None:
# The search result already carries a usable title/artist, and the
# canonical metadata comes from enrichment (§6.2). A dedicated lookup is
# an optional refinement — skipped for now (returns None gracefully).
return None
def _to_result(self, row: dict[str, Any]) -> SearchResult | None:
video_id = row.get("videoId")
if not video_id:
return None # non-playable row (e.g. a video without audio id)
artists = row.get("artists") or []
artist = ", ".join(a["name"] for a in artists if a.get("name")) or None
album = (row.get("album") or {}).get("name") if isinstance(row.get("album"), dict) else None
thumbnails = row.get("thumbnails") or []
thumbnail = thumbnails[-1].get("url") if thumbnails else None
return SearchResult(
source=self.name,
source_id=str(video_id),
title=row.get("title") or "Unknown",
artist=artist,
album=album,
duration_seconds=row.get("duration_seconds"),
thumbnail_url=thumbnail,
raw=row,
)
def _default_search(query: str, limit: int) -> list[dict[str, Any]]:
"""Real ytmusicapi search (songs only). Runs in a worker thread."""
from ytmusicapi import YTMusic
yt = YTMusic() # unauthenticated: public search needs no login
results: list[dict[str, Any]] = yt.search(query, filter="songs", limit=limit)
return results[:limit]
def _default_download(
video_id: str,
tmp_dir: Path,
progress_hook: Callable[[dict[str, Any]], None],
cookies_path: Path | None,
) -> dict[str, Any]:
"""Real yt-dlp download of the best audio stream. Runs in a worker thread.
Stores the original stream (no transcode — plan §6.3/§6.6). Returns a
normalized dict the adapter maps to :class:`DownloadResult`.
"""
from yt_dlp import YoutubeDL
opts: dict[str, Any] = {
"format": "bestaudio/best",
"outtmpl": str(tmp_dir / "%(id)s.%(ext)s"),
"quiet": True,
"no_warnings": True,
"noprogress": True,
"progress_hooks": [progress_hook],
}
# Use cookies only when the file is actually present: the path can be set
# unconditionally (e.g. a mounted volume that may be empty) and downloads
# still work without it — cookies just unlock age/region-restricted items.
if cookies_path is not None and cookies_path.is_file():
opts["cookiefile"] = str(cookies_path)
with YoutubeDL(opts) as ydl:
info = ydl.extract_info(_watch_url(video_id), download=True)
filepath = Path(ydl.prepare_filename(info))
abr = info.get("abr")
return {
"filepath": filepath,
"file_format": filepath.suffix.lstrip(".").lower() or "m4a",
"bitrate": int(abr) if abr else None,
"title": info.get("title"),
}
+10 -1
View File
@@ -8,7 +8,7 @@ from pathlib import Path
import anyio
from app.domain.entities.storage import ObjectStat
from app.domain.entities.storage import DiskUsage, ObjectStat
from app.domain.errors import StorageError
_EXT_CONTENT_TYPE: dict[str, str] = {
@@ -78,6 +78,15 @@ class LocalFileStorage:
async def delete(self, key: str) -> None:
(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]:
return self._as_local_path_cm(key)
+4
View File
@@ -127,6 +127,10 @@ class S3FileStorage:
except ClientError as 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]:
return self._as_local_path_cm(key)
+8 -2
View File
@@ -1,7 +1,6 @@
"""arq worker settings — the queue runtime. Task functions register here.
Run with: ``arq app.workers.arq_worker.WorkerSettings``.
Tasks (download, transcode) are appended to ``functions`` in later steps.
"""
from typing import Any, ClassVar
@@ -10,8 +9,10 @@ from arq.connections import RedisSettings
from app.core.config import get_settings
from app.core.logging import configure_logging, get_logger
from app.workers.tasks.download_task import download_track
from app.workers.tasks.enrich_task import enrich_track
from app.workers.tasks.import_task import scan_local_folder
from app.workers.tasks.materialize_task import materialize_track
log = get_logger("worker")
@@ -27,7 +28,12 @@ async def shutdown(_ctx: dict[str, Any]) -> None:
class WorkerSettings:
functions: ClassVar[list[Any]] = [scan_local_folder, enrich_track]
functions: ClassVar[list[Any]] = [
scan_local_folder,
enrich_track,
download_track,
materialize_track,
]
on_startup = startup
on_shutdown = shutdown
max_jobs = get_settings().max_parallel_downloads
+25
View File
@@ -34,6 +34,31 @@ async def enqueue(function: str, **kwargs: Any) -> str:
return str(job.job_id)
async def enqueue_download(job_id: uuid.UUID) -> None:
"""Best-effort enqueue of a download job for the worker.
The job row is already persisted as ``queued``, so this is a follow-up, not a
barrier: if the queue is unreachable we log and move on (graceful
degradation) — the job stays ``queued`` and can be retried later. Deferred a
few seconds so the request's DB transaction commits before the worker reads
the row (same reason as :func:`enqueue_enrich`)."""
try:
await enqueue("download_track", job_id=str(job_id), _defer_by=3)
except DependencyUnavailableError:
log.warning("download_enqueue_failed", job_id=str(job_id))
async def enqueue_materialize(job_id: uuid.UUID) -> None:
"""Best-effort enqueue of a materialize job for the worker (plan: Model C
lazy materialization). Same deferred-commit reasoning as
:func:`enqueue_download` — the job row stays ``queued`` and can be retried
if the queue is unreachable."""
try:
await enqueue("materialize_track", job_id=str(job_id), _defer_by=3)
except DependencyUnavailableError:
log.warning("materialize_enqueue_failed", job_id=str(job_id))
async def enqueue_enrich(track_id: uuid.UUID) -> None:
"""Best-effort enqueue of metadata enrichment for a freshly stored track.
+151
View File
@@ -0,0 +1,151 @@
"""arq task: download one queued job through a fetch source (plan §6.1).
Flow: load job → ``downloading`` → ``backend.fetch`` (progress streamed to the
job row) → ``enriching`` → store file + minimal track → ``done`` → enqueue
enrichment. yt-dlp fails often, so a failed fetch retries with exponential
backoff (``download_max_retries``); only after the last try is the job marked
``failed`` with a reason for the §A5 download manager.
Heavy I/O belongs off the request cycle (CLAUDE.md); the HTTP endpoint only
enqueues. The job row tolerates being deleted mid-flight (cancellation) — status
writes against a missing row are no-ops.
"""
import uuid
from typing import Any
from arq import Retry
from app.application.download_service import DownloadService
from app.core.config import get_settings
from app.core.logging import correlation_id, get_logger
from app.domain.entities.download import DownloadJob
from app.domain.errors import NotFoundError, ValidationError
from app.domain.ports import FetchableSource, ProgressCallback
from app.domain.sources import DownloadResult
from app.infrastructure.db import session_scope
from app.infrastructure.db.repositories import (
SqlAlchemyArtistRepository,
SqlAlchemyDownloadJobRepository,
SqlAlchemyTrackRepository,
)
from app.infrastructure.sources.registry import build_source_registry
from app.infrastructure.storage.provider import get_file_storage
from app.workers.queue import enqueue_enrich
log = get_logger("worker.download")
# Exponential backoff between retries: 30s, 60s, 120s … capped.
_BACKOFF_BASE_SECONDS = 30
_BACKOFF_MAX_SECONDS = 600
# Only write progress when it advances by at least this much (avoid hammering
# the DB on every yt-dlp chunk).
_PROGRESS_STEP = 0.01
async def download_track(_ctx: dict[str, Any], *, job_id: str) -> dict[str, Any]:
correlation_id.set(f"dl:{job_id}")
jid = uuid.UUID(job_id)
settings = get_settings()
job = await _load_job(jid)
if job is None:
log.info("download_job_missing", job_id=job_id) # cancelled before pickup
return {"job_id": job_id, "status": "missing"}
registry = build_source_registry(settings)
try:
backend = registry.fetchable(job.source)
except (NotFoundError, ValidationError) as exc:
await _mark_failed(jid, f"Source unavailable: {exc}")
return {"job_id": job_id, "status": "failed"}
if job.source_id is None:
await _mark_failed(jid, "Job has no source_id to download.")
return {"job_id": job_id, "status": "failed"}
await _set_status(jid, "downloading")
try:
result = await _run_fetch(backend, job.source_id, jid)
except Exception as exc:
return await _handle_failure(jid, exc, settings.download_max_retries, job_id)
try:
track_id = await _import_result(jid, job, result)
except Exception as exc:
log.exception("download_import_failed", job_id=job_id)
await _mark_failed(jid, f"Import failed: {type(exc).__name__}: {exc}")
return {"job_id": job_id, "status": "failed"}
await enqueue_enrich(track_id)
log.info("download_complete", job_id=job_id, track_id=str(track_id))
return {"job_id": job_id, "status": "done", "track_id": str(track_id)}
async def _run_fetch(
backend: FetchableSource, source_id: str, jid: uuid.UUID
) -> DownloadResult:
"""Fetch the file, streaming progress into the job row. A single session is
held for the download so progress writes don't churn connections; each
throttled update is committed so API pollers see it."""
async with session_scope() as session:
repo = SqlAlchemyDownloadJobRepository(session)
last = 0.0
async def on_progress(frac: float) -> None:
nonlocal last
if frac - last < _PROGRESS_STEP:
return
last = frac
await repo.set_progress(jid, frac)
await session.commit()
cb: ProgressCallback = on_progress
return await backend.fetch(source_id, on_progress=cb)
async def _import_result(jid: uuid.UUID, job: DownloadJob, result: DownloadResult) -> uuid.UUID:
async with session_scope() as session:
repo = SqlAlchemyDownloadJobRepository(session)
await repo.set_status(jid, status="enriching")
service = DownloadService(
jobs=repo,
tracks=SqlAlchemyTrackRepository(session),
artists=SqlAlchemyArtistRepository(session),
storage=get_file_storage(),
)
track_id = await service.store_result(
source=job.source, result=result, requested_by=job.requested_by
)
await repo.set_status(jid, status="done", track_id=track_id)
return track_id
async def _handle_failure(
jid: uuid.UUID, exc: Exception, max_retries: int, job_id: str
) -> dict[str, Any]:
async with session_scope() as session:
tries = await SqlAlchemyDownloadJobRepository(session).increment_retry(jid)
if tries <= max_retries:
backoff = min(_BACKOFF_BASE_SECONDS * 2 ** (tries - 1), _BACKOFF_MAX_SECONDS)
log.warning("download_retry", job_id=job_id, attempt=tries, defer=backoff)
raise Retry(defer=backoff) from exc
log.exception("download_failed", job_id=job_id)
await _mark_failed(jid, f"Download failed after {tries} attempts: {type(exc).__name__}: {exc}")
return {"job_id": job_id, "status": "failed"}
async def _load_job(jid: uuid.UUID) -> DownloadJob | None:
async with session_scope() as session:
return await SqlAlchemyDownloadJobRepository(session).get_by_id(jid)
async def _set_status(jid: uuid.UUID, status: str) -> None:
async with session_scope() as session:
await SqlAlchemyDownloadJobRepository(session).set_status(jid, status=status)
async def _mark_failed(jid: uuid.UUID, error: str) -> None:
async with session_scope() as session:
await SqlAlchemyDownloadJobRepository(session).set_status(
jid, status="failed", error_message=error
)
+101
View File
@@ -0,0 +1,101 @@
"""arq task: materialize a remote placeholder track (plan: Model C).
Counterpart to ``download_task`` for tracks that were *saved* from a remote
browse hit without audio (``availability="remote"``, ``storage_uri=NULL``).
The job's ``track_id`` already points at the existing placeholder row — on
success the file is stored and ``TrackRepository.materialize`` fills the row
in place (the track's ``id`` never changes), then enrichment is enqueued as
usual.
Shares its fetch/retry/failure machinery with ``download_task`` — only the
"what happens on success" step differs (fill in an existing row vs. create a
new one).
"""
import contextlib
import uuid
from typing import Any
import anyio
from app.core.config import get_settings
from app.core.logging import correlation_id, get_logger
from app.domain.errors import NotFoundError, ValidationError
from app.domain.sources import DownloadResult
from app.infrastructure.db import session_scope
from app.infrastructure.db.repositories import (
SqlAlchemyDownloadJobRepository,
SqlAlchemyTrackRepository,
)
from app.infrastructure.sources.registry import build_source_registry
from app.infrastructure.storage.provider import get_file_storage
from app.workers.queue import enqueue_enrich
from app.workers.tasks.download_task import _handle_failure, _load_job, _mark_failed, _run_fetch
log = get_logger("worker.materialize")
async def materialize_track(_ctx: dict[str, Any], *, job_id: str) -> dict[str, Any]:
correlation_id.set(f"mat:{job_id}")
jid = uuid.UUID(job_id)
settings = get_settings()
job = await _load_job(jid)
if job is None:
log.info("materialize_job_missing", job_id=job_id) # cancelled before pickup
return {"job_id": job_id, "status": "missing"}
if job.track_id is None or job.source_id is None:
await _mark_failed(jid, "Materialize job missing track_id/source_id.")
return {"job_id": job_id, "status": "failed"}
registry = build_source_registry(settings)
try:
backend = registry.fetchable(job.source)
except (NotFoundError, ValidationError) as exc:
await _mark_failed(jid, f"Source unavailable: {exc}")
return {"job_id": job_id, "status": "failed"}
await _set_status(jid, "downloading")
try:
result = await _run_fetch(backend, job.source_id, jid)
except Exception as exc:
return await _handle_failure(jid, exc, settings.download_max_retries, job_id)
try:
await _materialize_result(jid, job.track_id, result)
except Exception as exc:
log.exception("materialize_finalize_failed", job_id=job_id)
await _mark_failed(jid, f"Materialize failed: {type(exc).__name__}: {exc}")
return {"job_id": job_id, "status": "failed"}
await enqueue_enrich(job.track_id)
log.info("materialize_complete", job_id=job_id, track_id=str(job.track_id))
return {"job_id": job_id, "status": "done", "track_id": str(job.track_id)}
async def _materialize_result(jid: uuid.UUID, track_id: uuid.UUID, result: DownloadResult) -> None:
"""Store the downloaded file and fill in the placeholder track in place."""
key = f"tracks/{str(track_id)[:2]}/{track_id}.{result.file_format}"
storage = get_file_storage()
try:
await storage.save_file(key, result.path)
async with session_scope() as session:
job_repo = SqlAlchemyDownloadJobRepository(session)
await job_repo.set_status(jid, status="enriching")
tracks = SqlAlchemyTrackRepository(session)
await tracks.materialize(
track_id,
storage_uri=key,
file_format=result.file_format,
file_size=result.file_size,
bitrate=result.bitrate,
)
await job_repo.set_status(jid, status="done", track_id=track_id)
finally:
with contextlib.suppress(Exception):
await anyio.Path(result.path).unlink(missing_ok=True)
async def _set_status(jid: uuid.UUID, status: str) -> None:
async with session_scope() as session:
await SqlAlchemyDownloadJobRepository(session).set_status(jid, status=status)
+135
View File
@@ -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"
}
+1
View File
@@ -0,0 +1 @@
/Users/senko/.local/share/uv/tools/graphifyy/bin/python
+1
View File
@@ -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"}
+505
View File
@@ -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._
+12
View File
@@ -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
+902
View File
@@ -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"
}
}
+523
View File
@@ -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._
@@ -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": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_py", "label": "metadata.py", "file_type": "code", "source_file": "app/domain/entities/metadata.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_audiotags", "label": "AudioTags", "file_type": "code", "source_file": "app/domain/entities/metadata.py", "source_location": "L11", "_callable": true}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_fingerprint", "label": "Fingerprint", "file_type": "code", "source_file": "app/domain/entities/metadata.py", "source_location": "L30", "_callable": true}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_recordingmatch", "label": "RecordingMatch", "file_type": "code", "source_file": "app/domain/entities/metadata.py", "source_location": "L38", "_callable": true}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_rationale_1", "label": "Value objects for the metadata-enrichment pipeline (plan \u00a76.2). Pure data carri", "file_type": "rationale", "source_file": "app/domain/entities/metadata.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_rationale_12", "label": "Embedded tags read from the file itself (ID3 / Vorbis / MP4 \u2026). Every field", "file_type": "rationale", "source_file": "app/domain/entities/metadata.py", "source_location": "L12"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_rationale_31", "label": "Chromaprint fingerprint plus the decoded duration (both needed by AcoustID).", "file_type": "rationale", "source_file": "app/domain/entities/metadata.py", "source_location": "L31"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_rationale_39", "label": "A single AcoustID result, flattened to the fields enrichment cares about. `", "file_type": "rationale", "source_file": "app/domain/entities/metadata.py", "source_location": "L39"}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_py", "target": "dataclasses", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/domain/entities/metadata.py", "source_location": "L7", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_audiotags", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/domain/entities/metadata.py", "source_location": "L11", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_fingerprint", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/domain/entities/metadata.py", "source_location": "L30", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_recordingmatch", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/domain/entities/metadata.py", "source_location": "L38", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_rationale_1", "target": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/domain/entities/metadata.py", "source_location": "L1", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_rationale_12", "target": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_audiotags", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/domain/entities/metadata.py", "source_location": "L12", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_rationale_31", "target": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_fingerprint", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/domain/entities/metadata.py", "source_location": "L31", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_rationale_39", "target": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_metadata_recordingmatch", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/domain/entities/metadata.py", "source_location": "L39", "weight": 1.0}], "raw_calls": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_py", "label": "artist.py", "file_type": "code", "source_file": "app/infrastructure/db/models/artist.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_artistmodel", "label": "ArtistModel", "file_type": "code", "source_file": "app/infrastructure/db/models/artist.py", "source_location": "L10", "_callable": true}, {"id": "uuidprimarykeymixin", "label": "UUIDPrimaryKeyMixin", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "/Users/senko/Work/mcma-compose/mcma-backend/app/infrastructure/db/models/artist.py"}, {"id": "timestampmixin", "label": "TimestampMixin", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "/Users/senko/Work/mcma-compose/mcma-backend/app/infrastructure/db/models/artist.py"}, {"id": "base", "label": "Base", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "/Users/senko/Work/mcma-compose/mcma-backend/app/infrastructure/db/models/artist.py"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_rationale_1", "label": "ORM model for artists.", "file_type": "rationale", "source_file": "app/infrastructure/db/models/artist.py", "source_location": "L1"}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_py", "target": "sqlalchemy", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/artist.py", "source_location": "L3", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_py", "target": "sqlalchemy_orm", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/artist.py", "source_location": "L4", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_py", "target": "app_infrastructure_db_base", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/artist.py", "source_location": "L6", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_py", "target": "app_infrastructure_db_models_mixins", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/artist.py", "source_location": "L7", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_artistmodel", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/artist.py", "source_location": "L10", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_artistmodel", "target": "uuidprimarykeymixin", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/artist.py", "source_location": "L10", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_artistmodel", "target": "timestampmixin", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/artist.py", "source_location": "L10", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_artistmodel", "target": "base", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/artist.py", "source_location": "L10", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_rationale_1", "target": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_artist_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/artist.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_py", "label": "hashing.py", "file_type": "code", "source_file": "app/core/hashing.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_sha256_of_file", "label": "sha256_of_file()", "file_type": "code", "source_file": "app/core/hashing.py", "source_location": "L7", "_callable": true}, {"id": "path", "label": "Path", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "/Users/senko/Work/mcma-compose/mcma-backend/app/core/hashing.py"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_rationale_1", "label": "File hashing utilities.", "file_type": "rationale", "source_file": "app/core/hashing.py", "source_location": "L1"}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_py", "target": "hashlib", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "app/core/hashing.py", "source_location": "L3", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_py", "target": "pathlib", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/core/hashing.py", "source_location": "L4", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_sha256_of_file", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/core/hashing.py", "source_location": "L7", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_sha256_of_file", "target": "path", "relation": "references", "context": "parameter_type", "confidence": "EXTRACTED", "source_file": "app/core/hashing.py", "source_location": "L7", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_rationale_1", "target": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/core/hashing.py", "source_location": "L1", "weight": 1.0}], "raw_calls": [{"caller_nid": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_sha256_of_file", "callee": "sha256", "is_member_call": true, "source_file": "app/core/hashing.py", "source_location": "L8", "receiver": "hashlib"}, {"caller_nid": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_sha256_of_file", "callee": "read", "is_member_call": true, "source_file": "app/core/hashing.py", "source_location": "L10", "receiver": "f"}, {"caller_nid": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_sha256_of_file", "callee": "update", "is_member_call": true, "source_file": "app/core/hashing.py", "source_location": "L11", "receiver": "h"}, {"caller_nid": "users_senko_work_mcma_compose_mcma_backend_app_core_hashing_sha256_of_file", "callee": "hexdigest", "is_member_call": true, "source_file": "app/core/hashing.py", "source_location": "L12", "receiver": "h"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_cache_init_py", "label": "__init__.py", "file_type": "code", "source_file": "app/infrastructure/cache/__init__.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_cache_init_rationale_1", "label": "Redis adapter: shared connection pool for cache and arq broker.", "file_type": "rationale", "source_file": "app/infrastructure/cache/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_cache_init_py", "target": "app_infrastructure_cache_redis", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/infrastructure/cache/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_cache_init_rationale_1", "target": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_cache_init_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/infrastructure/cache/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_py", "label": "like.py", "file_type": "code", "source_file": "app/infrastructure/db/models/like.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_likemodel", "label": "LikeModel", "file_type": "code", "source_file": "app/infrastructure/db/models/like.py", "source_location": "L19", "_callable": true}, {"id": "uuidprimarykeymixin", "label": "UUIDPrimaryKeyMixin", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "/Users/senko/Work/mcma-compose/mcma-backend/app/infrastructure/db/models/like.py"}, {"id": "base", "label": "Base", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "/Users/senko/Work/mcma-compose/mcma-backend/app/infrastructure/db/models/like.py"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_rationale_1", "label": "ORM model for likes \u2014 an append-only event log. A like/dislike is **never** upd", "file_type": "rationale", "source_file": "app/infrastructure/db/models/like.py", "source_location": "L1"}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_py", "target": "datetime", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/like.py", "source_location": "L9", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_py", "target": "uuid", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/like.py", "source_location": "L10", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_py", "target": "sqlalchemy", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/like.py", "source_location": "L12", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_py", "target": "sqlalchemy_orm", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/like.py", "source_location": "L13", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_py", "target": "app_infrastructure_db_base", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/like.py", "source_location": "L15", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_py", "target": "app_infrastructure_db_models_mixins", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/like.py", "source_location": "L16", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_likemodel", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/like.py", "source_location": "L19", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_likemodel", "target": "uuidprimarykeymixin", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/like.py", "source_location": "L19", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_likemodel", "target": "base", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/like.py", "source_location": "L19", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_rationale_1", "target": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_db_models_like_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/infrastructure/db/models/like.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_playlist_py", "label": "playlist.py", "file_type": "code", "source_file": "app/domain/entities/playlist.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_playlist_playlist", "label": "Playlist", "file_type": "code", "source_file": "app/domain/entities/playlist.py", "source_location": "L9", "_callable": true}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_playlist_rationale_1", "label": "Playlist domain entity.", "file_type": "rationale", "source_file": "app/domain/entities/playlist.py", "source_location": "L1"}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_playlist_py", "target": "datetime", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "app/domain/entities/playlist.py", "source_location": "L3", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_playlist_py", "target": "uuid", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "app/domain/entities/playlist.py", "source_location": "L4", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_playlist_py", "target": "dataclasses", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/domain/entities/playlist.py", "source_location": "L5", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_playlist_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_playlist_playlist", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/domain/entities/playlist.py", "source_location": "L9", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_playlist_rationale_1", "target": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_playlist_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/domain/entities/playlist.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1200_subsonic_app_password_py", "label": "20260608_1200-subsonic_app_password.py", "file_type": "code", "source_file": "alembic/versions/20260608_1200-subsonic_app_password.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1200_subsonic_app_password_upgrade", "label": "upgrade()", "file_type": "code", "source_file": "alembic/versions/20260608_1200-subsonic_app_password.py", "source_location": "L24", "_callable": true}, {"id": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1200_subsonic_app_password_downgrade", "label": "downgrade()", "file_type": "code", "source_file": "alembic/versions/20260608_1200-subsonic_app_password.py", "source_location": "L31", "_callable": true}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1200_subsonic_app_password_py", "target": "collections_abc", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260608_1200-subsonic_app_password.py", "source_location": "L13", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1200_subsonic_app_password_py", "target": "sqlalchemy", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260608_1200-subsonic_app_password.py", "source_location": "L15", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1200_subsonic_app_password_py", "target": "alembic", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260608_1200-subsonic_app_password.py", "source_location": "L16", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1200_subsonic_app_password_py", "target": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1200_subsonic_app_password_upgrade", "relation": "contains", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260608_1200-subsonic_app_password.py", "source_location": "L24", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1200_subsonic_app_password_py", "target": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1200_subsonic_app_password_downgrade", "relation": "contains", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260608_1200-subsonic_app_password.py", "source_location": "L31", "weight": 1.0}], "raw_calls": [{"caller_nid": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1200_subsonic_app_password_upgrade", "callee": "add_column", "is_member_call": true, "source_file": "alembic/versions/20260608_1200-subsonic_app_password.py", "source_location": "L25", "receiver": "op"}, {"caller_nid": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1200_subsonic_app_password_upgrade", "callee": "Column", "is_member_call": true, "source_file": "alembic/versions/20260608_1200-subsonic_app_password.py", "source_location": "L27", "receiver": "sa"}, {"caller_nid": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1200_subsonic_app_password_downgrade", "callee": "drop_column", "is_member_call": true, "source_file": "alembic/versions/20260608_1200-subsonic_app_password.py", "source_location": "L32", "receiver": "op"}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_upload_py", "label": "upload.py", "file_type": "code", "source_file": "app/api/schemas/upload.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_upload_uploadresponse", "label": "UploadResponse", "file_type": "code", "source_file": "app/api/schemas/upload.py", "source_location": "L8", "_callable": true}, {"id": "basemodel", "label": "BaseModel", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "/Users/senko/Work/mcma-compose/mcma-backend/app/api/schemas/upload.py"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_upload_rationale_1", "label": "Schemas for upload responses.", "file_type": "rationale", "source_file": "app/api/schemas/upload.py", "source_location": "L1"}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_upload_py", "target": "uuid", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "app/api/schemas/upload.py", "source_location": "L3", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_upload_py", "target": "pydantic", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/api/schemas/upload.py", "source_location": "L5", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_upload_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_upload_uploadresponse", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/api/schemas/upload.py", "source_location": "L8", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_upload_uploadresponse", "target": "basemodel", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "app/api/schemas/upload.py", "source_location": "L8", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_upload_rationale_1", "target": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_upload_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/api/schemas/upload.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_py", "label": "20260614_1000-download_job_track_id.py", "file_type": "code", "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_upgrade", "label": "upgrade()", "file_type": "code", "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L26", "_callable": true}, {"id": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_downgrade", "label": "downgrade()", "file_type": "code", "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L41", "_callable": true}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_py", "target": "collections_abc", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L15", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_py", "target": "sqlalchemy", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L17", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_py", "target": "alembic", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L18", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_py", "target": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_upgrade", "relation": "contains", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L26", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_py", "target": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_downgrade", "relation": "contains", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L41", "weight": 1.0}], "raw_calls": [{"caller_nid": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_upgrade", "callee": "add_column", "is_member_call": true, "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L27", "receiver": "op"}, {"caller_nid": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_upgrade", "callee": "Column", "is_member_call": true, "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L29", "receiver": "sa"}, {"caller_nid": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_upgrade", "callee": "Uuid", "is_member_call": true, "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L29", "receiver": "sa"}, {"caller_nid": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_upgrade", "callee": "create_foreign_key", "is_member_call": true, "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L31", "receiver": "op"}, {"caller_nid": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_upgrade", "callee": "f", "is_member_call": true, "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L32", "receiver": "op"}, {"caller_nid": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_downgrade", "callee": "drop_constraint", "is_member_call": true, "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L42", "receiver": "op"}, {"caller_nid": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_downgrade", "callee": "f", "is_member_call": true, "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L43", "receiver": "op"}, {"caller_nid": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260614_1000_download_job_track_id_downgrade", "callee": "drop_column", "is_member_call": true, "source_file": "alembic/versions/20260614_1000-download_job_track_id.py", "source_location": "L47", "receiver": "op"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_py", "label": "history.py", "file_type": "code", "source_file": "app/api/schemas/history.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_historyin", "label": "HistoryIn", "file_type": "code", "source_file": "app/api/schemas/history.py", "source_location": "L9", "_callable": true}, {"id": "basemodel", "label": "BaseModel", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "/Users/senko/Work/mcma-compose/mcma-backend/app/api/schemas/history.py"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_historyout", "label": "HistoryOut", "file_type": "code", "source_file": "app/api/schemas/history.py", "source_location": "L16", "_callable": true}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_rationale_1", "label": "Play history request/response schemas.", "file_type": "rationale", "source_file": "app/api/schemas/history.py", "source_location": "L1"}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_py", "target": "datetime", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "app/api/schemas/history.py", "source_location": "L3", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_py", "target": "uuid", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "app/api/schemas/history.py", "source_location": "L4", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_py", "target": "pydantic", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/api/schemas/history.py", "source_location": "L6", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_historyin", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/api/schemas/history.py", "source_location": "L9", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_historyin", "target": "basemodel", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "app/api/schemas/history.py", "source_location": "L9", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_historyout", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/api/schemas/history.py", "source_location": "L16", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_historyout", "target": "basemodel", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "app/api/schemas/history.py", "source_location": "L16", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_rationale_1", "target": "users_senko_work_mcma_compose_mcma_backend_app_api_schemas_history_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/api/schemas/history.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1132_rename_track_file_path_to_storage_uri_py", "label": "20260608_1132-rename_track_file_path_to_storage_uri.py", "file_type": "code", "source_file": "alembic/versions/20260608_1132-rename_track_file_path_to_storage_uri.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1132_rename_track_file_path_to_storage_uri_upgrade", "label": "upgrade()", "file_type": "code", "source_file": "alembic/versions/20260608_1132-rename_track_file_path_to_storage_uri.py", "source_location": "L20", "_callable": true}, {"id": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1132_rename_track_file_path_to_storage_uri_downgrade", "label": "downgrade()", "file_type": "code", "source_file": "alembic/versions/20260608_1132-rename_track_file_path_to_storage_uri.py", "source_location": "L24", "_callable": true}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1132_rename_track_file_path_to_storage_uri_py", "target": "collections_abc", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260608_1132-rename_track_file_path_to_storage_uri.py", "source_location": "L10", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1132_rename_track_file_path_to_storage_uri_py", "target": "alembic", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260608_1132-rename_track_file_path_to_storage_uri.py", "source_location": "L12", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1132_rename_track_file_path_to_storage_uri_py", "target": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1132_rename_track_file_path_to_storage_uri_upgrade", "relation": "contains", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260608_1132-rename_track_file_path_to_storage_uri.py", "source_location": "L20", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1132_rename_track_file_path_to_storage_uri_py", "target": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1132_rename_track_file_path_to_storage_uri_downgrade", "relation": "contains", "confidence": "EXTRACTED", "source_file": "alembic/versions/20260608_1132-rename_track_file_path_to_storage_uri.py", "source_location": "L24", "weight": 1.0}], "raw_calls": [{"caller_nid": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1132_rename_track_file_path_to_storage_uri_upgrade", "callee": "alter_column", "is_member_call": true, "source_file": "alembic/versions/20260608_1132-rename_track_file_path_to_storage_uri.py", "source_location": "L21", "receiver": "op"}, {"caller_nid": "users_senko_work_mcma_compose_mcma_backend_alembic_versions_20260608_1132_rename_track_file_path_to_storage_uri_downgrade", "callee": "alter_column", "is_member_call": true, "source_file": "alembic/versions/20260608_1132-rename_track_file_path_to_storage_uri.py", "source_location": "L25", "receiver": "op"}]}
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_track_py", "label": "track.py", "file_type": "code", "source_file": "app/domain/entities/track.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_track_artist", "label": "Artist", "file_type": "code", "source_file": "app/domain/entities/track.py", "source_location": "L9", "_callable": true}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_track_track", "label": "Track", "file_type": "code", "source_file": "app/domain/entities/track.py", "source_location": "L19", "_callable": true}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_track_rationale_1", "label": "Track and Artist domain entities.", "file_type": "rationale", "source_file": "app/domain/entities/track.py", "source_location": "L1"}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_track_py", "target": "datetime", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "app/domain/entities/track.py", "source_location": "L3", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_track_py", "target": "uuid", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "app/domain/entities/track.py", "source_location": "L4", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_track_py", "target": "dataclasses", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/domain/entities/track.py", "source_location": "L5", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_track_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_track_artist", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/domain/entities/track.py", "source_location": "L9", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_track_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_track_track", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/domain/entities/track.py", "source_location": "L19", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_track_rationale_1", "target": "users_senko_work_mcma_compose_mcma_backend_app_domain_entities_track_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/domain/entities/track.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_py", "label": "sync.py", "file_type": "code", "source_file": "app/api/v1/sync.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_get_changes", "label": "get_changes()", "file_type": "code", "source_file": "app/api/v1/sync.py", "source_location": "L11", "_callable": true}, {"id": "any", "label": "Any", "file_type": "code", "source_file": "", "source_location": "", "origin_file": "/Users/senko/Work/mcma-compose/mcma-backend/app/api/v1/sync.py"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_push_changes", "label": "push_changes()", "file_type": "code", "source_file": "app/api/v1/sync.py", "source_location": "L15", "_callable": true}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_rationale_1", "label": "Client sync endpoints (offline-first event log).", "file_type": "rationale", "source_file": "app/api/v1/sync.py", "source_location": "L1"}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_py", "target": "typing", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/api/v1/sync.py", "source_location": "L3", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_py", "target": "fastapi", "relation": "imports_from", "context": "import", "confidence": "EXTRACTED", "source_file": "app/api/v1/sync.py", "source_location": "L5", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_get_changes", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/api/v1/sync.py", "source_location": "L11", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_get_changes", "target": "any", "relation": "references", "context": "return_type", "confidence": "EXTRACTED", "source_file": "app/api/v1/sync.py", "source_location": "L11", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_py", "target": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_push_changes", "relation": "contains", "confidence": "EXTRACTED", "source_file": "app/api/v1/sync.py", "source_location": "L15", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_push_changes", "target": "any", "relation": "references", "context": "return_type", "confidence": "EXTRACTED", "source_file": "app/api/v1/sync.py", "source_location": "L15", "weight": 1.0}, {"source": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_rationale_1", "target": "users_senko_work_mcma_compose_mcma_backend_app_api_v1_sync_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/api/v1/sync.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"nodes": [{"id": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_sources_init_py", "label": "__init__.py", "file_type": "code", "source_file": "app/infrastructure/sources/__init__.py", "source_location": "L1"}, {"id": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_sources_init_rationale_1", "label": "Source backends \u2014 driven adapters that discover/fetch tracks.", "file_type": "rationale", "source_file": "app/infrastructure/sources/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_sources_init_rationale_1", "target": "users_senko_work_mcma_compose_mcma_backend_app_infrastructure_sources_init_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "app/infrastructure/sources/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
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