feat(main): main

This commit is contained in:
2026-03-09 08:26:45 -04:00
parent f37382d2b8
commit f14454b4c8
12 changed files with 598 additions and 62 deletions

View File

@@ -39,11 +39,13 @@ class ChannelSourceRuleSchema(Schema):
source_name: str source_name: str
rule_mode: str rule_mode: str
weight: float weight: float
schedule_block_label: Optional[str] = None
class ChannelSourceAssignSchema(Schema): class ChannelSourceAssignSchema(Schema):
source_id: int source_id: int
rule_mode: str = 'allow' # allow | prefer | avoid | block rule_mode: str = 'allow' # allow | prefer | avoid | block
weight: float = 1.0 weight: float = 1.0
schedule_block_label: Optional[str] = None
class AiringSchema(Schema): class AiringSchema(Schema):
id: int id: int
@@ -58,7 +60,21 @@ class AiringSchema(Schema):
def from_airing(airing) -> 'AiringSchema': def from_airing(airing) -> 'AiringSchema':
media_path = None media_path = None
if airing.media_item: if airing.media_item:
raw_path = airing.media_item.cached_file_path or airing.media_item.file_path item = airing.media_item
# 1. Determine if this item is from a YouTube source
is_youtube = False
if item.media_source and item.media_source.source_type in ['youtube', 'youtube_channel', 'youtube_playlist']:
is_youtube = True
# 2. Strict signaling: If YouTube, we MUST have it downloaded
if is_youtube:
raw_path = item.cached_file_path
# If cached_file_path is None, raw_path is None, and media_path remains None
else:
# Fallback for generic local files/links
raw_path = item.cached_file_path or item.file_path
if raw_path: if raw_path:
if raw_path.startswith("http://") or raw_path.startswith("https://"): if raw_path.startswith("http://") or raw_path.startswith("https://"):
media_path = raw_path media_path = raw_path
@@ -133,6 +149,7 @@ def list_channel_sources(request, channel_id: int):
source_name=r.media_source.name, source_name=r.media_source.name,
rule_mode=r.rule_mode, rule_mode=r.rule_mode,
weight=float(r.weight), weight=float(r.weight),
schedule_block_label=r.schedule_block_label,
) )
for r in rules for r in rules
] ]
@@ -146,6 +163,7 @@ def assign_source_to_channel(request, channel_id: int, payload: ChannelSourceAss
media_source=source, media_source=source,
rule_mode=payload.rule_mode, rule_mode=payload.rule_mode,
weight=payload.weight, weight=payload.weight,
schedule_block_label=payload.schedule_block_label,
) )
return 201, ChannelSourceRuleSchema( return 201, ChannelSourceRuleSchema(
id=rule.id, id=rule.id,
@@ -153,6 +171,7 @@ def assign_source_to_channel(request, channel_id: int, payload: ChannelSourceAss
source_name=source.name, source_name=source.name,
rule_mode=rule.rule_mode, rule_mode=rule.rule_mode,
weight=float(rule.weight), weight=float(rule.weight),
schedule_block_label=rule.schedule_block_label,
) )
@router.delete("/{channel_id}/sources/{rule_id}", response={204: None}) @router.delete("/{channel_id}/sources/{rule_id}", response={204: None})

View File

@@ -1,5 +1,6 @@
from ninja import Router, Schema from ninja import Router, Schema
from typing import List, Optional from typing import List, Optional
from datetime import date, time
from core.models import ScheduleTemplate, Channel, ScheduleBlock from core.models import ScheduleTemplate, Channel, ScheduleBlock
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from datetime import date from datetime import date
@@ -27,6 +28,28 @@ class ScheduleTemplateCreateSchema(Schema):
is_active: bool = True is_active: bool = True
channel_id: int channel_id: int
class ScheduleBlockSchema(Schema):
id: int
schedule_template_id: int
name: str
block_type: str
start_local_time: time
end_local_time: time
day_of_week_mask: int
spills_past_midnight: bool
target_content_rating: Optional[int] = None
default_genre_id: Optional[int] = None
class ScheduleBlockCreateSchema(Schema):
schedule_template_id: int
name: str
block_type: str
start_local_time: time
end_local_time: time
day_of_week_mask: int
spills_past_midnight: bool = False
target_content_rating: Optional[int] = None
@router.get("/template/", response=List[ScheduleTemplateSchema]) @router.get("/template/", response=List[ScheduleTemplateSchema])
def list_schedule_templates(request): def list_schedule_templates(request):
return ScheduleTemplate.objects.all() return ScheduleTemplate.objects.all()
@@ -92,3 +115,29 @@ def generate_schedule_today(request, channel_id: int):
generator = ScheduleGenerator(channel=channel) generator = ScheduleGenerator(channel=channel)
airings_created = generator.generate_for_date(date.today()) airings_created = generator.generate_for_date(date.today())
return {"status": "success", "airings_created": airings_created} return {"status": "success", "airings_created": airings_created}
@router.get("/template/{template_id}/blocks", response=List[ScheduleBlockSchema])
def list_schedule_blocks(request, template_id: int):
template = get_object_or_404(ScheduleTemplate, id=template_id)
return template.scheduleblock_set.all().order_by('start_local_time')
@router.post("/block/", response={201: ScheduleBlockSchema})
def create_schedule_block(request, payload: ScheduleBlockCreateSchema):
template = get_object_or_404(ScheduleTemplate, id=payload.schedule_template_id)
block = ScheduleBlock.objects.create(
schedule_template=template,
name=payload.name,
block_type=payload.block_type,
start_local_time=payload.start_local_time,
end_local_time=payload.end_local_time,
day_of_week_mask=payload.day_of_week_mask,
spills_past_midnight=payload.spills_past_midnight,
target_content_rating=payload.target_content_rating,
)
return 201, block
@router.delete("/block/{block_id}", response={204: None})
def delete_schedule_block(request, block_id: int):
block = get_object_or_404(ScheduleBlock, id=block_id)
block.delete()
return 204, None

View File

@@ -0,0 +1,56 @@
"""
management command: run_cache_worker
Runs continuously in the background to automatically download and cache
upcoming programming for the next 24 hours. Intended to run as a daemon
or Docker service.
"""
import time
import logging
from django.core.management.base import BaseCommand
from core.services.cache import run_cache
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Run the 24-hour ahead cache worker continuously in the background."
def add_arguments(self, parser):
parser.add_argument(
"--interval",
type=int,
default=600,
help="Interval in seconds between cache runs (default: 600s/10m).",
)
parser.add_argument(
"--hours",
type=int,
default=24,
help="How many hours ahead to scan for upcoming airings (default: 24).",
)
def handle(self, *args, **options):
interval = options["interval"]
hours = options["hours"]
self.stdout.write(self.style.SUCCESS(f"Starting continuous cache worker (interval: {interval}s, ahead: {hours}h)"))
while True:
try:
self.stdout.write(f"▶ Running background cache worker (window: {hours}h)")
result = run_cache(hours=hours, prune_only=False)
if result["downloaded"] > 0 or result["pruned"] > 0 or result["failed"] > 0:
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']}"))
except Exception as e:
self.stderr.write(self.style.ERROR(f"Error in cache worker loop: {e}"))
logger.error(f"Error in cache worker loop: {e}")
# Sleep until next interval
time.sleep(interval)

View File

@@ -0,0 +1,34 @@
# Generated by Django 6.0.3 on 2026-03-08 22:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0002_mediaitem_cache_expires_at_and_more"),
]
operations = [
migrations.AddField(
model_name="channelsourcerule",
name="schedule_block_label",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="scheduleblock",
name="target_content_rating",
field=models.IntegerField(
blank=True,
choices=[
(1, "TV-Y / All Children"),
(2, "TV-Y7 / Directed to Older Children"),
(3, "TV-G / General Audience"),
(4, "TV-PG / Parental Guidance Suggested"),
(5, "TV-14 / Parents Strongly Cautioned"),
(6, "TV-MA / Mature Audience Only"),
],
null=True,
),
),
]

