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

BIN
.DS_Store vendored Normal file

Binary file not shown.

BIN
.coverage

Binary file not shown.

View File

@@ -17,7 +17,7 @@ COPY . /app
# Install dependencies using uv
# --system ensures it installs into the global python environment rather than venv
RUN uv pip install --system django psycopg django-environ gunicorn django-ninja django-cors-headers
RUN uv pip install --system django psycopg django-environ gunicorn django-ninja django-cors-headers yt-dlp
# Expose Django default port
EXPOSE 8000

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

View File

@@ -0,0 +1,52 @@
"""
management command: cache_upcoming
Delegates to core.services.cache.run_cache() — the same logic exposed
by the API endpoint, so CLI and web UI behavior are always in sync.
Usage:
python manage.py cache_upcoming # default: next 24 hours
python manage.py cache_upcoming --hours 48
python manage.py cache_upcoming --prune-only
"""
from django.core.management.base import BaseCommand
from core.services.cache import run_cache
class Command(BaseCommand):
help = "Download YouTube videos for upcoming airings and prune old cache files."
def add_arguments(self, parser):
parser.add_argument(
"--hours",
type=int,
default=24,
help="How many hours ahead to scan for upcoming airings (default: 24).",
)
parser.add_argument(
"--prune-only",
action="store_true",
default=False,
help="Only delete expired cache files; do not download anything new.",
)
def handle(self, *args, **options):
hours = options["hours"]
prune_only = options["prune_only"]
self.stdout.write(f"▶ Running cache worker (window: {hours}h, prune-only: {prune_only})")
result = run_cache(hours=hours, prune_only=prune_only)
self.stdout.write(self.style.SUCCESS(f" 🗑 Pruned: {result['pruned']}"))
self.stdout.write(self.style.SUCCESS(f" ↓ Downloaded: {result['downloaded']}"))
self.stdout.write(self.style.SUCCESS(f" ✓ Already cached: {result['already_cached']}"))
if result["failed"]:
self.stderr.write(self.style.ERROR(f" ✗ Failed: {result['failed']}"))
for item in result["items"]:
icon = {"downloaded": "", "cached": "", "failed": ""}.get(item["status"], "?")
line = f" {icon} [{item['status']:10}] {item['title'][:70]}"
if item.get("error"):
line += f"{item['error']}"
self.stdout.write(line)

View File

@@ -1,16 +1,29 @@
from django.core.management.base import BaseCommand
from core.models import AppUser, Library, Channel, MediaItem, Airing, ScheduleTemplate
from core.services.scheduler import ScheduleGenerator
from django.utils import timezone
from datetime import timedelta
from datetime import timedelta, date
import textwrap
class Command(BaseCommand):
help = "Displays a beautifully formatted terminal dashboard of the current backend state."
def add_arguments(self, parser):
parser.add_argument('--channel', type=int, help='Inspect specific channel schedule')
parser.add_argument('--test-generate', action='store_true', help='Trigger generation for today if inspecting a channel')
def get_color(self, text, code):
"""Helper to wrap string in bash color codes"""
return f"\033[{code}m{text}\033[0m"
def handle(self, *args, **options):
channel_id = options.get('channel')
test_generate = options.get('test_generate')
if channel_id:
self.inspect_channel(channel_id, test_generate)
return
# 1. Gather Aggregate Metrics
total_users = AppUser.objects.count()
total_libraries = Library.objects.count()
@@ -46,7 +59,7 @@ class Command(BaseCommand):
for c in channels:
status_color = "1;32" if c.is_active else "1;31"
status_text = "ACTIVE" if c.is_active else "INACTIVE"
self.stdout.write(f"\n 📺 [{c.channel_number or '-'}] {c.name} ({self.get_color(status_text, status_color)})")
self.stdout.write(f"\n 📺 [{c.id}] {c.name} (Ch {c.channel_number or '-'}) ({self.get_color(status_text, status_color)})")
# Show templates
templates = c.scheduletemplate_set.filter(is_active=True).order_by('-priority')
@@ -57,4 +70,40 @@ class Command(BaseCommand):
blocks_count = t.scheduleblock_set.count()
self.stdout.write(f" 📄 Template: {t.name} (Priority {t.priority}) -> {blocks_count} Blocks")
self.stdout.write(f"\nUse {self.get_color('--channel <id>', '1;37')} to inspect detailed schedule.\n")
def inspect_channel(self, channel_id, test_generate):
try:
channel = Channel.objects.get(id=channel_id)
except Channel.DoesNotExist:
self.stdout.write(self.get_color(f"Error: Channel {channel_id} not found.", "1;31"))
return
if test_generate:
self.stdout.write(self.get_color(f"\nTriggering schedule generation for {channel.name}...", "1;33"))
generator = ScheduleGenerator(channel)
count = generator.generate_for_date(date.today())
self.stdout.write(f"Done. Created {self.get_color(str(count), '1;32')} new airings.")
now = timezone.now()
end_window = now + timedelta(hours=12)
airings = Airing.objects.filter(
channel=channel,
ends_at__gt=now,
starts_at__lt=end_window
).select_related('media_item').order_by('starts_at')
self.stdout.write(self.get_color(f"\n=== Schedule for {channel.name} (Next 12h) ===", "1;34"))
if not airings:
self.stdout.write(self.get_color(" (No airings scheduled in this window)", "1;33"))
else:
for a in airings:
time_str = f"{a.starts_at.strftime('%H:%M')} - {a.ends_at.strftime('%H:%M')}"
if a.starts_at <= now <= a.ends_at:
self.stdout.write(f" {self.get_color('▶ ON AIR', '1;32')} {self.get_color(time_str, '1;37')} | {a.media_item.title}")
else:
self.stdout.write(f" {time_str} | {a.media_item.title}")
self.stdout.write("\n")

View File

@@ -0,0 +1,45 @@
# Generated by Django 6.0.3 on 2026-03-08 15:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="mediaitem",
name="cache_expires_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="mediaitem",
name="cached_file_path",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="mediaitem",
name="youtube_video_id",
field=models.CharField(blank=True, db_index=True, max_length=64, null=True),
),
migrations.AlterField(
model_name="mediasource",
name="source_type",
field=models.CharField(
choices=[
("local_directory", "Local Directory"),
("network_share", "Network Share"),
("manual_import", "Manual Import"),
("playlist", "Playlist"),
("stream", "Stream"),
("api_feed", "API Feed"),
("youtube_channel", "YouTube Channel"),
("youtube_playlist", "YouTube Playlist"),
],
max_length=32,
),
),
]

View File

@@ -85,6 +85,8 @@ class MediaSource(models.Model):
PLAYLIST = 'playlist', 'Playlist'
STREAM = 'stream', 'Stream'
API_FEED = 'api_feed', 'API Feed'
YOUTUBE_CHANNEL = 'youtube_channel', 'YouTube Channel'
YOUTUBE_PLAYLIST = 'youtube_playlist', 'YouTube Playlist'
source_type = models.CharField(max_length=32, choices=SourceType.choices)
uri = models.TextField()
@@ -152,6 +154,11 @@ class MediaItem(models.Model):
is_active = models.BooleanField(default=True)
date_added_at = models.DateTimeField(auto_now_add=True)
metadata_json = models.JSONField(default=dict)
# YouTube-specific: the video ID from yt-dlp
youtube_video_id = models.CharField(max_length=64, blank=True, null=True, db_index=True)
# Local cache path for downloaded YouTube videos (distinct from file_path which holds source URI)
cached_file_path = models.TextField(blank=True, null=True)
cache_expires_at = models.DateTimeField(blank=True, null=True)
genres = models.ManyToManyField(Genre, related_name="media_items", blank=True)

140
core/services/cache.py Normal file
View File

@@ -0,0 +1,140 @@
"""
Cache service — reusable download/prune logic used by both:
- python manage.py cache_upcoming
- POST /api/sources/cache-upcoming
"""
import logging
import pathlib
from datetime import timedelta
from django.utils import timezone
from core.models import Airing, MediaItem, MediaSource
from core.services.youtube import download_for_airing, YOUTUBE_SOURCE_TYPES
logger = logging.getLogger(__name__)
def run_cache(hours: int = 24, prune_only: bool = False) -> dict:
"""
Scan Airings in the next `hours` hours, download any uncached YouTube
videos, and prune stale local files.
Returns a summary dict suitable for JSON serialization.
"""
now = timezone.now()
window_end = now + timedelta(hours=hours)
# ── Prune first ────────────────────────────────────────────────────────
pruned = _prune(now)
if prune_only:
return {"pruned": pruned, "downloaded": 0, "already_cached": 0, "failed": 0, "items": []}
# ── Find upcoming and currently playing YouTube-backed airings ──────────
upcoming = (
Airing.objects
.filter(ends_at__gt=now, starts_at__lte=window_end)
.select_related("media_item__media_source")
)
youtube_items: dict[int, MediaItem] = {}
for airing in upcoming:
item = airing.media_item
if item.media_source and item.media_source.source_type in YOUTUBE_SOURCE_TYPES:
youtube_items[item.pk] = item
downloaded = already_cached = failed = 0
items_status = []
for item in youtube_items.values():
# Skip if already cached
if item.cached_file_path and pathlib.Path(item.cached_file_path).exists():
already_cached += 1
items_status.append({
"id": item.pk,
"title": item.title,
"status": "cached",
"path": item.cached_file_path,
})
continue
try:
local_path = download_for_airing(item)
downloaded += 1
items_status.append({
"id": item.pk,
"title": item.title,
"status": "downloaded",
"path": str(local_path),
})
except Exception as exc:
failed += 1
items_status.append({
"id": item.pk,
"title": item.title,
"status": "failed",
"error": str(exc),
})
logger.error("download_for_airing(%s) failed: %s", item.pk, exc)
logger.info(
"run_cache(hours=%d): pruned=%d downloaded=%d cached=%d failed=%d",
hours, pruned, downloaded, already_cached, failed,
)
return {
"pruned": pruned,
"downloaded": downloaded,
"already_cached": already_cached,
"failed": failed,
"items": items_status,
}
def _prune(now) -> int:
"""Delete local cache files whose airings have all ended."""
pruned = 0
stale = MediaItem.objects.filter(cached_file_path__isnull=False).exclude(
airing__ends_at__gte=now
)
for item in stale:
p = pathlib.Path(item.cached_file_path)
if p.exists():
try:
p.unlink()
pruned += 1
except OSError as exc:
logger.warning("Could not delete %s: %s", p, exc)
item.cached_file_path = None
item.cache_expires_at = None
item.save(update_fields=["cached_file_path", "cache_expires_at"])
return pruned
def get_download_status() -> dict:
"""
Return a snapshot of all YouTube MediaItems and their cache status,
useful for rendering the Downloads UI.
"""
items = (
MediaItem.objects
.filter(media_source__source_type__in=YOUTUBE_SOURCE_TYPES)
.select_related("media_source")
.order_by("media_source__name", "title")
)
result = []
for item in items:
cached = bool(item.cached_file_path and pathlib.Path(item.cached_file_path).exists())
result.append({
"id": item.pk,
"title": item.title,
"source_name": item.media_source.name,
"source_id": item.media_source.id,
"youtube_video_id": item.youtube_video_id,
"runtime_seconds": item.runtime_seconds,
"cached": cached,
"cached_path": item.cached_file_path if cached else None,
})
return {"items": result, "total": len(result), "cached": sum(1 for r in result if r["cached"])}

