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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user