78007461e1
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>
96 lines
3.2 KiB
Python
96 lines
3.2 KiB
Python
"""Source-backend value objects — framework-free.
|
|
|
|
A *source* is a place tracks come from (a mounted folder, YouTube, an upload).
|
|
Backends are driven adapters (``app.infrastructure.sources``); these are the
|
|
shapes they speak in, and the ports they satisfy live in ``app.domain.ports``.
|
|
|
|
The first backend, ``local``, is *indexable*: it enumerates files already on
|
|
disk. Concrete metadata (artist/album/tags) is intentionally **not** resolved
|
|
here — a source yields a file plus a minimal title; enrichment (plan §6.2) fills
|
|
the rest later, so this stays a thin discovery layer (CLAUDE.md: no duplicated
|
|
business logic)."""
|
|
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# A source's ``kind`` describes which ports it satisfies, so the UI/admin can
|
|
# tell an indexed folder from a searchable fetch-source. A backend may be both.
|
|
KIND_INDEXABLE = "indexable" # enumerates files already on disk (local folder)
|
|
KIND_FETCH = "fetch" # searches + downloads from an external service (YTM, …)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SourceInfo:
|
|
"""Describes a registered source for enumeration / health (UI, admin)."""
|
|
|
|
name: str
|
|
label: str
|
|
kind: str # KIND_INDEXABLE | KIND_FETCH
|
|
available: bool
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SourceFile:
|
|
"""A single importable file discovered by an indexable source.
|
|
|
|
``source_id`` is stable per source (the local backend uses the path relative
|
|
to its root) so re-scans are idempotent — already-imported files are skipped.
|
|
"""
|
|
|
|
source_id: str
|
|
path: Path
|
|
suggested_title: str
|
|
file_format: str
|
|
file_size: int
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SearchResult:
|
|
"""One hit from a searchable source (plan §5), shown on the discover screen.
|
|
|
|
``source_id`` is the stable handle the same backend later resolves in
|
|
``fetch`` — it must round-trip a download request without re-searching.
|
|
``raw`` carries the backend's untouched payload for debugging / future use.
|
|
"""
|
|
|
|
source: str
|
|
source_id: str
|
|
title: str
|
|
artist: str | None
|
|
album: str | None
|
|
duration_seconds: int | None
|
|
thumbnail_url: str | None
|
|
raw: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RawMetadata:
|
|
"""Metadata a fetch-source can offer about an item *before* enrichment.
|
|
|
|
Best-effort and source-shaped — the canonical metadata still comes from the
|
|
enrichment pipeline (plan §6.2). Used to seed a more useful provisional
|
|
title than a bare id while a download is queued."""
|
|
|
|
title: str | None
|
|
artist: str | None
|
|
album: str | None
|
|
year: int | None
|
|
extra: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class DownloadResult:
|
|
"""A file a fetch-source produced on local disk (plan §5).
|
|
|
|
``path`` is a temp file the caller owns: it is stored into managed storage
|
|
and then removed (same lifecycle as an upload). ``source_id`` is echoed back
|
|
because some backends only learn the canonical id during the download."""
|
|
|
|
source_id: str
|
|
path: Path
|
|
file_format: str
|
|
file_size: int
|
|
bitrate: int | None
|
|
suggested_title: str
|