View File

@@ -1,108 +1,210 @@
from datetime import datetime, timedelta, date, time, timezone
from core.models import Channel, ScheduleTemplate, ScheduleBlock, Airing, MediaItem
"""
Schedule generator — respects ChannelSourceRule assignments.
Source selection priority:
1. If any rules with rule_mode='prefer' exist, items from those sources
are weighted much more heavily.
2. Items from rule_mode='allow' sources fill the rest.
3. Items from rule_mode='avoid' sources are only used as a last resort
(weight × 0.1).
4. Items from rule_mode='block' sources are NEVER scheduled.
5. If NO ChannelSourceRule rows exist for this channel, falls back to
the old behaviour (all items in the channel's library).
"""
import random
import uuid
from datetime import datetime, timedelta, date, timezone
from core.models import (
Channel, ChannelSourceRule, ScheduleTemplate,
ScheduleBlock, Airing, MediaItem,
)
class ScheduleGenerator:
"""
A service that reads the latest ScheduleTemplate and Blocks for a given channel
and generates concrete Airings logic based on available matching MediaItems.
Reads ScheduleTemplate + ScheduleBlocks for a channel and fills the day
with concrete Airing rows, picking MediaItems according to the channel's
ChannelSourceRule assignments.
"""
def __init__(self, channel: Channel):
self.channel = channel
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def generate_for_date(self, target_date: date) -> int:
"""
Idempotent generation of airings for a specific date on this channel.
Returns the number of new Airings created.
Idempotent generation of airings for `target_date`.
Returns the number of new Airing rows created.
"""
# 1. Get the highest priority active template valid on this date
template = ScheduleTemplate.objects.filter(
channel=self.channel,
is_active=True
).filter(
# Start date is null or <= target_date
valid_from_date__isnull=True
).order_by('-priority').first()
# In a real app we'd construct complex Q objects for the valid dates,
# but for PYTV mock we will just grab the highest priority active template.
template = self._get_template()
if not template:
template = ScheduleTemplate.objects.filter(channel=self.channel, is_active=True).order_by('-priority').first()
if not template:
return 0
return 0
# 2. Extract day of week mask
# Python weekday: 0=Monday, 6=Sunday
# Our mask: bit 0 = Monday, bit 6 = Sunday
target_weekday_bit = 1 << target_date.weekday()
blocks = template.scheduleblock_set.all()
airings_created = 0
for block in blocks:
# Check if block runs on this day
if not (block.day_of_week_mask & target_weekday_bit):
continue
# Naive time combining mapping local time to UTC timeline without specific tz logic for simplicity now
start_dt = datetime.combine(target_date, block.start_local_time, tzinfo=timezone.utc)
end_dt = datetime.combine(target_date, block.end_local_time, tzinfo=timezone.utc)
end_dt = datetime.combine(target_date, block.end_local_time, tzinfo=timezone.utc)
# If the block wraps past midnight (e.g. 23:00 to 02:00)
# Midnight-wrap support (e.g. 23:0002:00)
if end_dt <= start_dt:
end_dt += timedelta(days=1)
# Clear existing airings in this window to allow idempotency
# Clear existing airings in this window (idempotency)
Airing.objects.filter(
channel=self.channel,
starts_at__gte=start_dt,
starts_at__lt=end_dt
starts_at__lt=end_dt,
).delete()
# 3. Pull matching Media Items
# Simplistic matching: pull items from library matching the block's genre
items_query = MediaItem.objects.filter(media_source__library=self.channel.library)
if block.default_genre:
items_query = items_query.filter(genres=block.default_genre)
available_items = list(items_query.exclude(item_kind="bumper"))
available_items = self._get_weighted_items(block)
if not available_items:
continue
# Shuffle randomly for basic scheduling variety
random.shuffle(available_items)
# 4. Fill the block
current_cursor = start_dt
item_index = 0
while current_cursor < end_dt and item_index < len(available_items):
item = available_items[item_index]
duration = timedelta(seconds=item.runtime_seconds or 3600)
# Check if this item fits
if current_cursor + duration > end_dt:
# Item doesn't strictly fit, but we'll squeeze it in and break if needed
# Real systems pad this out or trim the slot.
pass
import uuid
Airing.objects.create(
channel=self.channel,
schedule_template=template,
schedule_block=block,
media_item=item,
starts_at=current_cursor,
ends_at=current_cursor + duration,
slot_kind="program",
status="scheduled",
source_reason="template",
generation_batch_uuid=uuid.uuid4()
)
current_cursor += duration
item_index += 1
airings_created += 1
airings_created += self._fill_block(
template, block, start_dt, end_dt, available_items
)
return airings_created
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _get_template(self):
"""Pick the highest-priority active ScheduleTemplate for this channel."""
qs = ScheduleTemplate.objects.filter(
channel=self.channel, is_active=True
).order_by('-priority')
return qs.first()
def _get_weighted_items(self, block: ScheduleBlock) -> list:
"""
Build a weighted pool of MediaItems respecting ChannelSourceRule.
Returns a flat list with items duplicated according to their effective
weight (rounded to nearest int, min 1) so random.choice() gives the
right probability distribution without needing numpy.
"""
rules = list(
ChannelSourceRule.objects.filter(channel=self.channel)
.select_related('media_source')
)
if rules:
# ── Rules exist: build filtered + weighted pool ───────────────
allowed_source_ids = set() # allow + prefer
blocked_source_ids = set() # block
avoid_source_ids = set() # avoid
source_weights: dict[int, float] = {}
for rule in rules:
sid = rule.media_source_id
mode = rule.rule_mode
w = float(rule.weight or 1.0)
if mode == 'block':
blocked_source_ids.add(sid)
elif mode == 'avoid':
avoid_source_ids.add(sid)
source_weights[sid] = w * 0.1 # heavily discounted
elif mode == 'prefer':
allowed_source_ids.add(sid)
source_weights[sid] = w * 3.0 # boosted
else: # 'allow'
allowed_source_ids.add(sid)
source_weights[sid] = w
# Build base queryset from allowed + avoid sources (not blocked)
eligible_source_ids = (allowed_source_ids | avoid_source_ids) - blocked_source_ids
if not eligible_source_ids:
return []
base_qs = MediaItem.objects.filter(
media_source_id__in=eligible_source_ids,
is_active=True,
).exclude(item_kind='bumper').select_related('media_source')
else:
# ── No rules: fall back to full library (old behaviour) ────────
base_qs = MediaItem.objects.filter(
media_source__library=self.channel.library,
is_active=True,
).exclude(item_kind='bumper')
source_weights = {}
# Optionally filter by genre if block specifies one
if block.default_genre:
base_qs = base_qs.filter(genres=block.default_genre)
items = list(base_qs)
if not items:
return []
if not source_weights:
# No weight information — plain shuffle
random.shuffle(items)
return items
# Build weighted list: each item appears ⌈weight⌉ times
weighted: list[MediaItem] = []
for item in items:
w = source_weights.get(item.media_source_id, 1.0)
copies = max(1, round(w))
weighted.extend([item] * copies)
random.shuffle(weighted)
return weighted
def _fill_block(
self,
template: ScheduleTemplate,
block: ScheduleBlock,
start_dt: datetime,
end_dt: datetime,
items: list,
) -> int:
"""Fill start_dt→end_dt with sequential Airings, cycling through items."""
cursor = start_dt
idx = 0
created = 0
batch = uuid.uuid4()
while cursor < end_dt:
item = items[idx % len(items)]
idx += 1
duration = timedelta(seconds=max(item.runtime_seconds or 1800, 1))
# Don't let a single item overshoot the end by more than its own length
if cursor + duration > end_dt + timedelta(hours=1):
break
Airing.objects.create(
channel=self.channel,
schedule_template=template,
schedule_block=block,
media_item=item,
starts_at=cursor,
ends_at=cursor + duration,
slot_kind="program",
status="scheduled",
source_reason="template",
generation_batch_uuid=batch,
)
cursor += duration
created += 1
return created

244
core/services/youtube.py Normal file
View File