View File

@@ -255,6 +255,7 @@ class ChannelSourceRule(models.Model):
channel = models.ForeignKey(Channel, on_delete=models.CASCADE) channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
media_source = models.ForeignKey(MediaSource, on_delete=models.CASCADE, blank=True, null=True) media_source = models.ForeignKey(MediaSource, on_delete=models.CASCADE, blank=True, null=True)
media_collection = models.ForeignKey(MediaCollection, on_delete=models.CASCADE, blank=True, null=True) media_collection = models.ForeignKey(MediaCollection, on_delete=models.CASCADE, blank=True, null=True)
schedule_block_label = models.CharField(max_length=255, blank=True, null=True)
class RuleMode(models.TextChoices): class RuleMode(models.TextChoices):
ALLOW = 'allow', 'Allow' ALLOW = 'allow', 'Allow'
@@ -347,6 +348,16 @@ class ScheduleBlock(models.Model):
end_local_time = models.TimeField() end_local_time = models.TimeField()
day_of_week_mask = models.SmallIntegerField() # 1 to 127 day_of_week_mask = models.SmallIntegerField() # 1 to 127
spills_past_midnight = models.BooleanField(default=False) spills_past_midnight = models.BooleanField(default=False)
class TargetRating(models.IntegerChoices):
TV_Y = 1, 'TV-Y / All Children'
TV_Y7 = 2, 'TV-Y7 / Directed to Older Children'
TV_G = 3, 'TV-G / General Audience'
TV_PG = 4, 'TV-PG / Parental Guidance Suggested'
TV_14 = 5, 'TV-14 / Parents Strongly Cautioned'
TV_MA = 6, 'TV-MA / Mature Audience Only'
target_content_rating = models.IntegerField(choices=TargetRating.choices, blank=True, null=True)
default_genre = models.ForeignKey(Genre, on_delete=models.SET_NULL, blank=True, null=True) default_genre = models.ForeignKey(Genre, on_delete=models.SET_NULL, blank=True, null=True)
min_content_rating = models.ForeignKey(ContentRating, on_delete=models.SET_NULL, blank=True, null=True, related_name='+') min_content_rating = models.ForeignKey(ContentRating, on_delete=models.SET_NULL, blank=True, null=True, related_name='+')
max_content_rating = models.ForeignKey(ContentRating, on_delete=models.SET_NULL, blank=True, null=True, related_name='+') max_content_rating = models.ForeignKey(ContentRating, on_delete=models.SET_NULL, blank=True, null=True, related_name='+')

