feat(main): commit

This commit is contained in:
2026-03-08 16:48:58 -04:00
parent 567766eaed
commit f37382d2b8
29 changed files with 3735 additions and 223 deletions

View File

@@ -1,7 +1,9 @@
from ninja import Router, Schema
from typing import List, Optional
from core.models import Channel, AppUser, Library
from core.models import Channel, AppUser, Library, ChannelSourceRule, MediaSource, Airing
from django.shortcuts import get_object_or_404
from django.utils import timezone
from datetime import datetime, timedelta
router = Router(tags=["channel"])
@@ -23,6 +25,66 @@ class ChannelCreateSchema(Schema):
library_id: int
owner_user_id: int # Mock Auth User
class ChannelUpdateSchema(Schema):
name: Optional[str] = None
description: Optional[str] = None
channel_number: Optional[int] = None
scheduling_mode: Optional[str] = None
visibility: Optional[str] = None
is_active: Optional[bool] = None
class ChannelSourceRuleSchema(Schema):
id: int
source_id: int
source_name: str
rule_mode: str
weight: float
class ChannelSourceAssignSchema(Schema):
source_id: int
rule_mode: str = 'allow' # allow | prefer | avoid | block
weight: float = 1.0
class AiringSchema(Schema):
id: int
media_item_title: str
media_item_path: Optional[str] = None
starts_at: datetime
ends_at: datetime
slot_kind: str
status: str
@staticmethod
def from_airing(airing) -> 'AiringSchema':
media_path = None
if airing.media_item:
raw_path = airing.media_item.cached_file_path or airing.media_item.file_path
if raw_path:
if raw_path.startswith("http://") or raw_path.startswith("https://"):
media_path = raw_path
else:
from django.conf import settings
import os
try:
rel_path = os.path.relpath(raw_path, settings.MEDIA_ROOT)
if not rel_path.startswith("..") and not os.path.isabs(rel_path):
base = settings.MEDIA_URL.rstrip('/')
media_path = f"{base}/{rel_path}"
else:
media_path = raw_path
except ValueError:
media_path = raw_path
return AiringSchema(
id=airing.id,
media_item_title=airing.media_item.title if airing.media_item else 'Unknown',
media_item_path=media_path,
starts_at=airing.starts_at,
ends_at=airing.ends_at,
slot_kind=airing.slot_kind,
status=airing.status,
)
@router.get("/", response=List[ChannelSchema])
def list_channels(request):
return Channel.objects.all()
@@ -45,3 +107,96 @@ def create_channel(request, payload: ChannelCreateSchema):
description=payload.description
)
return 201, channel
@router.patch("/{channel_id}", response=ChannelSchema)
def update_channel(request, channel_id: int, payload: ChannelUpdateSchema):
channel = get_object_or_404(Channel, id=channel_id)
for attr, value in payload.dict(exclude_unset=True).items():
setattr(channel, attr, value)
channel.save()
return channel
@router.delete("/{channel_id}", response={204: None})
def delete_channel(request, channel_id: int):
channel = get_object_or_404(Channel, id=channel_id)
channel.delete()
return 204, None
@router.get("/{channel_id}/sources", response=List[ChannelSourceRuleSchema])
def list_channel_sources(request, channel_id: int):
channel = get_object_or_404(Channel, id=channel_id)
rules = ChannelSourceRule.objects.filter(channel=channel, media_source__isnull=False).select_related('media_source')
return [
ChannelSourceRuleSchema(
id=r.id,
source_id=r.media_source.id,
source_name=r.media_source.name,
rule_mode=r.rule_mode,
weight=float(r.weight),
)
for r in rules
]
@router.post("/{channel_id}/sources", response={201: ChannelSourceRuleSchema})
def assign_source_to_channel(request, channel_id: int, payload: ChannelSourceAssignSchema):
channel = get_object_or_404(Channel, id=channel_id)
source = get_object_or_404(MediaSource, id=payload.source_id)
rule = ChannelSourceRule.objects.create(
channel=channel,
media_source=source,
rule_mode=payload.rule_mode,
weight=payload.weight,
)
return 201, ChannelSourceRuleSchema(
id=rule.id,
source_id=source.id,
source_name=source.name,
rule_mode=rule.rule_mode,
weight=float(rule.weight),
)
@router.delete("/{channel_id}/sources/{rule_id}", response={204: None})
def remove_source_from_channel(request, channel_id: int, rule_id: int):
rule = get_object_or_404(ChannelSourceRule, id=rule_id, channel_id=channel_id)
rule.delete()
return 204, None
@router.get("/{channel_id}/now", response=Optional[AiringSchema])
def channel_now_playing(request, channel_id: int):
"""Return the Airing currently on-air for this channel, or null."""
channel = get_object_or_404(Channel, id=channel_id)
# Using a 1-second buffer to handle boundary conditions smoothly
now = timezone.now()
airing = (
Airing.objects
.filter(channel=channel, starts_at__lte=now, ends_at__gt=now)
.select_related('media_item')
.first()
)
if airing is None:
return None
return AiringSchema.from_airing(airing)
@router.get("/{channel_id}/airings", response=List[AiringSchema])
def channel_airings(request, channel_id: int, hours: int = 4):
"""
Return Airings for this channel that overlap with the window:
[now - 2 hours, now + {hours} hours]
"""
channel = get_object_or_404(Channel, id=channel_id)
now = timezone.now()
window_start = now - timedelta(hours=2) # Look back 2h for context
window_end = now + timedelta(hours=hours)
# Logic for overlap: starts_at < window_end AND ends_at > window_start
airings = (
Airing.objects
.filter(
channel=channel,
starts_at__lt=window_end,
ends_at__gt=window_start
)
.select_related('media_item')
.order_by('starts_at')
)
return [AiringSchema.from_airing(a) for a in airings]

