feat(main): commit
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
178
api/routers/sources.py
Normal 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}")
|
||||
@@ -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
|
||||
|
||||
@@ -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
292
api/tests/test_sources.py
Normal 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
|
||||
52
core/management/commands/cache_upcoming.py
Normal file
52
core/management/commands/cache_upcoming.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
45
core/migrations/0002_mediaitem_cache_expires_at_and_more.py
Normal file
45
core/migrations/0002_mediaitem_cache_expires_at_and_more.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
140
core/services/cache.py
Normal 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"])}
|
||||
@@ -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:00–02: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
244
core/services/youtube.py
Normal 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
|
||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`); };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 · <span style={{ color: '#fff' }}>↑↓</span> to navigate channels · <span style={{ color: '#fff' }}>Escape</span> to close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
846
frontend/src/components/Settings.jsx
Normal file
846
frontend/src/components/Settings.jsx
Normal 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
@@ -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/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
11
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user