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

@@ -6,8 +6,10 @@ from api.routers.library import router as library_router
from api.routers.channel import router as channel_router
from api.routers.schedule import router as schedule_router
from api.routers.user import router as user_router
from api.routers.sources import router as sources_router
api.add_router("/library/", library_router)
api.add_router("/channel/", channel_router)
api.add_router("/schedule/", schedule_router)
api.add_router("/user/", user_router)
api.add_router("/sources/", sources_router)

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

View File

@@ -1,6 +1,9 @@
import pytest
from django.test import Client
from core.models import AppUser, Library, Channel
from core.models import AppUser, Library, Channel, MediaSource, MediaItem, Airing
from django.utils import timezone
from datetime import timedelta
import uuid
@pytest.fixture
def client():
@@ -63,5 +66,68 @@ def test_create_channel(client, user, library):
assert data["slug"] == "new-api-ch"
# Verify it hit the DB
assert Channel.objects.count() == 1
assert Channel.objects.get(id=data["id"]).name == "New API Channel"
@pytest.fixture
def media_source(db, library):
return MediaSource.objects.create(
library=library,
name="Test Source",
source_type="local_directory",
uri="/mock/test"
)
@pytest.fixture
def media_item_youtube(db, media_source):
return MediaItem.objects.create(
media_source=media_source,
title="YT Test Video",
item_kind="video",
runtime_seconds=600,
file_path="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
)
@pytest.mark.django_db
def test_channel_now_playing_uncached_media_path(client, channel, media_item_youtube):
now = timezone.now()
Airing.objects.create(
channel=channel,
media_item=media_item_youtube,
starts_at=now - timedelta(minutes=5),
ends_at=now + timedelta(minutes=5),
slot_kind="program",
status="playing",
generation_batch_uuid=uuid.uuid4()
)
response = client.get(f"/api/channel/{channel.id}/now")
assert response.status_code == 200
data = response.json()
assert data["media_item_title"] == "YT Test Video"
# Should use the raw file_path since there is no cached_file_path
assert data["media_item_path"] == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
@pytest.mark.django_db
def test_channel_now_playing_cached_media_path(client, channel, media_item_youtube):
from django.conf import settings
import os
media_item_youtube.cached_file_path = os.path.join(settings.MEDIA_ROOT, "dQw4w9WgXcQ.mp4")
media_item_youtube.save()
now = timezone.now()
Airing.objects.create(
channel=channel,
media_item=media_item_youtube,
starts_at=now - timedelta(minutes=5),
ends_at=now + timedelta(minutes=5),
slot_kind="program",
status="playing",
generation_batch_uuid=uuid.uuid4()
)
response = client.get(f"/api/channel/{channel.id}/now")
assert response.status_code == 200
data = response.json()
assert data["media_item_title"] == "YT Test Video"
# Should resolve the cached_file_path to a web-accessible MEDIA_URL
assert data["media_item_path"] == "/dQw4w9WgXcQ.mp4"

292
api/tests/test_sources.py Normal file
View File

