Files
musicseerr/backend/api/v1/routes/discover.py
T
2026-04-03 15:53:00 +01:00

214 lines
8.2 KiB
Python

import logging
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from api.v1.schemas.discover import (
DiscoverResponse,
DiscoverQueueResponse,
DiscoverQueueEnrichment,
DiscoverIgnoredRelease,
DiscoverQueueIgnoreRequest,
DiscoverQueueValidateRequest,
DiscoverQueueValidateResponse,
DiscoverQueueStatusResponse,
QueueGenerateRequest,
QueueGenerateResponse,
YouTubeSearchResponse,
YouTubeQuotaResponse,
TrackCacheCheckRequest,
TrackCacheCheckResponse,
TrackCacheCheckResponseItem,
)
from api.v1.schemas.common import StatusMessageResponse
from core.dependencies import get_discover_service, get_discover_queue_manager, get_youtube_repo
from infrastructure.degradation import try_get_degradation_context
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
import msgspec.structs
from repositories.youtube import YouTubeRepository
from services.discover_service import DiscoverService
from services.discover_queue_manager import DiscoverQueueManager
logger = logging.getLogger(__name__)
router = APIRouter(route_class=MsgSpecRoute, prefix="/discover", tags=["discover"])
@router.get("", response_model=DiscoverResponse)
async def get_discover_data(
source: Literal["listenbrainz", "lastfm"] | None = Query(default=None, description="Data source: listenbrainz or lastfm"),
discover_service: DiscoverService = Depends(get_discover_service),
):
result = await discover_service.get_discover_data(source=source)
ctx = try_get_degradation_context()
if ctx is not None and ctx.has_degradation():
result = msgspec.structs.replace(result, service_status=ctx.degraded_summary())
return result
@router.post("/refresh", response_model=StatusMessageResponse)
async def refresh_discover_data(
discover_service: DiscoverService = Depends(get_discover_service),
):
await discover_service.refresh_discover_data()
return StatusMessageResponse(status="ok", message="Discover refresh triggered")
@router.get("/queue", response_model=DiscoverQueueResponse)
async def get_discover_queue(
count: int | None = Query(default=None, description="Number of items (default from settings, max 20)"),
source: Literal["listenbrainz", "lastfm"] | None = Query(default=None, description="Data source: listenbrainz or lastfm"),
discover_service: DiscoverService = Depends(get_discover_service),
queue_manager: DiscoverQueueManager = Depends(get_discover_queue_manager),
):
resolved = source or discover_service.resolve_source(None)
cached = await queue_manager.consume_queue(resolved)
if cached:
logger.info("Serving pre-built discover queue (source=%s, items=%d)", resolved, len(cached.items))
return cached
effective_count = min(count, 20) if count is not None else None
return await queue_manager.build_hydrated_queue(resolved, effective_count)
@router.get("/queue/status", response_model=DiscoverQueueStatusResponse)
async def get_queue_status(
source: Literal["listenbrainz", "lastfm"] | None = Query(default=None, description="Data source"),
discover_service: DiscoverService = Depends(get_discover_service),
queue_manager: DiscoverQueueManager = Depends(get_discover_queue_manager),
):
resolved = source or discover_service.resolve_source(None)
return queue_manager.get_status(resolved)
@router.post("/queue/generate", response_model=QueueGenerateResponse)
async def generate_queue(
body: QueueGenerateRequest = MsgSpecBody(QueueGenerateRequest),
discover_service: DiscoverService = Depends(get_discover_service),
queue_manager: DiscoverQueueManager = Depends(get_discover_queue_manager),
):
resolved = body.source or discover_service.resolve_source(None)
return await queue_manager.start_build(resolved, force=body.force)
@router.get("/queue/enrich/{release_group_mbid}", response_model=DiscoverQueueEnrichment)
async def enrich_queue_item(
release_group_mbid: str,
discover_service: DiscoverService = Depends(get_discover_service),
):
return await discover_service.enrich_queue_item(release_group_mbid)
@router.post("/queue/ignore", status_code=204)
async def ignore_queue_item(
body: DiscoverQueueIgnoreRequest = MsgSpecBody(DiscoverQueueIgnoreRequest),
discover_service: DiscoverService = Depends(get_discover_service),
):
await discover_service.ignore_release(
body.release_group_mbid, body.artist_mbid, body.release_name, body.artist_name
)
@router.get("/queue/ignored", response_model=list[DiscoverIgnoredRelease])
async def get_ignored_items(
discover_service: DiscoverService = Depends(get_discover_service),
):
return await discover_service.get_ignored_releases()
@router.post("/queue/validate", response_model=DiscoverQueueValidateResponse)
async def validate_queue(
body: DiscoverQueueValidateRequest = MsgSpecBody(DiscoverQueueValidateRequest),
discover_service: DiscoverService = Depends(get_discover_service),
):
in_library = await discover_service.validate_queue_mbids(body.release_group_mbids)
return DiscoverQueueValidateResponse(in_library=in_library)
@router.get("/queue/youtube-search", response_model=YouTubeSearchResponse)
async def youtube_search(
artist: str = Query(..., description="Artist name"),
album: str = Query(..., description="Album name"),
yt_repo: YouTubeRepository = Depends(get_youtube_repo),
):
if not yt_repo or not yt_repo.is_configured:
return YouTubeSearchResponse(error="not_configured")
if yt_repo.quota_remaining <= 0 and not yt_repo.is_cached(artist, album):
return YouTubeSearchResponse(error="quota_exceeded")
was_cached = yt_repo.is_cached(artist, album)
video_id = await yt_repo.search_video(artist, album)
if video_id:
return YouTubeSearchResponse(
video_id=video_id,
embed_url=f"https://www.youtube.com/embed/{video_id}",
cached=was_cached,
)
return YouTubeSearchResponse(error="not_found")
@router.get("/queue/youtube-track-search", response_model=YouTubeSearchResponse)
async def youtube_track_search(
artist: str = Query(..., description="Artist name"),
track: str = Query(..., description="Track name"),
yt_repo: YouTubeRepository = Depends(get_youtube_repo),
):
if not yt_repo or not yt_repo.is_configured:
return YouTubeSearchResponse(error="not_configured")
if yt_repo.quota_remaining <= 0 and not yt_repo.is_cached(artist, track):
return YouTubeSearchResponse(error="quota_exceeded")
was_cached = yt_repo.is_cached(artist, track)
video_id = await yt_repo.search_track(artist, track)
if video_id:
return YouTubeSearchResponse(
video_id=video_id,
embed_url=f"https://www.youtube.com/embed/{video_id}",
cached=was_cached,
)
return YouTubeSearchResponse(error="not_found")
@router.get("/queue/youtube-quota", response_model=YouTubeQuotaResponse)
async def youtube_quota(
yt_repo: YouTubeRepository = Depends(get_youtube_repo),
):
if not yt_repo or not yt_repo.is_configured:
raise HTTPException(status_code=404, detail="YouTube not configured")
return yt_repo.get_quota_status()
CACHE_CHECK_MAX_ITEMS = 100
CACHE_CHECK_MAX_STR_LEN = 200
@router.post("/queue/youtube-cache-check", response_model=TrackCacheCheckResponse)
async def youtube_cache_check(
body: TrackCacheCheckRequest = MsgSpecBody(TrackCacheCheckRequest),
yt_repo: YouTubeRepository = Depends(get_youtube_repo),
):
if not yt_repo or not yt_repo.is_configured:
return TrackCacheCheckResponse()
seen: set[str] = set()
deduped: list[tuple[str, str]] = []
for item in body.items[:CACHE_CHECK_MAX_ITEMS]:
artist = item.artist[:CACHE_CHECK_MAX_STR_LEN]
track = item.track[:CACHE_CHECK_MAX_STR_LEN]
key = f"{artist.lower()}|{track.lower()}"
if key not in seen:
seen.add(key)
deduped.append((artist, track))
cache_results = yt_repo.are_cached(deduped)
return TrackCacheCheckResponse(
items=[
TrackCacheCheckResponseItem(
artist=artist,
track=track,
cached=cache_results.get(f"{artist.lower()}|{track.lower()}", False),
)
for artist, track in deduped
]
)