@@ -0,0 +1,244 @@
"""
YouTube source sync service.
Two-phase design:
Phase 1 — METADATA ONLY (sync_source):
Crawls a YouTube channel or playlist and upserts MediaItem rows with
title, duration, thumbnail etc. No video files are downloaded.
A max_videos cap keeps this fast for large channels.
Phase 2 — DOWNLOAD ON DEMAND (download_for_airing):
Called only by `python manage.py cache_upcoming` immediately before
a scheduled Airing. Downloads only the specific video needed.
"""
import logging
import os
from pathlib import Path
import yt_dlp
from django.conf import settings
from django.utils import timezone
from core.models import MediaItem, MediaSource
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
YOUTUBE_SOURCE_TYPES = {
MediaSource.SourceType.YOUTUBE_CHANNEL,
MediaSource.SourceType.YOUTUBE_PLAYLIST,
}
def _cache_dir() -> Path:
"""Return (and create) the directory where downloaded videos are stored."""
root = Path(getattr(settings, "MEDIA_ROOT", "/tmp/pytv_cache"))
root.mkdir(parents=True, exist_ok=True)
return root
# ---------------------------------------------------------------------------
# metadata extraction (no download)
# ---------------------------------------------------------------------------
def _extract_playlist_info(url: str, max_videos: int | None = None) -> list[dict]:
"""
Use yt-dlp to extract metadata for up to `max_videos` videos in a
channel/playlist without downloading any files.
`extract_flat=True` is crucial — it fetches only a lightweight index
(title, id, duration) rather than resolving full stream URLs, which
makes crawling large channels orders of magnitude faster.
Returns a list of yt-dlp info dicts (most-recent first for channels).
"""
ydl_opts = {
"quiet": True,
"no_warnings": True,
"extract_flat": True, # metadata only — NO stream/download URLs
"ignoreerrors": True,
}
if max_videos is not None:
# yt-dlp uses 1-based playlist indices; playlistend limits how many
# entries are fetched from the source before returning.
ydl_opts["playlistend"] = max_videos
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
if info is None:
return []
# Both channels and playlists wrap entries in an "entries" key.
entries = info.get("entries") or []
# Flatten one extra level for channels (channel -> playlist -> entries)
flat: list[dict] = []
for entry in entries:
if entry is None:
continue
if "entries" in entry: # nested playlist
flat.extend(e for e in entry["entries"] if e)
else:
flat.append(entry)
return flat
# ---------------------------------------------------------------------------
# public API
# ---------------------------------------------------------------------------
def sync_source(media_source: MediaSource, max_videos: int | None = None) -> dict:
"""
Phase 1: Metadata-only sync.
Crawls a YouTube channel/playlist and upserts MediaItem rows for each
discovered video. No video files are ever downloaded here.
Args:
media_source: The MediaSource to sync.
max_videos: Maximum number of videos to import. When None the
defaults are applied:
- youtube_channel → 50 (channels can have 10k+ videos)
- youtube_playlist → 200 (playlists are usually curated)
Returns:
{"created": int, "updated": int, "skipped": int}
"""
if media_source.source_type not in YOUTUBE_SOURCE_TYPES:
raise ValueError(f"MediaSource {media_source.id} is not a YouTube source.")
# Apply sensible defaults per source type
if max_videos is None:
if media_source.source_type == MediaSource.SourceType.YOUTUBE_CHANNEL:
max_videos = 50
else:
max_videos = 200
entries = _extract_playlist_info(media_source.uri, max_videos=max_videos)
created = updated = skipped = 0
for entry in entries:
video_id = entry.get("id")
if not video_id:
skipped += 1
continue
title = entry.get("title") or f"YouTube Video {video_id}"
duration = entry.get("duration") or 0 # seconds, may be None for live
thumbnail = entry.get("thumbnail") or ""
description = entry.get("description") or ""
release_year = None
upload_date = entry.get("upload_date") # "YYYYMMDD"
if upload_date and len(upload_date) >= 4:
try:
release_year = int(upload_date[:4])
except ValueError:
pass
# Store the YouTube watch URL in file_path so the scheduler can
# reference it. The ACTUAL video file will only be downloaded when
# `cache_upcoming` runs before the airing.
video_url = entry.get("url") or f"https://www.youtube.com/watch?v={video_id}"
obj, was_created = MediaItem.objects.update_or_create(
media_source=media_source,
youtube_video_id=video_id,
defaults={
"title": title,
"item_kind": MediaItem.ItemKind.MOVIE,
"runtime_seconds": max(int(duration), 1),
"file_path": video_url,
"thumbnail_path": thumbnail,
"description": description,
"release_year": release_year,
"metadata_json": {
"yt_id": video_id,
"yt_url": video_url,
"uploader": entry.get("uploader", ""),
},
"is_active": True,
},
)
if was_created:
created += 1
else:
updated += 1
# Update last-scanned timestamp
media_source.last_scanned_at = timezone.now()
media_source.save(update_fields=["last_scanned_at"])
logger.info(
"sync_source(%s): created=%d updated=%d skipped=%d (limit=%s)",
media_source.id,
created,
updated,
skipped,
max_videos,
)
return {"created": created, "updated": updated, "skipped": skipped}
def download_for_airing(media_item: MediaItem) -> Path:
"""
Download a YouTube video to the local cache so it can be served
directly without network dependency at airing time.
Returns the local Path of the downloaded file.
Raises RuntimeError if the download fails.
"""
video_id = media_item.youtube_video_id
if not video_id:
raise ValueError(f"MediaItem {media_item.id} has no youtube_video_id.")
cache_dir = _cache_dir()
# Use video_id so we can detect already-cached files quickly.
output_template = str(cache_dir / f"{video_id}.%(ext)s")
# Check if already cached and not expired
if media_item.cached_file_path:
existing = Path(media_item.cached_file_path)
if existing.exists():
logger.info("cache hit: %s already at %s", video_id, existing)
return existing
ydl_opts = {
"quiet": True,
"no_warnings": True,
"outtmpl": output_template,
# Only request pre-muxed (progressive) formats — no separate video+audio
# streams that would require ffmpeg to merge. Falls back through:
# 1. Best pre-muxed mp4 up to 1080p
# 2. Any pre-muxed mp4
# 3. Any pre-muxed webm
# 4. Anything pre-muxed (no merger needed)
"format": "best[ext=mp4][height<=1080]/best[ext=mp4]/best[ext=webm]/best",
}
url = media_item.file_path # URL stored here by sync_source
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
if info is None:
raise RuntimeError(f"yt-dlp returned no info for {url}")
downloaded_path = Path(ydl.prepare_filename(info))
if not downloaded_path.exists():
# yt-dlp may have merged to .mp4 even if the template said otherwise
mp4_path = downloaded_path.with_suffix(".mp4")
if mp4_path.exists():
downloaded_path = mp4_path
else:
raise RuntimeError(f"Expected download at {downloaded_path} but file not found.")
# Persist the cache location on the model
media_item.cached_file_path = str(downloaded_path)
media_item.save(update_fields=["cached_file_path"])
logger.info("downloaded %s -> %s", video_id, downloaded_path)
return downloaded_path

Binary file not shown.

View File

