Files
PYTV/api/routers/sources.py
2026-03-08 16:48:58 -04:00

179 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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}")