197 lines
6.7 KiB
Python
197 lines
6.7 KiB
Python
"""
|
||
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}")
|
||
|
||
|
||
@router.get("/{item_id}/progress", response=dict)
|
||
def download_progress(request, item_id: int):
|
||
"""
|
||
Return the current download progress string (e.g. '45.1%') from the yt-dlp hook.
|
||
"""
|
||
from django.core.cache import cache
|
||
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.")
|
||
|
||
if item.cached_file_path:
|
||
return {"status": "finished", "progress": "100%"}
|
||
|
||
pct = cache.get(f"yt_progress_{item.youtube_video_id}")
|
||
return {"status": "downloading" if pct else "unknown", "progress": pct or "0%"}
|