feat(sources): YouTube Music search + download pipeline (§1C/§1E)
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>
This commit is contained in:
+79
-2
@@ -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
|
||||
@@ -18,6 +18,7 @@ from app.domain.entities import (
|
||||
CoverArt,
|
||||
Credentials,
|
||||
DiskUsage,
|
||||
DownloadJob,
|
||||
Fingerprint,
|
||||
LibraryStats,
|
||||
Like,
|
||||
@@ -29,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: ...
|
||||
@@ -275,6 +281,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, …).
|
||||
|
||||
@@ -293,6 +347,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
|
||||
|
||||
Reference in New Issue
Block a user