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