View File

@@ -1,6 +1,6 @@
from ninja import Router, Schema
from typing import List, Optional
from core.models import ScheduleTemplate, Channel
from core.models import ScheduleTemplate, Channel, ScheduleBlock
from django.shortcuts import get_object_or_404
from datetime import date
@@ -49,6 +49,19 @@ def create_schedule_template(request, payload: ScheduleTemplateCreateSchema):
priority=payload.priority,
is_active=payload.is_active
)
# Create a default 24/7 programming block automatically to avoid
# complex block management in the UI
from datetime import time
ScheduleBlock.objects.create(
schedule_template=template,
name="Default 24/7 Block",
block_type=ScheduleBlock.BlockType.PROGRAMMING,
start_local_time=time(0, 0, 0),
end_local_time=time(23, 59, 59),
day_of_week_mask=127,
)
return 201, template
class GenerateScheduleSchema(Schema):
@@ -63,3 +76,19 @@ def generate_schedule(request, channel_id: int, payload: GenerateScheduleSchema)
generator = ScheduleGenerator(channel=channel)
airings_created = generator.generate_for_date(payload.target_date)
return {"status": "success", "airings_created": airings_created}
@router.delete("/template/{template_id}", response={204: None})
def delete_schedule_template(request, template_id: int):
template = get_object_or_404(ScheduleTemplate, id=template_id)
template.delete()
return 204, None
@router.post("/generate-today/{channel_id}")
def generate_schedule_today(request, channel_id: int):
"""Convenience endpoint: generates today's schedule for a channel."""
from datetime import date
from core.services.scheduler import ScheduleGenerator
channel = get_object_or_404(Channel, id=channel_id)
generator = ScheduleGenerator(channel=channel)
airings_created = generator.generate_for_date(date.today())
return {"status": "success", "airings_created": airings_created}

178
api/routers/sources.py Normal file
View File

@@ -0,0 +1,178 @@
"""
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}")

View File

@@ -50,3 +50,9 @@ def update_user(request, user_id: int, payload: UserUpdateSchema):
setattr(user, attr, value)
user.save()
return user
@router.delete("/{user_id}", response={204: None})
def delete_user(request, user_id: int):
user = get_object_or_404(AppUser, id=user_id)
user.delete()
return 204, None