@@ -1,31 +1,52 @@
import React, { useState } from 'react';
import ChannelTuner from './components/ChannelTuner';
import Guide from './components/Guide';
import Settings from './components/Settings';
function App() {
const [showGuide, setShowGuide] = useState(false);
const [showSettings, setShowSettings] = useState(false);
// We capture the global Escape from within Guide for closing
React.useEffect(() => {
if (!showGuide) return;
const handleClose = (e) => {
if (['Escape', 'Backspace', 'Enter'].includes(e.key)) {
const handleKey = (e) => {
// 'S' key opens settings (when nothing else is open)
if (e.key === 's' && !showGuide && !showSettings) {
setShowSettings(true);
}
// Escape/Backspace closes whatever is open
if (['Escape', 'Backspace'].includes(e.key)) {
setShowGuide(false);
setShowSettings(false);
}
};
window.addEventListener('keydown', handleClose);
return () => window.removeEventListener('keydown', handleClose);
}, [showGuide]);
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [showGuide, showSettings]);
return (
<>
{/*
The ChannelTuner always remains mounted in the background
so we don't drop video connections while browsing the guide.
*/}
{/* ChannelTuner always stays mounted to preserve buffering */}
<ChannelTuner onOpenGuide={() => setShowGuide(!showGuide)} />
{showGuide && <Guide onClose={() => setShowGuide(false)} onSelectChannel={(id) => { console.log("Tuning to", id); setShowGuide(false); }} />}
{showGuide && (
<Guide
onClose={() => setShowGuide(false)}
onSelectChannel={(id) => { console.log('Tuning to', id); setShowGuide(false); }}
/>
)}
{showSettings && <Settings onClose={() => setShowSettings(false)} />}
{/* Gear button — visible only when nothing else is overlaid */}
{!showGuide && !showSettings && (
<button
className="settings-gear-btn"
onClick={() => setShowSettings(true)}
title="Settings (S)"
>
</button>
)}
</>
);
}

View File

@@ -1,24 +1,63 @@
import axios from 'axios';
// The base URL relies on the Vite proxy in development,
// and same-origin in production.
const apiClient = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
});
export const fetchChannels = async () => {
const response = await apiClient.get('/channel/');
return response.data;
// ── Channels ──────────────────────────────────────────────────────────────
export const fetchChannels = async () => (await apiClient.get('/channel/')).data;
export const createChannel = async (payload) => (await apiClient.post('/channel/', payload)).data;
export const updateChannel = async (id, payload) => (await apiClient.patch(`/channel/${id}`, payload)).data;
export const deleteChannel = async (id) => { await apiClient.delete(`/channel/${id}`); };
// Channel program data
export const fetchChannelNow = async (channelId) =>
(await apiClient.get(`/channel/${channelId}/now`)).data;
export const fetchChannelAirings = async (channelId, hours = 4) =>
(await apiClient.get(`/channel/${channelId}/airings?hours=${hours}`)).data;
// Channel ↔ Source assignments
export const fetchChannelSources = async (channelId) =>
(await apiClient.get(`/channel/${channelId}/sources`)).data;
export const assignSourceToChannel = async (channelId, payload) =>
(await apiClient.post(`/channel/${channelId}/sources`, payload)).data;
export const removeSourceFromChannel = async (channelId, ruleId) => {
await apiClient.delete(`/channel/${channelId}/sources/${ruleId}`);
};
// If a channel is selected, we can load its upcoming airings
export const fetchScheduleGenerations = async (channelId) => {
// We can trigger an immediate generation for the day to ensure there's data
const response = await apiClient.post(`/schedule/generate/${channelId}`);
return response.data;
};
// ── Schedule ──────────────────────────────────────────────────────────────
export const fetchTemplates = async () => (await apiClient.get('/schedule/template/')).data;
export const createTemplate = async (payload) =>
(await apiClient.post('/schedule/template/', payload)).data;
export const deleteTemplate = async (id) => { await apiClient.delete(`/schedule/template/${id}`); };
export const generateScheduleToday = async (channelId) =>
(await apiClient.post(`/schedule/generate-today/${channelId}`)).data;
// Future logic can query specific lists of Airings here...
// Legacy used by guide
export const fetchScheduleGenerations = async (channelId) =>
(await apiClient.post(`/schedule/generate/${channelId}`)).data;
// ── Media Sources (YouTube / local) ───────────────────────────────────────
export const fetchSources = async () => (await apiClient.get('/sources/')).data;
export const createSource = async (payload) => (await apiClient.post('/sources/', payload)).data;
export const syncSource = async (sourceId, maxVideos) => {
const url = maxVideos ? `/sources/${sourceId}/sync?max_videos=${maxVideos}` : `/sources/${sourceId}/sync`;
return (await apiClient.post(url)).data;
};
export const deleteSource = async (sourceId) => { await apiClient.delete(`/sources/${sourceId}`); };
// Download controls
export const fetchDownloadStatus = async () => (await apiClient.get('/sources/download-status')).data;
export const triggerCacheUpcoming = async (hours = 24, pruneOnly = false) =>
(await apiClient.post(`/sources/cache-upcoming?hours=${hours}&prune_only=${pruneOnly}`)).data;
export const downloadItem = async (itemId) => (await apiClient.post(`/sources/${itemId}/download`)).data;
// ── Libraries ─────────────────────────────────────────────────────────────
export const fetchLibraries = async () => (await apiClient.get('/library/')).data;
// ── Users ─────────────────────────────────────────────────────────────────
export const fetchUsers = async () => (await apiClient.get('/user/')).data;
export const createUser = async (payload) => (await apiClient.post('/user/', payload)).data;
export const updateUser = async (id, payload) => (await apiClient.patch(`/user/${id}`, payload)).data;
export const deleteUser = async (id) => { await apiClient.delete(`/user/${id}`); };

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useRemoteControl } from '../hooks/useRemoteControl';
import { fetchChannels } from '../api';
import { fetchChannels, fetchChannelNow } from '../api';
const FALLBACK_VIDEOS = [
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
@@ -13,6 +13,8 @@ export default function ChannelTuner({ onOpenGuide }) {
const [loading, setLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [showOSD, setShowOSD] = useState(true);
const [showDebug, setShowDebug] = useState(false);
const [nowPlaying, setNowPlaying] = useState({}); // { channelId: airingData }
const osdTimerRef = useRef(null);
// The 3 buffer indices
@@ -22,11 +24,11 @@ export default function ChannelTuner({ onOpenGuide }) {
const prevIndex = getPrevIndex(currentIndex);
const nextIndex = getNextIndex(currentIndex);
const triggerOSD = () => {
const triggerOSD = useCallback(() => {
setShowOSD(true);
if (osdTimerRef.current) clearTimeout(osdTimerRef.current);
osdTimerRef.current = setTimeout(() => setShowOSD(false), 5000);
};
}, []);
const wrapChannelUp = () => {
setCurrentIndex(getNextIndex);
@@ -42,89 +44,166 @@ export default function ChannelTuner({ onOpenGuide }) {
onChannelUp: wrapChannelUp,
onChannelDown: wrapChannelDown,
onSelect: triggerOSD,
onBack: onOpenGuide // Often on TVs 'Menu' or 'Back' opens Guide/App list
onBack: onOpenGuide
});
// Debug Info Toggle
useEffect(() => {
const handleKeyDown = (e) => {
// Ignore if user is typing in an input
if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return;
if (e.key === 'i' || e.key === 'I') {
setShowDebug(prev => !prev);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// Fetch channels from Django API
useEffect(() => {
fetchChannels().then(data => {
// If db gives us channels, pad them with a fallback video stream based on index
const mapped = data.map((ch, idx) => ({
...ch,
file: FALLBACK_VIDEOS[idx % FALLBACK_VIDEOS.length]
fallbackFile: FALLBACK_VIDEOS[idx % FALLBACK_VIDEOS.length]
}));
if (mapped.length === 0) {
// Fallback if db is completely empty
mapped.push({ id: 99, channel_number: '99', name: 'Default Local feed', file: FALLBACK_VIDEOS[0] });
mapped.push({ id: 99, channel_number: '99', name: 'Default Feed', fallbackFile: FALLBACK_VIDEOS[0] });
}
setChannels(mapped);
setLoading(false);
}).catch(err => {
console.error(err);
setChannels([{ id: 99, channel_number: '99', name: 'Error Offline', file: FALLBACK_VIDEOS[0] }]);
setChannels([{ id: 99, channel_number: '99', name: 'Offline', fallbackFile: FALLBACK_VIDEOS[0] }]);
setLoading(false);
});
}, []);
// Fetch "Now Playing" metadata for the current channel group whenever currentIndex changes
useEffect(() => {
if (channels.length === 0) return;
const activeIndices = [currentIndex, prevIndex, nextIndex];
activeIndices.forEach(idx => {
const chan = channels[idx];
fetchChannelNow(chan.id).then(data => {
setNowPlaying(prev => ({ ...prev, [chan.id]: data }));
}).catch(() => {
setNowPlaying(prev => ({ ...prev, [chan.id]: null }));
});
});
}, [currentIndex, channels, prevIndex, nextIndex]);
// Initial OSD hide
useEffect(() => {
if (!loading) triggerOSD();
return () => clearTimeout(osdTimerRef.current);
}, [loading]);
}, [loading, triggerOSD]);
if (loading) {
if (loading || channels.length === 0) {
return <div style={{position: 'absolute', top: '50%', left: '50%', color: 'white'}}>Connecting to PYTV Backend...</div>;
}
const currentChan = channels[currentIndex];
const airing = nowPlaying[currentChan.id];
return (
<div className="tuner-container">
{/*
We map over all channels, but selectively apply 'playing' or 'buffering'
classes to only the surrounding 3 elements. The rest are completely unrendered
to save immense DOM and memory resources.
*/}
{channels.map((chan, index) => {
const isCurrent = index === currentIndex;
const isPrev = index === prevIndex;
const isNext = index === nextIndex;
// Only mount the node if it's one of the 3 active buffers
if (!isCurrent && !isPrev && !isNext) return null;
let stateClass = 'buffering';
if (isCurrent) stateClass = 'playing';
// Use the current airing's media item file if available, else fallback
const currentAiring = nowPlaying[chan.id];
let videoSrc = chan.fallbackFile;
if (currentAiring && currentAiring.media_item_path) {
if (currentAiring.media_item_path.startsWith('http')) {
videoSrc = currentAiring.media_item_path;
} else {
// Django serves cached media at root, Vite proxies /media to root
// Remove leading slashes or /media/ to avoid double slashes like /media//mock
const cleanPath = currentAiring.media_item_path.replace(/^\/?(media)?\/*/, '');
videoSrc = `/media/${cleanPath}`;
}
}
return (
<video
key={chan.id}
src={chan.file}
src={videoSrc}
className={`tuner-video ${stateClass}`}
autoPlay={true}
muted={!isCurrent} // Always mute background buffers instantly
muted={!isCurrent}
loop
playsInline
onError={(e) => {
if (e.target.src !== chan.fallbackFile) {
console.warn(`Video failed to load: ${e.target.src}, falling back.`);
e.target.src = chan.fallbackFile;
}
}}
/>
);
})}
{/* Debug Info Overlay */}
{showDebug && (
<div className="debug-panel glass-panel">
<h3>PYTV Debug Info</h3>
<div className="debug-grid">
<span className="debug-label">Channel:</span>
<span className="debug-value">{currentChan.channel_number} - {currentChan.name} (ID: {currentChan.id})</span>
<span className="debug-label">Airing ID:</span>
<span className="debug-value">{airing ? airing.id : 'N/A'}</span>
<span className="debug-label">Media Item:</span>
<span className="debug-value">{airing && airing.media_item_title ? airing.media_item_title : 'N/A'}</span>
<span className="debug-label">Stream URL:</span>
<span className="debug-value debug-url">{(() => {
if (airing && airing.media_item_path) {
if (airing.media_item_path.startsWith('http')) return airing.media_item_path;
return `/media/${airing.media_item_path.replace(/^\/?(media)?\/*/, '')}`;
}
return currentChan.fallbackFile;
})()}</span>
</div>
</div>
)}
{/* OSD Layer */}
<div className={`osd-overlay ${showOSD ? '' : 'hidden'}`}>
<div className="osd-top">
<div className="osd-channel-bug">
<span className="ch-num">{channels[currentIndex].channel_number}</span>
{channels[currentIndex].name}
<span className="ch-num">{currentChan.channel_number}</span>
{currentChan.name}
</div>
</div>
<div className="osd-bottom">
<div className="osd-info-box glass-panel">
<div className="osd-meta">
<span className="osd-badge">LIVE</span>
<span>12:00 PM - 2:00 PM</span>
<span className="osd-badge">
{airing ? airing.slot_kind.toUpperCase() : 'LIVE'}
</span>
{airing && (
<span>
{new Date(airing.starts_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} -
{new Date(airing.ends_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}
</span>
)}
</div>
<h1 className="osd-title">Sample Movie Feed</h1>
<h1 className="osd-title">
{airing ? airing.media_item_title : 'Loading Program...'}
</h1>
<p style={{ color: 'var(--pytv-text-dim)' }}>
A classic broadcast playing locally via PYTV. Use Up/Down arrows to switch channels seamlessly.
Press Escape to load the EPG Guide.
Watching {currentChan.name}. Press Escape to load the EPG Guide.
</p>
</div>
</div>

View File

@@ -1,69 +1,195 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useRemoteControl } from '../hooks/useRemoteControl';
import { fetchChannels } from '../api';
import { fetchChannels, fetchChannelAirings } from '../api';
// Hours to show in the EPG and width configs
const EPG_HOURS = 4;
const HOUR_WIDTH_PX = 360;
const PX_PER_MS = HOUR_WIDTH_PX / (60 * 60 * 1000);
function fmtTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
export default function Guide({ onClose, onSelectChannel }) {
const [channels, setChannels] = useState([]);
const [airings, setAirings] = useState({}); // { channelId: [airing, ...] }
const [selectedIndex, setSelectedIndex] = useState(0);
const [now, setNow] = useState(Date.now());
const scrollRef = useRef(null);
// Time anchor: align to previous 30-min boundary for clean axis
const anchorTime = useRef(new Date(Math.floor(Date.now() / 1800000) * 1800000).getTime());
useRemoteControl({
onUp: () => setSelectedIndex(prev => (prev - 1 + channels.length) % channels.length),
onDown: () => setSelectedIndex(prev => (prev + 1) % channels.length),
onSelect: () => onSelectChannel(channels[selectedIndex].id),
onBack: onClose
onUp: () => setSelectedIndex(prev => (prev - 1 + channels.length) % channels.length),
onDown: () => setSelectedIndex(prev => (prev + 1) % channels.length),
onSelect: () => channels[selectedIndex] && onSelectChannel(channels[selectedIndex].id),
onBack: onClose,
});
const [currentTime, setCurrentTime] = useState(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
// Clock tick
useEffect(() => {
fetchChannels().then(data => {
// Map channels securely, providing a fallback block if properties are missing
const mapped = data.map(ch => ({
...ch,
currentlyPlaying: ch.currentlyPlaying || { title: 'Live Broadcast', time: 'Now Playing' }
}));
if (mapped.length > 0) {
setChannels(mapped);
} else {
setChannels([{id: 99, channel_number: '99', name: 'No Channels Found', currentlyPlaying: {title: 'Empty Database', time: '--'}}]);
}
}).catch(err => {
console.error(err);
setChannels([{id: 99, channel_number: '99', name: 'Network Error', currentlyPlaying: {title: 'Could not reach PyTV server', time: '--'}}]);
});
}, []);
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
}, 60000);
const timer = setInterval(() => setNow(Date.now()), 15000); // 15s refresh
return () => clearInterval(timer);
}, []);
// Fetch channels and airings
useEffect(() => {
fetchChannels()
.then(data => {
if (!data || data.length === 0) {
setChannels([{ id: 99, channel_number: 99, name: 'No Channels Found' }]);
return;
}
setChannels(data);
// Fetch overlapping data for timeline
Promise.allSettled(
data.map(ch =>
fetchChannelAirings(ch.id, EPG_HOURS)
.then(list => ({ channelId: ch.id, list }))
.catch(() => ({ channelId: ch.id, list: [] }))
)
).then(results => {
const map = {};
for (const r of results) {
if (r.status === 'fulfilled') {
map[r.value.channelId] = r.value.list;
}
}
setAirings(map);
});
})
.catch((err) => {
console.error("Guide fetch error:", err);
setChannels([{ id: 99, channel_number: 99, name: 'Network Error' }]);
});
}, []);
// Auto-scroll the timeline to keep the playhead visible but leave
// ~0.5 hours of padding on the left
useEffect(() => {
if (scrollRef.current) {
const msOffset = now - anchorTime.current;
const pxOffset = msOffset * PX_PER_MS;
// scroll left to (current time - 30 mins)
const targetScroll = Math.max(0, pxOffset - (HOUR_WIDTH_PX / 2));
scrollRef.current.scrollLeft = targetScroll;
}
}, [now, airings]);
const currentTimeStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const playheadPx = (now - anchorTime.current) * PX_PER_MS;
// Generate grid time slots (half-hour chunks)
const timeSlots = Array.from({ length: EPG_HOURS * 2 + 2 }).map((_, i) => {
const ts = new Date(anchorTime.current + (i * 30 * 60 * 1000));
return {
label: ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
left: i * (HOUR_WIDTH_PX / 2)
};
});
// Background pattern width
const containerWidthPx = (EPG_HOURS * HOUR_WIDTH_PX) + HOUR_WIDTH_PX;
return (
<div className="guide-container open">
<div className="guide-header">
<h1>PYTV Guide</h1>
<div className="guide-clock">{currentTime}</div>
<div className="guide-clock">{currentTimeStr}</div>
</div>
<div className="guide-grid">
{channels.length === 0 ? <p style={{color: 'white'}}>Loading TV Guide...</p> :
channels.map((chan, idx) => (
<div key={chan.id} className={`guide-row ${idx === selectedIndex ? 'active' : ''}`}>
<div className="guide-ch-col">
{chan.channel_number}
<div className="epg-wrapper">
{/* Left fixed column for Channels */}
<div className="epg-channels-col">
<div className="epg-corner"></div>
<div className="epg-grid-rows">
{channels.map((chan, idx) => (
<div key={chan.id} className={`epg-row ${idx === selectedIndex ? 'active' : ''}`}>
<div className="epg-ch-num">{chan.channel_number}</div>
<div className="epg-ch-name">{chan.name}</div>
</div>
))}
</div>
</div>
{/* Scrollable Timeline */}
<div className="epg-timeline-scroll" ref={scrollRef}>
<div
className="epg-timeline-container"
style={{ width: `${containerWidthPx}px`, backgroundSize: `${HOUR_WIDTH_PX / 2}px 100%` }}
>
{/* Time Axis Row */}
<div className="epg-time-axis">
{timeSlots.map((slot, i) => (
<div key={i} className="epg-time-slot" style={{ left: `${slot.left}px` }}>
{slot.label}
</div>
))}
</div>
<div className="guide-prog-col">
<div className="guide-prog-title">{chan.name} - {chan.currentlyPlaying.title}</div>
<div className="guide-prog-time">{chan.currentlyPlaying.time}</div>
{/* Live Playhead Line */}
{playheadPx > 0 && playheadPx < containerWidthPx && (
<div className="epg-playhead" style={{ left: `${playheadPx}px` }} />
)}
{/* Grid Rows for Airings */}
<div className="epg-grid-rows">
{channels.map((chan, idx) => {
const chanAirings = airings[chan.id];
const isLoading = !chanAirings && chan.id !== 99;
return (
<div key={chan.id} className={`epg-row ${idx === selectedIndex ? 'active' : ''}`}>
{isLoading && <div className="epg-loading">Loading...</div>}
{!isLoading && chanAirings?.length === 0 && (
<div className="epg-empty">No scheduled programs</div>
)}
{chanAirings && chanAirings.map(a => {
const sTs = new Date(a.starts_at).getTime();
const eTs = new Date(a.ends_at).getTime();
// Filter anything that ended before our timeline anchor
if (eTs <= anchorTime.current) return null;
// Calculate block dimensions
const startPx = Math.max(0, (sTs - anchorTime.current) * PX_PER_MS);
const rawEndPx = (eTs - anchorTime.current) * PX_PER_MS;
const endPx = Math.min(containerWidthPx, rawEndPx);
const widthPx = Math.max(2, endPx - startPx);
let stateClass = 'future';
if (now >= sTs && now < eTs) stateClass = 'current';
if (now >= eTs) stateClass = 'past';
return (
<div
key={a.id}
className={`epg-program ${stateClass}`}
style={{ left: `${startPx}px`, width: `${widthPx}px` }}
onClick={() => onSelectChannel(chan.id)}
>
<div className="epg-program-title">{a.media_item_title}</div>
<div className="epg-program-time">{fmtTime(a.starts_at)} - {fmtTime(a.ends_at)}</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
<div style={{ marginTop: '2rem', color: 'var(--pytv-text-dim)', textAlign: 'center' }}>
Press <span style={{color: '#fff'}}>Enter</span> to tune to the selected channel. Press <span style={{color: '#fff'}}>Escape</span> to exit guide.
<div style={{ padding: '0 3rem', flexShrink: 0, marginTop: '2rem', color: 'var(--pytv-text-dim)', textAlign: 'center' }}>
Press <span style={{ color: '#fff' }}>Enter</span> to tune &middot; <span style={{ color: '#fff' }}>&uarr;&darr;</span> to navigate channels &middot; <span style={{ color: '#fff' }}>Escape</span> to close
</div>
</div>
);

View File

@@ -0,0 +1,846 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
fetchUsers, createUser, deleteUser,
fetchChannels, createChannel, deleteChannel, updateChannel,
fetchChannelSources, assignSourceToChannel, removeSourceFromChannel,
fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday,
fetchSources, createSource, syncSource, deleteSource,
fetchLibraries,
fetchDownloadStatus, triggerCacheUpcoming, downloadItem,
} from '../api';
// ─── Constants ────────────────────────────────────────────────────────────
const TABS = [
{ id: 'channels', label: '📺 Channels' },
{ id: 'sources', label: '📡 Sources' },
{ id: 'downloads', label: '⬇ Downloads' },
{ id: 'schedule', label: '📅 Scheduling' },
{ id: 'users', label: '👤 Users' },
];
const SOURCE_TYPE_OPTIONS = [
{ value: 'youtube_channel', label: '▶ YouTube Channel' },
{ value: 'youtube_playlist', label: '▶ YouTube Playlist' },
{ value: 'local_directory', label: '📁 Local Directory' },
{ value: 'stream', label: '📡 Live Stream' },
];
const RULE_MODE_OPTIONS = [
{ value: 'allow', label: 'Allow' },
{ value: 'prefer', label: 'Prefer' },
{ value: 'avoid', label: 'Avoid' },
{ value: 'block', label: 'Block' },
];
// ─── Shared helpers ────────────────────────────────────────────────────────
function useFeedback() {
const [feedback, setFeedback] = useState(null);
const ok = (text) => setFeedback({ kind: 'success', text });
const err = (text) => setFeedback({ kind: 'error', text });
return [feedback, setFeedback, ok, err];
}
function Feedback({ fb, clear }) {
if (!fb) return null;
return (
<div className={`settings-feedback ${fb.kind}`}>
<span>{fb.text}</span>
<button onClick={clear}></button>
</div>
);
}
function EmptyState({ text }) {
return <p className="settings-empty">{text}</p>;
}
function IconBtn({ icon, label, kind = 'default', onClick, disabled, title }) {
return (
<button
onClick={onClick}
disabled={disabled}
title={title || label}
className={`icon-btn icon-btn-${kind}`}
>
{icon}
</button>
);
}
// ─── Users Tab ────────────────────────────────────────────────────────────
function UsersTab() {
const [users, setUsers] = useState([]);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ username: '', email: '', password: '', is_superuser: false });
const [feedback, setFeedback, ok, err] = useFeedback();
useEffect(() => { fetchUsers().then(setUsers).catch(() => err('Failed to load users')); }, []);
const handleCreate = async (e) => {
e.preventDefault();
try {
const user = await createUser(form);
setUsers(u => [...u, user]);
setForm({ username: '', email: '', password: '', is_superuser: false });
setShowForm(false);
ok(`User "${user.username}" created.`);
} catch { err('Failed to create user.'); }
};
const handleDelete = async (user) => {
if (!confirm(`Delete user "${user.username}"?`)) return;
try {
await deleteUser(user.id);
setUsers(u => u.filter(x => x.id !== user.id));
ok(`User "${user.username}" deleted.`);
} catch { err('Failed to delete user.'); }
};
return (
<div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} />
<div className="settings-section-title">
<h3>Users</h3>
<button className="btn-accent" onClick={() => setShowForm(f => !f)}>
{showForm ? '— Cancel' : '+ Add User'}
</button>
</div>
{showForm && (
<form className="settings-form" onSubmit={handleCreate}>
<label>Username<input required value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))} /></label>
<label>Email<input type="email" required value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} /></label>
<label>Password<input type="password" required value={form.password} onChange={e => setForm(f => ({ ...f, password: e.target.value }))} /></label>
<label className="checkbox-label">
<input type="checkbox" checked={form.is_superuser} onChange={e => setForm(f => ({ ...f, is_superuser: e.target.checked }))} />
Admin (superuser)
</label>
<button type="submit" className="btn-accent">Create User</button>
</form>
)}
<div className="settings-row-list">
{users.length === 0 && <EmptyState text="No users found." />}
{users.map(u => (
<div key={u.id} className="settings-row">
<div className="row-avatar">{u.username[0].toUpperCase()}</div>
<div className="row-info">
<strong>{u.username}</strong>
<span className="row-sub">{u.email}</span>
<span className="row-badges">
{u.is_superuser && <span className="badge badge-accent">Admin</span>}
{!u.is_active && <span className="badge badge-muted">Disabled</span>}
</span>
</div>
<div className="row-actions">
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(u)} title="Delete user" />
</div>
</div>
))}
</div>
</div>
);
}
// ─── Channels Tab ─────────────────────────────────────────────────────────
function ChannelsTab() {
const [channels, setChannels] = useState([]);
const [sources, setSources] = useState([]);
const [libraries, setLibraries] = useState([]);
const [users, setUsers] = useState([]);
const [expandedId, setExpandedId] = useState(null);
const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] }
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '' });
const [assignForm, setAssignForm] = useState({ source_id: '', rule_mode: 'allow', weight: 1.0 });
const [syncingId, setSyncingId] = useState(null);
const [feedback, setFeedback, ok, err] = useFeedback();
useEffect(() => {
Promise.all([fetchChannels(), fetchSources(), fetchLibraries(), fetchUsers()])
.then(([c, s, l, u]) => { setChannels(c); setSources(s); setLibraries(l); setUsers(u); })
.catch(() => err('Failed to load channels'));
}, []);
const toggleExpand = async (ch) => {
const next = expandedId === ch.id ? null : ch.id;
setExpandedId(next);
if (next && !channelSources[next]) {
try {
const rules = await fetchChannelSources(ch.id);
setChannelSources(cs => ({ ...cs, [ch.id]: rules }));
} catch { err('Failed to load channel sources'); }
}
};
const handleCreate = async (e) => {
e.preventDefault();
try {
const ch = await createChannel({
...form,
channel_number: form.channel_number ? parseInt(form.channel_number) : undefined,
library_id: parseInt(form.library_id),
owner_user_id: parseInt(form.owner_user_id),
});
setChannels(c => [...c, ch]);
setShowForm(false);
setForm({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '' });
ok(`Channel "${ch.name}" created.`);
} catch { err('Failed to create channel. Check slug is unique.'); }
};
const handleDelete = async (ch) => {
if (!confirm(`Delete channel "${ch.name}"?`)) return;
try {
await deleteChannel(ch.id);
setChannels(c => c.filter(x => x.id !== ch.id));
if (expandedId === ch.id) setExpandedId(null);
ok(`Channel "${ch.name}" deleted.`);
} catch { err('Failed to delete channel.'); }
};
const handleAssign = async (channelId) => {
if (!assignForm.source_id) { err('Select a source first.'); return; }
try {
const rule = await assignSourceToChannel(channelId, {
source_id: parseInt(assignForm.source_id),
rule_mode: assignForm.rule_mode,
weight: parseFloat(assignForm.weight),
});
setChannelSources(cs => ({ ...cs, [channelId]: [...(cs[channelId] || []), rule] }));
setAssignForm({ source_id: '', rule_mode: 'allow', weight: 1.0 });
ok('Source assigned to channel.');
} catch { err('Failed to assign source.'); }
};
const handleRemoveRule = async (channelId, ruleId) => {
try {
await removeSourceFromChannel(channelId, ruleId);
setChannelSources(cs => ({ ...cs, [channelId]: cs[channelId].filter(r => r.id !== ruleId) }));
ok('Source removed from channel.');
} catch { err('Failed to remove source.'); }
};
const handleGenerateToday = async (ch) => {
setSyncingId(ch.id);
try {
const result = await generateScheduleToday(ch.id);
ok(`Schedule generated for "${ch.name}": ${result.airings_created} airings created.`);
} catch { err('Failed to generate schedule.'); }
finally { setSyncingId(null); }
};
return (
<div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} />
<div className="settings-section-title">
<h3>Channels</h3>
<button className="btn-accent" onClick={() => setShowForm(f => !f)}>
{showForm ? '— Cancel' : '+ Add Channel'}
</button>
</div>
{showForm && (
<form className="settings-form" onSubmit={handleCreate}>
<div className="form-row">
<label>Name<input required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></label>
<label>Slug<input required placeholder="unique-slug" value={form.slug} onChange={e => setForm(f => ({ ...f, slug: e.target.value }))} /></label>
</div>
<div className="form-row">
<label>Channel #<input type="number" min="1" value={form.channel_number} onChange={e => setForm(f => ({ ...f, channel_number: e.target.value }))} /></label>
<label>Library
<select required value={form.library_id} onChange={e => setForm(f => ({ ...f, library_id: e.target.value }))}>
<option value=""> select </option>
{libraries.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label>Owner
<select required value={form.owner_user_id} onChange={e => setForm(f => ({ ...f, owner_user_id: e.target.value }))}>
<option value=""> select </option>
{users.map(u => <option key={u.id} value={u.id}>{u.username}</option>)}
</select>
</label>
</div>
<label>Description<input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></label>
<button type="submit" className="btn-accent">Create Channel</button>
</form>
)}
<div className="settings-row-list">
{channels.length === 0 && <EmptyState text="No channels configured." />}
{channels.map(ch => {
const isExpanded = expandedId === ch.id;
const rules = channelSources[ch.id] || [];
const hasRules = rules.length > 0;
return (
<div key={ch.id} className={`settings-row-expandable ${isExpanded ? 'expanded' : ''}`}>
{/* Main row */}
<div className="settings-row" onClick={() => toggleExpand(ch)}>
<div className="row-avatar ch-num">{ch.channel_number ?? '?'}</div>
<div className="row-info">
<strong>{ch.name}</strong>
<span className="row-sub">{ch.slug} · {ch.scheduling_mode}</span>
<span className="row-badges">
{!hasRules && isExpanded === false && channelSources[ch.id] !== undefined && (
<span className="badge badge-warn" style={{ marginLeft: '0.2rem' }}> Fallback Library Mode</span>
)}
</span>
</div>
<div className="row-actions" onClick={e => e.stopPropagation()}>
<button
className="btn-sync"
onClick={() => handleGenerateToday(ch)}
disabled={syncingId === ch.id}
title="Generate today's schedule"
>
{syncingId === ch.id ? '...' : '▶ Schedule'}
</button>
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(ch)} />
<span className="expand-chevron">{isExpanded ? '▲' : '▼'}</span>
</div>
</div>
{/* Expanded: source assignment panel */}
{isExpanded && (
<div className="channel-expand-panel">
<h4 className="expand-section-title">Assigned Sources</h4>
{!hasRules && (
<div className="settings-feedback error" style={{ marginBottom: '1rem', display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<strong> No sources assigned.</strong>
<span style={{ fontSize: '0.9rem' }}>The scheduler will fall back to using ALL items in the library. This is usually not what you want. Please assign a specific source below.</span>
</div>
)}
{rules.length > 0 && rules.map(r => (
<div key={r.id} className="rule-row">
<span className="rule-source">{r.source_name}</span>
<span className={`rule-mode badge badge-mode-${r.rule_mode}`}>{r.rule_mode}</span>
<span className="rule-weight">×{r.weight}</span>
<IconBtn icon="✕" kind="danger" onClick={() => handleRemoveRule(ch.id, r.id)} title="Remove assignment" />
</div>
))}
{/* Assign form */}
<div className="assign-form" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<select
value={assignForm.source_id}
onChange={e => setAssignForm(f => ({ ...f, source_id: e.target.value }))}
style={{ flex: 1 }}
>
<option value=""> Select source </option>
{sources.map(s => <option key={s.id} value={s.id}>{s.name} ({s.source_type})</option>)}
</select>
<button
className="btn-accent"
onClick={() => {
if (!assignForm.source_id) { err('Select a source first.'); return; }
setAssignForm(f => ({ ...f, rule_mode: 'prefer', weight: 10.0 }));
// We wait for re-render state update before submit
setTimeout(() => handleAssign(ch.id), 0);
}}
title="Quick add as heavily preferred source to ensure it dominates schedule"
>
Set as Primary Source
</button>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', opacity: 0.8, fontSize: '0.9rem' }}>
<span>Or custom rule:</span>
<select
value={assignForm.rule_mode}
onChange={e => setAssignForm(f => ({ ...f, rule_mode: e.target.value }))}
>
{RULE_MODE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<input
type="number" min="0.1" max="10" step="0.1"
value={assignForm.weight}
onChange={e => setAssignForm(f => ({ ...f, weight: e.target.value }))}
style={{ width: 60 }}
title="Weight (higher = more airings)"
/>
<button className="btn-sync sm" onClick={() => handleAssign(ch.id)}>+ Add Rule</button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
// ─── Sources Tab ──────────────────────────────────────────────────────────
function SourcesTab() {
const [sources, setSources] = useState([]);
const [libraries, setLibraries] = useState([]);
const [loading, setLoading] = useState(true);
const [syncingId, setSyncingId] = useState(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50 });
const [feedback, setFeedback, ok, err] = useFeedback();
useEffect(() => {
Promise.all([fetchSources(), fetchLibraries()])
.then(([s, l]) => { setSources(s); setLibraries(l); setLoading(false); })
.catch(() => { err('Failed to load sources'); setLoading(false); });
}, []);
const handleCreate = async (e) => {
e.preventDefault();
if (!form.library_id) { err('Please select a library.'); return; }
try {
const src = await createSource({ ...form, library_id: parseInt(form.library_id) });
setSources(s => [...s, src]);
setShowForm(false);
setForm({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50 });
ok(`Source "${src.name}" registered. Hit Sync to import videos.`);
} catch { err('Failed to create source.'); }
};
const handleSync = async (src) => {
setSyncingId(src.id);
setFeedback(null);
try {
const result = await syncSource(src.id, src.source_type.startsWith('youtube') ? form.max_videos || 50 : undefined);
setSources(s => s.map(x => x.id === src.id ? { ...x, last_scanned_at: new Date().toISOString() } : x));
ok(`Sync done: ${result.created} new, ${result.updated} updated, ${result.skipped} skipped.`);
} catch (e) {
err(`Sync failed: ${e?.response?.data?.detail || e.message}`);
} finally { setSyncingId(null); }
};
const handleDelete = async (src) => {
if (!confirm(`Delete "${src.name}"? This removes all its media items.`)) return;
try {
await deleteSource(src.id);
setSources(s => s.filter(x => x.id !== src.id));
ok(`Deleted "${src.name}".`);
} catch { err('Failed to delete source.'); }
};
const isYT = (src) => src.source_type.startsWith('youtube');
return (
<div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} />
<div className="settings-section-title">
<h3>Media Sources</h3>
<button className="btn-accent" onClick={() => setShowForm(f => !f)}>
{showForm ? '— Cancel' : '+ Add Source'}
</button>
</div>
{showForm && (
<form className="settings-form" onSubmit={handleCreate}>
<div className="form-row">
<label>Name<input required placeholder="ABC News" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></label>
<label>Type
<select value={form.source_type} onChange={e => setForm(f => ({ ...f, source_type: e.target.value }))}>
{SOURCE_TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
</div>
<label>URL / Path
<input required
placeholder={form.source_type.startsWith('youtube') ? 'https://www.youtube.com/@Channel' : '/mnt/media/movies'}
value={form.uri}
onChange={e => setForm(f => ({ ...f, uri: e.target.value }))}
/>
</label>
<div className="form-row">
<label>Library
<select required value={form.library_id} onChange={e => setForm(f => ({ ...f, library_id: e.target.value }))}>
<option value=""> select </option>
{libraries.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
{form.source_type.startsWith('youtube') && (
<label>Max Videos (sync cap)
<input type="number" min="1" max="5000" value={form.max_videos}
onChange={e => setForm(f => ({ ...f, max_videos: e.target.value }))} />
</label>
)}
</div>
<button type="submit" className="btn-accent">Register Source</button>
</form>
)}
<div className="settings-row-list">
{loading && <p className="settings-loading">Loading</p>}
{!loading && sources.length === 0 && <EmptyState text="No sources configured. Add one above." />}
{sources.map(src => {
const isSyncing = syncingId === src.id;
const synced = src.last_scanned_at;
return (
<div key={src.id} className="settings-row">
<div className="row-avatar">{isYT(src) ? '▶' : '📁'}</div>
<div className="row-info">
<strong>{src.name}</strong>
<span className="row-sub">{src.uri}</span>
<span className="row-badges">
<span className="badge badge-type">{src.source_type.replace('_', ' ')}</span>
{synced
? <span className="badge badge-ok">Synced {new Date(synced).toLocaleDateString()}</span>
: <span className="badge badge-warn">Not synced</span>
}
</span>
</div>
<div className="row-actions">
{isYT(src) && (
<button className="btn-sync" onClick={() => handleSync(src)} disabled={isSyncing}>
{isSyncing ? '…' : '↻ Sync'}
</button>
)}
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(src)} />
</div>
</div>
);
})}
</div>
<p className="settings-hint">
After syncing, assign the source to a channel in the <strong>Channels</strong> tab.
Downloads happen automatically via <code>manage.py cache_upcoming</code>.
</p>
</div>
);
}
// ─── Downloads Tab ───────────────────────────────────────────────────────
function DownloadsTab() {
const [status, setStatus] = useState(null); // { total, cached, items }
const [loading, setLoading] = useState(true);
const [hours, setHours] = useState(24);
const [running, setRunning] = useState(false);
const [runResult, setRunResult] = useState(null);
const [downloadingId, setDownloadingId] = useState(null);
const [filterSource, setFilterSource] = useState('all');
const [feedback, setFeedback, ok, err] = useFeedback();
const load = async () => {
setLoading(true);
try { setStatus(await fetchDownloadStatus()); }
catch { err('Failed to load download status.'); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const handleBulkDownload = async () => {
setRunning(true);
setRunResult(null);
setFeedback(null);
try {
const result = await triggerCacheUpcoming(hours);
setRunResult(result);
ok(`Done — ${result.downloaded} downloaded, ${result.already_cached} already cached, ${result.failed} failed.`);
await load(); // refresh the item list
} catch (e) {
err(`Failed: ${e?.response?.data?.detail || e.message}`);
} finally { setRunning(false); }
};
const handleDownloadOne = async (item) => {
setDownloadingId(item.id);
try {
await downloadItem(item.id);
ok(`Downloaded "${item.title.slice(0, 50)}".`);
setStatus(s => ({
...s,
cached: s.cached + 1,
items: s.items.map(i => i.id === item.id ? { ...i, cached: true } : i),
}));
} catch (e) {
err(`Download failed: ${e?.response?.data?.detail || e.message}`);
} finally { setDownloadingId(null); }
};
const sources = status ? [...new Set(status.items.map(i => i.source_name))] : [];
const visibleItems = status
? (filterSource === 'all' ? status.items : status.items.filter(i => i.source_name === filterSource))
: [];
const pct = status && status.total > 0 ? Math.round((status.cached / status.total) * 100) : 0;
return (
<div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} />
{/* Stats header */}
<div className="download-stats-header">
<div className="download-stat">
<span className="stat-val">{status?.total ?? '—'}</span>
<span className="stat-label">Total videos</span>
</div>
<div className="download-stat">
<span className="stat-val" style={{ color: '#86efac' }}>{status?.cached ?? '—'}</span>
<span className="stat-label">Cached locally</span>
</div>
<div className="download-stat">
<span className="stat-val" style={{ color: '#fcd34d' }}>{status ? status.total - status.cached : '—'}</span>
<span className="stat-label">Not downloaded</span>
</div>
<div className="download-cache-bar">
<div className="cache-bar-fill" style={{ width: `${pct}%` }} />
<span className="cache-bar-label">{pct}% cached</span>
</div>
</div>
{/* Bulk trigger */}
<div className="download-trigger-row">
<label className="trigger-hours-label">
Download window
<select value={hours} onChange={e => setHours(Number(e.target.value))}>
<option value={6}>Next 6 hours</option>
<option value={12}>Next 12 hours</option>
<option value={24}>Next 24 hours</option>
<option value={48}>Next 48 hours</option>
<option value={72}>Next 72 hours</option>
</select>
</label>
<button className="btn-accent" onClick={handleBulkDownload} disabled={running}>
{running ? '⏳ Downloading…' : '⬇ Download Upcoming'}
</button>
<button className="btn-sync" onClick={load} disabled={loading} title="Refresh status">
</button>
</div>
{runResult && (
<div className="run-result-box">
<span className="rr-item rr-ok"> {runResult.downloaded} downloaded</span>
<span className="rr-item rr-cached"> {runResult.already_cached} already cached</span>
<span className="rr-item rr-pruned">🗑 {runResult.pruned} pruned</span>
{runResult.failed > 0 && <span className="rr-item rr-fail"> {runResult.failed} failed</span>}
</div>
)}
{/* Filter by source */}
{sources.length > 1 && (
<div className="download-filter-row">
<span className="filter-label">Filter by source:</span>
<select value={filterSource} onChange={e => setFilterSource(e.target.value)}>
<option value="all">All sources</option>
{sources.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
)}
{/* Item list */}
<div className="settings-row-list">
{loading && <p className="settings-loading">Loading</p>}
{!loading && visibleItems.length === 0 && (
<EmptyState text="No YouTube videos found. Sync a source first." />
)}
{visibleItems.map(item => (
<div key={item.id} className={`settings-row download-item-row ${item.cached ? 'is-cached' : ''}`}>
<div className="row-avatar" style={{ fontSize: '0.8rem', background: item.cached ? 'rgba(34,197,94,0.12)' : 'rgba(255,255,255,0.05)', borderColor: item.cached ? 'rgba(34,197,94,0.3)' : 'var(--pytv-glass-border)', color: item.cached ? '#86efac' : 'var(--pytv-text-dim)' }}>
{item.cached ? '✓' : '▶'}
</div>
<div className="row-info">
<strong>{item.title}</strong>
<span className="row-sub">{item.source_name} · {item.runtime_seconds}s</span>
<span className="row-badges">
{item.cached
? <span className="badge badge-ok">Cached</span>
: <span className="badge badge-warn">Not downloaded</span>
}
<span className="badge badge-muted">{item.youtube_video_id}</span>
</span>
</div>
<div className="row-actions">
{!item.cached && (
<button
className="btn-sync"
onClick={() => handleDownloadOne(item)}
disabled={downloadingId === item.id}
title="Download this video now"
>
{downloadingId === item.id ? '…' : '⬇'}
</button>
)}
</div>
</div>
))}
</div>
</div>
);
}
// ─── Scheduling Tab ───────────────────────────────────────────────────────
function SchedulingTab() {
const [templates, setTemplates] = useState([]);
const [channels, setChannels] = useState([]);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ name: '', channel_id: '', timezone_name: 'America/New_York', priority: 10 });
const [feedback, setFeedback, ok, err] = useFeedback();
useEffect(() => {
Promise.all([fetchTemplates(), fetchChannels()])
.then(([t, c]) => { setTemplates(t); setChannels(c); })
.catch(() => err('Failed to load schedule data'));
}, []);
const handleCreate = async (e) => {
e.preventDefault();
try {
const tmpl = await createTemplate({
...form,
channel_id: parseInt(form.channel_id),
priority: parseInt(form.priority),
is_active: true,
});
setTemplates(t => [...t, tmpl]);
setShowForm(false);
setForm({ name: '', channel_id: '', timezone_name: 'America/New_York', priority: 10 });
ok(`Template "${tmpl.name}" created.`);
} catch { err('Failed to create template.'); }
};
const handleDelete = async (tmpl) => {
if (!confirm(`Delete template "${tmpl.name}"?`)) return;
try {
await deleteTemplate(tmpl.id);
setTemplates(t => t.filter(x => x.id !== tmpl.id));
ok(`Template deleted.`);
} catch { err('Failed to delete template.'); }
};
const handleGenerateAll = async () => {
const chIds = [...new Set(templates.map(t => t.channel_id))];
let total = 0;
for (const id of chIds) {
try { const r = await generateScheduleToday(id); total += r.airings_created; }
catch {}
}
ok(`Generated today's schedule: ${total} total airings created.`);
};
const channelName = (id) => channels.find(c => c.id === id)?.name ?? `Channel ${id}`;
return (
<div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} />
<div className="settings-section-title">
<h3>Schedule Templates</h3>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn-sync" onClick={handleGenerateAll}> Generate All Today</button>
<button className="btn-accent" onClick={() => setShowForm(f => !f)}>
{showForm ? '— Cancel' : '+ New Template'}
</button>
</div>
</div>
{showForm && (
<form className="settings-form" onSubmit={handleCreate}>
<div className="form-row">
<label>Template Name<input required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></label>
<label>Channel
<select required value={form.channel_id} onChange={e => setForm(f => ({ ...f, channel_id: e.target.value }))}>
<option value=""> select </option>
{channels.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</label>
</div>
<div className="form-row">
<label>Timezone
<input value={form.timezone_name} onChange={e => setForm(f => ({ ...f, timezone_name: e.target.value }))} />
</label>
<label>Priority (higher = wins)
<input type="number" min="0" max="100" value={form.priority}
onChange={e => setForm(f => ({ ...f, priority: e.target.value }))} />
</label>
</div>
<button type="submit" className="btn-accent">Create Template</button>
</form>
)}
<div className="settings-hint schedule-hint">
<strong>How scheduling works:</strong> Each channel can have multiple schedule templates.
Templates are tried in priority order. The scheduler fills the day by picking random
media items from the channel's assigned sources. <code>▶ Generate All Today</code> runs
this for every channel right now.
</div>
<div className="settings-row-list">
{templates.length === 0 && <EmptyState text="No schedule templates yet. Create one above." />}
{templates.map(t => (
<div key={t.id} className="settings-row">
<div className="row-avatar" style={{ fontSize: '1.2rem' }}>📄</div>
<div className="row-info">
<strong>{t.name}</strong>
<span className="row-sub">{channelName(t.channel_id)} · {t.timezone_name}</span>
<span className="row-badges">
<span className="badge badge-type">Priority {t.priority}</span>
{t.is_active
? <span className="badge badge-ok">Active</span>
: <span className="badge badge-muted">Inactive</span>
}
</span>
</div>
<div className="row-actions">
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(t)} />
</div>
</div>
))}
</div>
</div>
);
}
// ─── Root Settings Component ──────────────────────────────────────────────
export default function Settings({ onClose }) {
const [activeTab, setActiveTab] = useState('channels');
return (
<div className="settings-overlay">
<div className="settings-panel">
{/* Header */}
<div className="settings-header">
<span className="settings-logo">PYTV</span>
<h2>Settings</h2>
<button className="settings-close-btn" onClick={onClose}>✕ Close</button>
</div>
{/* Tab Bar */}
<div className="settings-tab-bar">
{TABS.map(tab => (
<button
key={tab.id}
className={`settings-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div className="tab-content-wrapper">
{activeTab === 'channels' && <ChannelsTab />}
{activeTab === 'sources' && <SourcesTab />}
{activeTab === 'downloads' && <DownloadsTab />}
{activeTab === 'schedule' && <SchedulingTab />}
{activeTab === 'users' && <UsersTab />}
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,11 @@ export default defineConfig({
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
},
'/media': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/media/, '')
}
}
}

View File

@@ -15,4 +15,5 @@ dependencies = [
"pytest-cov>=7.0.0",
"pytest-django>=4.12.0",
"pytest-sugar>=1.1.1",
"yt-dlp>=2026.3.3",
]

View File

@@ -128,3 +128,6 @@ AUTH_USER_MODEL = 'core.AppUser'
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = "static/"
# YouTube video cache directory (used by core.services.youtube and cache_upcoming command)
MEDIA_ROOT = env('MEDIA_ROOT', default='/tmp/pytv_cache')

View File

@@ -17,9 +17,14 @@ Including another URLconf
from django.contrib import admin
from django.urls import path
from django.conf import settings
from django.conf.urls.static import static
from api.api import api
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", api.urls),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

11
uv.lock generated
View File

@@ -345,6 +345,7 @@ dependencies = [
{ name = "pytest-cov" },
{ name = "pytest-django" },
{ name = "pytest-sugar" },
{ name = "yt-dlp" },
]
[package.metadata]
@@ -359,6 +360,7 @@ requires-dist = [
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "pytest-django", specifier = ">=4.12.0" },
{ name = "pytest-sugar", specifier = ">=1.1.1" },
{ name = "yt-dlp", specifier = ">=2026.3.3" },
]
[[package]]
@@ -408,3 +410,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf3
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]
name = "yt-dlp"
version = "2026.3.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/66/6f/7427d23609353e5ef3470ff43ef551b8bd7b166dd4fef48957f0d0e040fe/yt_dlp-2026.3.3.tar.gz", hash = "sha256:3db7969e3a8964dc786bdebcffa2653f31123bf2a630f04a17bdafb7bbd39952", size = 3118658, upload-time = "2026-03-03T16:54:53.909Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/a4/8b5cd28ab87aef48ef15e74241befec3445496327db028f34147a9e0f14f/yt_dlp-2026.3.3-py3-none-any.whl", hash = "sha256:166c6e68c49ba526474bd400e0129f58aa522c2896204aa73be669c3d2f15e63", size = 3315599, upload-time = "2026-03-03T16:54:51.899Z" },
]