""" API router for MediaSource management (with YouTube support). Endpoints: GET /api/sources/ – list all sources POST /api/sources/ – create a new source POST /api/sources/cache-upcoming – download videos for upcoming airings GET /api/sources/download-status – snapshot of all YouTube item cache state GET /api/sources/{id} – retrieve one source DELETE /api/sources/{id} – delete a source POST /api/sources/{id}/sync – trigger yt-dlp metadata sync POST /api/sources/{id}/download – download a specific media item by ID IMPORTANT: literal paths (/cache-upcoming, /download-status) MUST be declared before parameterised paths (/{source_id}) so Django Ninja's URL dispatcher matches them first. """ from typing import Optional, List from datetime import datetime from django.shortcuts import get_object_or_404 from ninja import Router from pydantic import BaseModel from core.models import Library, MediaSource, MediaItem from core.services.youtube import sync_source, YOUTUBE_SOURCE_TYPES from core.services.cache import run_cache, get_download_status as _get_download_status router = Router(tags=["sources"]) # --------------------------------------------------------------------------- # Schemas # --------------------------------------------------------------------------- class MediaSourceIn(BaseModel): library_id: int name: str source_type: str # one of MediaSource.SourceType string values uri: str is_active: bool = True scan_interval_minutes: Optional[int] = None class MediaSourceOut(BaseModel): id: int library_id: int name: str source_type: str uri: str is_active: bool scan_interval_minutes: Optional[int] last_scanned_at: Optional[datetime] created_at: datetime class Config: from_attributes = True class SyncResult(BaseModel): created: int updated: int skipped: int max_videos: Optional[int] = None class CacheRunResult(BaseModel): pruned: int downloaded: int already_cached: int failed: int items: List[dict] class DownloadStatusResult(BaseModel): total: int cached: int items: List[dict] # --------------------------------------------------------------------------- # Collection endpoints (no path parameters — must come FIRST) # --------------------------------------------------------------------------- @router.get("/", response=list[MediaSourceOut]) def list_sources(request): """List all media sources across all libraries.""" return list(MediaSource.objects.select_related("library").all()) @router.post("/", response={201: MediaSourceOut}) def create_source(request, payload: MediaSourceIn): """Register a new media source (including YouTube channel/playlist URLs).""" library = get_object_or_404(Library, id=payload.library_id) source = MediaSource.objects.create( library=library, name=payload.name, source_type=payload.source_type, uri=payload.uri, is_active=payload.is_active, scan_interval_minutes=payload.scan_interval_minutes, ) return 201, source @router.post("/cache-upcoming", response=CacheRunResult) def trigger_cache_upcoming(request, hours: int = 24, prune_only: bool = False): """ Download YouTube videos for airings scheduled within the next `hours` hours. Equivalent to running: python manage.py cache_upcoming --hours N Query params: hours – scan window in hours (default: 24) prune_only – if true, only delete stale cache files; skip downloads """ result = run_cache(hours=hours, prune_only=prune_only) return result @router.get("/download-status", response=DownloadStatusResult) def download_status(request): """ Return a snapshot of all YouTube-backed MediaItems with their local cache status (downloaded vs not downloaded). """ return _get_download_status() # --------------------------------------------------------------------------- # Single-item endpoints (path parameters — AFTER all literal paths) # --------------------------------------------------------------------------- @router.get("/{source_id}", response=MediaSourceOut) def get_source(request, source_id: int): """Retrieve a single media source by ID.""" return get_object_or_404(MediaSource, id=source_id) @router.delete("/{source_id}", response={204: None}) def delete_source(request, source_id: int): """Delete a media source and all its associated media items.""" source = get_object_or_404(MediaSource, id=source_id) source.delete() return 204, None @router.post("/{source_id}/sync", response=SyncResult) def trigger_sync(request, source_id: int, max_videos: Optional[int] = None): """ Trigger a yt-dlp metadata sync for a YouTube source. Phase 1 only — video METADATA is fetched and upserted as MediaItem rows. No video files are downloaded here. Query params: max_videos – override the default cap (channel default: 50, playlist: 200) """ source = get_object_or_404(MediaSource, id=source_id) if source.source_type not in YOUTUBE_SOURCE_TYPES: from ninja.errors import HttpError raise HttpError(400, "Source is not a YouTube source type.") result = sync_source(source, max_videos=max_videos) return {**result, "max_videos": max_videos} @router.post("/{item_id}/download", response=dict) def download_item(request, item_id: int): """ Immediately download a single YouTube MediaItem to local cache. The item must already exist (synced) as a MediaItem in the database. """ from core.services.youtube import download_for_airing item = get_object_or_404(MediaItem, id=item_id) if not item.youtube_video_id: from ninja.errors import HttpError raise HttpError(400, "MediaItem is not a YouTube video.") try: path = download_for_airing(item) return {"status": "downloaded", "path": str(path)} except Exception as exc: from ninja.errors import HttpError raise HttpError(500, f"Download failed: {exc}")