View File

@@ -40,44 +40,66 @@ def run_cache(hours: int = 24, prune_only: bool = False) -> dict:
) )
youtube_items: dict[int, MediaItem] = {} 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 downloaded = already_cached = failed = 0
items_status = [] items_status = []
for airing in upcoming:
item = airing.media_item
# Determine if we are inside the 1-hour critical safety window
time_until_airing = airing.starts_at - now
in_safety_window = time_until_airing.total_seconds() < 3600
if item.media_source and item.media_source.source_type in YOUTUBE_SOURCE_TYPES:
youtube_items[item.pk] = item
# 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
# If in the 1-hour safety valve window, DO NOT download. Replace the airing.
if in_safety_window:
logger.warning(f"Airing {airing.id} ({item.title}) is < 1h away and not cached! Triggering emergency replacement.")
from core.services.scheduler import ScheduleGenerator
generator = ScheduleGenerator(channel=airing.channel)
try:
generator.replace_undownloaded_airings([airing])
items_status.append({
"id": item.pk,
"title": item.title,
"status": "replaced",
"error": "Not downloaded in time",
})
except Exception as e:
logger.error(f"Emergency replacement failed for airing {airing.id}: {e}")
continue
for item in youtube_items.values(): # Otherwise, attempt download normally
# Skip if already cached try:
if item.cached_file_path and pathlib.Path(item.cached_file_path).exists(): local_path = download_for_airing(item)
already_cached += 1 downloaded += 1
items_status.append({ items_status.append({
"id": item.pk, "id": item.pk,
"title": item.title, "title": item.title,
"status": "cached", "status": "downloaded",
"path": item.cached_file_path, "path": str(local_path),
}) })
continue except Exception as exc:
failed += 1
try: items_status.append({
local_path = download_for_airing(item) "id": item.pk,
downloaded += 1 "title": item.title,
items_status.append({ "status": "failed",
"id": item.pk, "error": str(exc),
"title": item.title, })
"status": "downloaded", logger.error("download_for_airing(%s) failed: %s", item.pk, exc)
"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( logger.info(
"run_cache(hours=%d): pruned=%d downloaded=%d cached=%d failed=%d", "run_cache(hours=%d): pruned=%d downloaded=%d cached=%d failed=%d",

View File

@@ -46,7 +46,7 @@ class ScheduleGenerator:
return 0 return 0
target_weekday_bit = 1 << target_date.weekday() target_weekday_bit = 1 << target_date.weekday()
blocks = template.scheduleblock_set.all() blocks = template.scheduleblock_set.all().order_by('start_local_time')
airings_created = 0 airings_created = 0
for block in blocks: for block in blocks:
@@ -60,7 +60,7 @@ class ScheduleGenerator:
if end_dt <= start_dt: if end_dt <= start_dt:
end_dt += timedelta(days=1) end_dt += timedelta(days=1)
# Clear existing airings in this window (idempotency) # Clear existing airings whose start time is within this block's window
Airing.objects.filter( Airing.objects.filter(
channel=self.channel, channel=self.channel,
starts_at__gte=start_dt, starts_at__gte=start_dt,
@@ -71,8 +71,18 @@ class ScheduleGenerator:
if not available_items: if not available_items:
continue continue
# Prevent overlaps: ensure we don't start before the end of the previous block's overrun
latest_prior_airing = Airing.objects.filter(
channel=self.channel,
starts_at__lt=start_dt
).order_by('-ends_at').first()
actual_start_dt = start_dt
if latest_prior_airing and latest_prior_airing.ends_at > start_dt:
actual_start_dt = latest_prior_airing.ends_at
airings_created += self._fill_block( airings_created += self._fill_block(
template, block, start_dt, end_dt, available_items template, block, actual_start_dt, end_dt, available_items
) )
return airings_created return airings_created
@@ -88,14 +98,20 @@ class ScheduleGenerator:
).order_by('-priority') ).order_by('-priority')
return qs.first() return qs.first()
def _get_weighted_items(self, block: ScheduleBlock) -> list: def _get_weighted_items(self, block: ScheduleBlock, require_downloaded: bool = False) -> list:
""" """
Build a weighted pool of MediaItems respecting ChannelSourceRule. Build a weighted pool of MediaItems respecting ChannelSourceRule.
If require_downloaded is True, strictly exclude items from YouTube sources
that have not yet been downloaded (cached_file_path is null).
Returns a flat list with items duplicated according to their effective Returns a flat list with items duplicated according to their effective
weight (rounded to nearest int, min 1) so random.choice() gives the weight (rounded to nearest int, min 1) so random.choice() gives the
right probability distribution without needing numpy. right probability distribution without needing numpy.
""" """
if block.block_type == ScheduleBlock.BlockType.OFF_AIR:
return []
rules = list( rules = list(
ChannelSourceRule.objects.filter(channel=self.channel) ChannelSourceRule.objects.filter(channel=self.channel)
.select_related('media_source') .select_related('media_source')
@@ -109,6 +125,10 @@ class ScheduleGenerator:
source_weights: dict[int, float] = {} source_weights: dict[int, float] = {}
for rule in rules: for rule in rules:
# If a rule has a label, it only applies if this block's name matches
if rule.schedule_block_label and rule.schedule_block_label != block.name:
continue
sid = rule.media_source_id sid = rule.media_source_id
mode = rule.rule_mode mode = rule.rule_mode
w = float(rule.weight or 1.0) w = float(rule.weight or 1.0)
@@ -148,6 +168,14 @@ class ScheduleGenerator:
if block.default_genre: if block.default_genre:
base_qs = base_qs.filter(genres=block.default_genre) base_qs = base_qs.filter(genres=block.default_genre)
# Enforce downloaded requirement for emergency replacements
if require_downloaded:
from django.db.models import Q
from core.services.youtube import YOUTUBE_SOURCE_TYPES
base_qs = base_qs.exclude(
Q(media_source__source_type__in=YOUTUBE_SOURCE_TYPES) & Q(cached_file_path__isnull=True)
)
items = list(base_qs) items = list(base_qs)
if not items: if not items:
return [] return []
@@ -208,3 +236,51 @@ class ScheduleGenerator:
created += 1 created += 1
return created return created
def replace_undownloaded_airings(self, airings: list[Airing]):
"""
Takes a list of specific Airings that failed to download or are
too close to airtime without a valid cache file. Replaces the
underlying media_item with one guaranteed to be playable, and
ripple-shifts all following airings on the channel by the duration diff.
"""
import logging
logger = logging.getLogger(__name__)
for original_airing in airings:
# 1. Fetch available downloaded items for this block
safe_items = self._get_weighted_items(original_airing.schedule_block, require_downloaded=True)
if not safe_items:
logger.error(f"Cannot replace airing {original_airing.id}: No downloaded items available for block {original_airing.schedule_block.name}")
continue
# 2. Pick a random valid fallback item
fallback_item = random.choice(safe_items)
old_duration = original_airing.ends_at - original_airing.starts_at
# Update the original airing to reference the new item
original_airing.media_item = fallback_item
original_airing.source_reason = 'recovery'
new_duration = timedelta(seconds=max(fallback_item.runtime_seconds or 1800, 1))
original_airing.ends_at = original_airing.starts_at + new_duration
original_airing.save(update_fields=['media_item', 'source_reason', 'ends_at'])
logger.info(f"Replaced airing {original_airing.id} with '{fallback_item.title}' (diff: {new_duration - old_duration})")
# 3. Ripple shift downstream airings accurately
delta = new_duration - old_duration
if delta.total_seconds() != 0:
# Find all airings strictly after this one on the same channel
downstream = Airing.objects.filter(
channel=self.channel,
starts_at__gte=original_airing.starts_at + old_duration
).exclude(id=original_airing.id).order_by('starts_at')
# Apply shift
for later_airing in downstream:
later_airing.starts_at += delta
later_airing.ends_at += delta
later_airing.save(update_fields=['starts_at', 'ends_at'])

View File

@@ -238,7 +238,36 @@ def download_for_airing(media_item: MediaItem) -> Path:
# Persist the cache location on the model # Persist the cache location on the model
media_item.cached_file_path = str(downloaded_path) media_item.cached_file_path = str(downloaded_path)
media_item.save(update_fields=["cached_file_path"])
# Extract exact runtime from the cached file using ffprobe-static via Node.js
import subprocess
import json
exact_duration = None
try:
# Resolve ffprobe path from the npm package
node_cmd = ["node", "-e", "console.log(require('ffprobe-static').path)"]
result = subprocess.run(node_cmd, capture_output=True, text=True, check=True)
ffprobe_cmd = result.stdout.strip()
probe_cmd = [
ffprobe_cmd,
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(downloaded_path)
]
probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, check=True)
exact_duration = float(probe_result.stdout.strip())
except Exception as e:
logger.warning(f"Failed to extract exact runtime for {video_id} using ffprobe: {e}")
if exact_duration:
# Round up to nearest integer to be safe on bounds
import math
media_item.runtime_seconds = int(math.ceil(exact_duration))
media_item.save(update_fields=["cached_file_path", "runtime_seconds"])
logger.info("downloaded %s -> %s", video_id, downloaded_path) logger.info("downloaded %s -> %s (exact runtime: %s)", video_id, downloaded_path, exact_duration)
return downloaded_path return downloaded_path

View File

@@ -34,6 +34,13 @@ export const deleteTemplate = async (id) => { await apiClient.delete(`/schedule/
export const generateScheduleToday = async (channelId) => export const generateScheduleToday = async (channelId) =>
(await apiClient.post(`/schedule/generate-today/${channelId}`)).data; (await apiClient.post(`/schedule/generate-today/${channelId}`)).data;
export const fetchTemplateBlocks = async (templateId) =>
(await apiClient.get(`/schedule/template/${templateId}/blocks`)).data;
export const createTemplateBlock = async (payload) =>
(await apiClient.post('/schedule/block/', payload)).data;
export const deleteTemplateBlock = async (blockId) =>
(await apiClient.delete(`/schedule/block/${blockId}`)).data;
// Legacy used by guide // Legacy used by guide
export const fetchScheduleGenerations = async (channelId) => export const fetchScheduleGenerations = async (channelId) =>
(await apiClient.post(`/schedule/generate/${channelId}`)).data; (await apiClient.post(`/schedule/generate/${channelId}`)).data;

View File

@@ -142,6 +142,24 @@ export default function ChannelTuner({ onOpenGuide }) {
muted={!isCurrent} muted={!isCurrent}
loop loop
playsInline playsInline
onLoadedMetadata={(e) => {
const video = e.target;
if (currentAiring && currentAiring.starts_at) {
const startTime = new Date(currentAiring.starts_at).getTime();
const nowTime = Date.now();
if (nowTime > startTime) {
const offsetSeconds = (nowTime - startTime) / 1000;
// If the video is shorter than the offset (e.g. repeating a short clip),
// modulo the offset by duration to emulate a continuous loop.
if (video.duration && video.duration > 0) {
video.currentTime = offsetSeconds % video.duration;
} else {
video.currentTime = offsetSeconds;
}
}
}
}}
onError={(e) => { onError={(e) => {
if (e.target.src !== chan.fallbackFile) { if (e.target.src !== chan.fallbackFile) {
console.warn(`Video failed to load: ${e.target.src}, falling back.`); console.warn(`Video failed to load: ${e.target.src}, falling back.`);

View File

@@ -4,6 +4,7 @@ import {
fetchChannels, createChannel, deleteChannel, updateChannel, fetchChannels, createChannel, deleteChannel, updateChannel,
fetchChannelSources, assignSourceToChannel, removeSourceFromChannel, fetchChannelSources, assignSourceToChannel, removeSourceFromChannel,
fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday, fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday,
fetchTemplateBlocks, createTemplateBlock, deleteTemplateBlock,
fetchSources, createSource, syncSource, deleteSource, fetchSources, createSource, syncSource, deleteSource,
fetchLibraries, fetchLibraries,
fetchDownloadStatus, triggerCacheUpcoming, downloadItem, fetchDownloadStatus, triggerCacheUpcoming, downloadItem,
@@ -156,7 +157,7 @@ function ChannelsTab() {
const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] } const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] }
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '' }); 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 [assignForm, setAssignForm] = useState({ source_id: '', rule_mode: 'allow', weight: 1.0, schedule_block_label: '' });
const [syncingId, setSyncingId] = useState(null); const [syncingId, setSyncingId] = useState(null);
const [feedback, setFeedback, ok, err] = useFeedback(); const [feedback, setFeedback, ok, err] = useFeedback();
@@ -210,9 +211,10 @@ function ChannelsTab() {
source_id: parseInt(assignForm.source_id), source_id: parseInt(assignForm.source_id),
rule_mode: assignForm.rule_mode, rule_mode: assignForm.rule_mode,
weight: parseFloat(assignForm.weight), weight: parseFloat(assignForm.weight),
schedule_block_label: assignForm.schedule_block_label || null,
}); });
setChannelSources(cs => ({ ...cs, [channelId]: [...(cs[channelId] || []), rule] })); setChannelSources(cs => ({ ...cs, [channelId]: [...(cs[channelId] || []), rule] }));
setAssignForm({ source_id: '', rule_mode: 'allow', weight: 1.0 }); setAssignForm({ source_id: '', rule_mode: 'allow', weight: 1.0, schedule_block_label: '' });
ok('Source assigned to channel.'); ok('Source assigned to channel.');
} catch { err('Failed to assign source.'); } } catch { err('Failed to assign source.'); }
}; };
@@ -367,6 +369,13 @@ function ChannelsTab() {
style={{ width: 60 }} style={{ width: 60 }}
title="Weight (higher = more airings)" title="Weight (higher = more airings)"
/> />
<input
placeholder="Target Block Label (Optional)"
value={assignForm.schedule_block_label}
onChange={e => setAssignForm(f => ({ ...f, schedule_block_label: e.target.value }))}
style={{ flex: 1 }}
title="If set, this source will ONLY play during blocks with this exact name"
/>
<button className="btn-sync sm" onClick={() => handleAssign(ch.id)}>+ Add Rule</button> <button className="btn-sync sm" onClick={() => handleAssign(ch.id)}>+ Add Rule</button>
</div> </div>
@@ -690,12 +699,35 @@ function SchedulingTab() {
const [form, setForm] = useState({ name: '', channel_id: '', timezone_name: 'America/New_York', priority: 10 }); const [form, setForm] = useState({ name: '', channel_id: '', timezone_name: 'America/New_York', priority: 10 });
const [feedback, setFeedback, ok, err] = useFeedback(); const [feedback, setFeedback, ok, err] = useFeedback();
// Block Editor State
const [expandedTmplId, setExpandedTmplId] = useState(null);
const [templateBlocks, setTemplateBlocks] = useState({}); // { tmplId: [blocks] }
const [blockForm, setBlockForm] = useState({
name: 'A Block',
block_type: 'PROGRAM',
start_local_time: '08:00',
end_local_time: '12:00',
day_of_week_mask: 127,
target_content_rating: ''
});
useEffect(() => { useEffect(() => {
Promise.all([fetchTemplates(), fetchChannels()]) Promise.all([fetchTemplates(), fetchChannels()])
.then(([t, c]) => { setTemplates(t); setChannels(c); }) .then(([t, c]) => { setTemplates(t); setChannels(c); })
.catch(() => err('Failed to load schedule data')); .catch(() => err('Failed to load schedule data'));
}, []); }, []);
const toggleExpand = async (tmpl) => {
const next = expandedTmplId === tmpl.id ? null : tmpl.id;
setExpandedTmplId(next);
if (next && !templateBlocks[next]) {
try {
const blocks = await fetchTemplateBlocks(tmpl.id);
setTemplateBlocks(tb => ({ ...tb, [tmpl.id]: blocks }));
} catch { err('Failed to load blocks'); }
}
};
const handleCreate = async (e) => { const handleCreate = async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
@@ -780,25 +812,105 @@ function SchedulingTab() {
<div className="settings-row-list"> <div className="settings-row-list">
{templates.length === 0 && <EmptyState text="No schedule templates yet. Create one above." />} {templates.length === 0 && <EmptyState text="No schedule templates yet. Create one above." />}
{templates.map(t => ( {templates.map(t => {
<div key={t.id} className="settings-row"> const isExpanded = expandedTmplId === t.id;
<div className="row-avatar" style={{ fontSize: '1.2rem' }}>📄</div> const blocks = templateBlocks[t.id] || [];
<div className="row-info"> return (
<strong>{t.name}</strong> <div key={t.id} className={`settings-row-expandable ${isExpanded ? 'expanded' : ''}`}>
<span className="row-sub">{channelName(t.channel_id)} · {t.timezone_name}</span> <div className="settings-row" onClick={() => toggleExpand(t)}>
<span className="row-badges"> <div className="row-avatar" style={{ fontSize: '1.2rem' }}>📄</div>
<span className="badge badge-type">Priority {t.priority}</span> <div className="row-info">
{t.is_active <strong>{t.name}</strong>
? <span className="badge badge-ok">Active</span> <span className="row-sub">{channelName(t.channel_id)} · {t.timezone_name}</span>
: <span className="badge badge-muted">Inactive</span> <span className="row-badges">
} <span className="badge badge-type">Priority {t.priority}</span>
</span> {t.is_active
? <span className="badge badge-ok">Active</span>
: <span className="badge badge-muted">Inactive</span>
}
</span>
</div>
<div className="row-actions" onClick={e => e.stopPropagation()}>
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(t)} />
<span className="expand-chevron">{isExpanded ? '' : ''}</span>
</div>
</div>
{isExpanded && (
<div className="channel-expand-panel block-editor" style={{ background: 'rgba(0,0,0,0.1)', borderTop: 'none', padding: '1rem', borderBottomLeftRadius: '6px', borderBottomRightRadius: '6px' }}>
<h4 style={{ margin: '0 0 1rem 0', opacity: 0.9 }}>Schedule Blocks</h4>
{blocks.length === 0 && (
<div style={{ fontSize: '0.9rem', opacity: 0.7, marginBottom: '1rem' }}>
No blocks defined. By default, PYTV acts as if there is a single 24/7 block. If you define blocks here, you must completely cover the 24 hours of a day to avoid dead air.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.5rem' }}>
{blocks.map(b => (
<div key={b.id} style={{ display: 'flex', gap: '0.5rem', background: '#353b48', padding: '0.5rem', borderRadius: '4px', alignItems: 'center', fontSize: '0.9rem' }}>
<strong style={{ minWidth: 100 }}>{b.name}</strong>
<span style={{ fontFamily: 'monospace', opacity: 0.8 }}>{b.start_local_time.slice(0,5)} - {b.end_local_time.slice(0,5)}</span>
<span className={`badge ${b.block_type === 'OFF_AIR' ? 'badge-warn' : 'badge-ok'}`}>{b.block_type}</span>
{b.target_content_rating && <span className="badge badge-type">Rating Tier: {b.target_content_rating}</span>}
<div style={{ flex: 1 }} />
<IconBtn icon="✕" kind="danger" onClick={async () => {
try {
await deleteTemplateBlock(b.id);
setTemplateBlocks(tb => ({ ...tb, [t.id]: tb[t.id].filter(x => x.id !== b.id) }));
ok('Block deleted.');
} catch { err('Failed to delete block.'); }
}} />
</div>
))}
</div>
<form className="assign-form" style={{ background: '#2f3640' }} onSubmit={async (e) => {
e.preventDefault();
if (!blockForm.name || !blockForm.start_local_time || !blockForm.end_local_time) { err('Fill req fields'); return; }
try {
const nb = await createTemplateBlock({
schedule_template_id: t.id,
name: blockForm.name,
block_type: blockForm.block_type,
start_local_time: blockForm.start_local_time,
end_local_time: blockForm.end_local_time,
day_of_week_mask: parseInt(blockForm.day_of_week_mask),
target_content_rating: blockForm.target_content_rating ? parseInt(blockForm.target_content_rating) : null,
});
setTemplateBlocks(tb => ({ ...tb, [t.id]: [...(tb[t.id] || []), nb] }));
ok('Block created.');
} catch { err('Failed to create block'); }
}}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input placeholder="Block Name (e.g. Morning News)" required style={{ flex: 1 }} value={blockForm.name} onChange={e => setBlockForm(f => ({...f, name: e.target.value}))} />
<select value={blockForm.block_type} onChange={e => setBlockForm(f => ({...f, block_type: e.target.value}))}>
<option value="PROGRAM">Programming</option>
<option value="OFF_AIR">Off Air / Dead Time</option>
</select>
<input type="time" required value={blockForm.start_local_time} onChange={e => setBlockForm(f => ({...f, start_local_time: e.target.value}))} />
<span style={{ opacity: 0.5 }}>to</span>
<input type="time" required value={blockForm.end_local_time} onChange={e => setBlockForm(f => ({...f, end_local_time: e.target.value}))} />
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginTop: '0.5rem' }}>
<select value={blockForm.target_content_rating} onChange={e => setBlockForm(f => ({...f, target_content_rating: e.target.value}))}>
<option value="">Any content rating</option>
<option value="1">TV-Y</option>
<option value="2">TV-Y7</option>
<option value="3">TV-G</option>
<option value="4">TV-PG</option>
<option value="5">TV-14</option>
<option value="6">TV-MA</option>
</select>
<button type="submit" className="btn-sync sm">+ Add Block</button>
</div>
</form>
</div>
)}
</div> </div>
<div className="row-actions"> );
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(t)} /> })}
</div>
</div>
))}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,103 @@
import pytest
import os
from datetime import timedelta
from django.utils import timezone
from core.models import Channel, MediaSource, MediaItem, Airing
from api.routers.channel import channel_now_playing
from django.conf import settings
@pytest.mark.django_db
def test_channel_signaling_now_playing():
"""
Test what file should be playing on a channel, and whether the API
response correctly signals an existing local file or fallback.
"""
channel = Channel.objects.first()
if not channel:
pytest.skip("No channels found in test database to test signaling")
# Clear current airings to set up a controlled test
Airing.objects.filter(channel=channel).delete()
source = MediaSource.objects.first()
# 1. Test a fully downloaded video
good_item = MediaItem.objects.create(
title="Valid Cached Video",
media_source=source,
cached_file_path=os.path.join(settings.MEDIA_ROOT, "test_valid.mp4"),
runtime_seconds=600,
is_active=True
)
# Create a dummy file to simulate it existing
os.makedirs(settings.MEDIA_ROOT, exist_ok=True)
with open(good_item.cached_file_path, 'w') as f:
f.write("dummy video data")
now = timezone.now()
airing = Airing.objects.create(
channel=channel,
media_item=good_item,
starts_at=now - timedelta(minutes=5),
ends_at=now + timedelta(minutes=5),
slot_kind="program"
)
# Call the API function
response = channel_now_playing(None, channel.id)
# Assertions
assert response is not None, "API should return an airing"
assert response.media_item_title == "Valid Cached Video"
assert response.media_item_path is not None, "A valid path must be returned"
# Check if the returned path maps to a real file
if not response.media_item_path.startswith("http"):
# The API returns a URL path like /media/..., we need to strip /media to get rel_path
rel_path = response.media_item_path.replace(settings.MEDIA_URL, "")
fs_path = os.path.join(settings.MEDIA_ROOT, rel_path)
assert os.path.exists(fs_path), f"Signaled file {fs_path} does not actually exist!"
# Cleanup
if os.path.exists(good_item.cached_file_path):
os.remove(good_item.cached_file_path)
@pytest.mark.django_db
def test_channel_signaling_youtube_raw_url():
"""
Test what happens if the video is NOT downloaded and only has a raw YouTube URL.
This demonstrates the bug where the frontend fails to play it.
"""
channel = Channel.objects.first()
if not channel:
pytest.skip("No channels found")
Airing.objects.filter(channel=channel).delete()
source = MediaSource.objects.filter(source_type__icontains='youtube').first()
if not source:
pytest.skip("No youtube source found")
raw_item = MediaItem.objects.create(
title="Uncached YouTube URL",
media_source=source,
file_path="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
cached_file_path=None,
runtime_seconds=600,
is_active=True
)
now = timezone.now()
Airing.objects.create(
channel=channel,
media_item=raw_item,
starts_at=now - timedelta(minutes=5),
ends_at=now + timedelta(minutes=5),
slot_kind="program"
)
response = channel_now_playing(None, channel.id)
assert response is not None
assert response.media_item_path == "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
print(f"\\nWARNING: Backend signaled {response.media_item_path} which HTML5 <video> CANNOT play directly.")