@@ -0,0 +1,292 @@
"""
Tests for the /api/sources/ router.
Covers:
- Listing sources
- Creating local and YouTube-type sources
- Syncing a YouTube source (mocked yt-dlp call)
- Deleting a source
- Attempting to sync a non-YouTube source (should 400)
"""
import pytest
from unittest.mock import patch, MagicMock
from django.test import Client
from core.models import AppUser, Library, MediaSource, MediaItem
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def client():
return Client()
@pytest.fixture
def user(db):
return AppUser.objects.create_user(
username="srcuser", email="src@pytv.local", password="password"
)
@pytest.fixture
def library(db, user):
return Library.objects.create(
owner_user=user,
name="Source Test Library",
visibility="public",
)
@pytest.fixture
def local_source(db, library):
return MediaSource.objects.create(
library=library,
name="Local Movies",
source_type=MediaSource.SourceType.LOCAL_DIRECTORY,
uri="/mnt/movies",
)
@pytest.fixture
def youtube_source(db, library):
return MediaSource.objects.create(
library=library,
name="ABC News",
source_type=MediaSource.SourceType.YOUTUBE_CHANNEL,
uri="https://www.youtube.com/@ABCNews",
)
# ---------------------------------------------------------------------------
# Listing
# ---------------------------------------------------------------------------
@pytest.mark.django_db
def test_list_sources_empty(client):
"""Listing when no sources exist returns an empty array."""
response = client.get("/api/sources/")
assert response.status_code == 200
assert response.json() == []
@pytest.mark.django_db
def test_list_sources_returns_all(client, local_source, youtube_source):
response = client.get("/api/sources/")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
names = {s["name"] for s in data}
assert names == {"Local Movies", "ABC News"}
# ---------------------------------------------------------------------------
# Creation
# ---------------------------------------------------------------------------
@pytest.mark.django_db
def test_create_local_source(client, library):
payload = {
"library_id": library.id,
"name": "My Movies",
"source_type": "local_directory",
"uri": "/mnt/media/movies",
"is_active": True,
}
response = client.post(
"/api/sources/", data=payload, content_type="application/json"
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "My Movies"
assert data["source_type"] == "local_directory"
assert MediaSource.objects.filter(id=data["id"]).exists()
@pytest.mark.django_db
def test_create_youtube_channel_source(client, library):
"""Registering a YouTube channel URL as a media source."""
payload = {
"library_id": library.id,
"name": "ABC News",
"source_type": "youtube_channel",
"uri": "https://www.youtube.com/@ABCNews",
"is_active": True,
}
response = client.post(
"/api/sources/", data=payload, content_type="application/json"
)
assert response.status_code == 201
data = response.json()
assert data["source_type"] == "youtube_channel"
assert "@ABCNews" in data["uri"]
# Verify it's in the DB with correct type
src = MediaSource.objects.get(id=data["id"])
assert src.source_type == MediaSource.SourceType.YOUTUBE_CHANNEL
@pytest.mark.django_db
def test_create_youtube_playlist_source(client, library):
"""Registering a YouTube playlist URL as a media source."""
payload = {
"library_id": library.id,
"name": "Tech Talks",
"source_type": "youtube_playlist",
"uri": "https://www.youtube.com/playlist?list=PLFgquLnL59akA2PflFpeQG9L01VFg90wS",
"is_active": True,
}
response = client.post(
"/api/sources/", data=payload, content_type="application/json"
)
assert response.status_code == 201
data = response.json()
assert data["source_type"] == "youtube_playlist"
@pytest.mark.django_db
def test_create_source_invalid_library(client):
"""Creating a source with a non-existent library should 404."""
payload = {
"library_id": 99999,
"name": "Orphan",
"source_type": "local_directory",
"uri": "/nope",
}
response = client.post(
"/api/sources/", data=payload, content_type="application/json"
)
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Retrieval
# ---------------------------------------------------------------------------
@pytest.mark.django_db
def test_get_source(client, youtube_source):
response = client.get(f"/api/sources/{youtube_source.id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == youtube_source.id
assert data["source_type"] == "youtube_channel"
@pytest.mark.django_db
def test_get_source_not_found(client):
response = client.get("/api/sources/99999")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Deletion
# ---------------------------------------------------------------------------
@pytest.mark.django_db
def test_delete_source(client, local_source):
source_id = local_source.id
response = client.delete(f"/api/sources/{source_id}")
assert response.status_code == 204
assert not MediaSource.objects.filter(id=source_id).exists()
@pytest.mark.django_db
def test_delete_source_not_found(client):
response = client.delete("/api/sources/99999")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# Sync (mocked yt-dlp)
# ---------------------------------------------------------------------------
MOCK_YT_ENTRIES = [
{
"id": "abc123",
"title": "Breaking News: Test Story",
"duration": 180,
"thumbnail": "https://i.ytimg.com/vi/abc123/hqdefault.jpg",
"description": "A breaking news segment.",
"upload_date": "20240315",
"url": "https://www.youtube.com/watch?v=abc123",
"uploader": "ABC News",
},
{
"id": "def456",
"title": "Weather Report: Sunny with a chance of clouds",
"duration": 90,
"thumbnail": "https://i.ytimg.com/vi/def456/hqdefault.jpg",
"description": "Evening weather.",
"upload_date": "20240316",
"url": "https://www.youtube.com/watch?v=def456",
"uploader": "ABC News",
},
]
@pytest.mark.django_db
def test_sync_youtube_channel_creates_media_items(client, youtube_source):
"""
Syncing a YouTube channel should call yt-dlp (mocked) and upsert
MediaItem rows for each discovered video.
"""
with patch("core.services.youtube._extract_playlist_info", return_value=MOCK_YT_ENTRIES):
response = client.post(f"/api/sources/{youtube_source.id}/sync")
assert response.status_code == 200
data = response.json()
# Two new videos → created=2
assert data["created"] == 2
assert data["updated"] == 0
assert data["skipped"] == 0
# MediaItems should now exist in the DB
items = MediaItem.objects.filter(media_source=youtube_source)
assert items.count() == 2
titles = {i.title for i in items}
assert "Breaking News: Test Story" in titles
assert "Weather Report: Sunny with a chance of clouds" in titles
# Verify youtube_video_id is populated
item = items.get(youtube_video_id="abc123")
assert item.runtime_seconds == 180
assert item.release_year == 2024
@pytest.mark.django_db
def test_sync_youtube_channel_updates_existing(client, youtube_source):
"""Re-syncing the same source updates existing rows rather than duplicating."""
with patch("core.services.youtube._extract_playlist_info", return_value=MOCK_YT_ENTRIES):
client.post(f"/api/sources/{youtube_source.id}/sync")
# Second sync — same entries, should update not create
with patch(
"core.services.youtube._extract_playlist_info",
return_value=[{**MOCK_YT_ENTRIES[0], "title": "Updated Title"}],
):
response = client.post(f"/api/sources/{youtube_source.id}/sync")
assert response.status_code == 200
data = response.json()
assert data["created"] == 0
assert data["updated"] == 1
item = MediaItem.objects.get(youtube_video_id="abc123")
assert item.title == "Updated Title"
@pytest.mark.django_db
def test_sync_non_youtube_source_returns_400(client, local_source):
"""Syncing a non-YouTube source type should return HTTP 400."""
response = client.post(f"/api/sources/{local_source.id}/sync")
assert response.status_code == 400
@pytest.mark.django_db
def test_sync_source_not_found(client):
response = client.post("/api/sources/99999/sync")
assert response.status_code == 404