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 ] )