diff --git a/api/routers/channel.py b/api/routers/channel.py index 22645a3..bf69ba2 100644 --- a/api/routers/channel.py +++ b/api/routers/channel.py @@ -39,11 +39,13 @@ class ChannelSourceRuleSchema(Schema): source_name: str rule_mode: str weight: float + schedule_block_label: Optional[str] = None class ChannelSourceAssignSchema(Schema): source_id: int rule_mode: str = 'allow' # allow | prefer | avoid | block weight: float = 1.0 + schedule_block_label: Optional[str] = None class AiringSchema(Schema): id: int @@ -58,7 +60,21 @@ class AiringSchema(Schema): 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 + 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.startswith("http://") or raw_path.startswith("https://"): media_path = raw_path @@ -133,6 +149,7 @@ def list_channel_sources(request, channel_id: int): source_name=r.media_source.name, rule_mode=r.rule_mode, weight=float(r.weight), + schedule_block_label=r.schedule_block_label, ) for r in rules ] @@ -146,6 +163,7 @@ def assign_source_to_channel(request, channel_id: int, payload: ChannelSourceAss media_source=source, rule_mode=payload.rule_mode, weight=payload.weight, + schedule_block_label=payload.schedule_block_label, ) return 201, ChannelSourceRuleSchema( id=rule.id, @@ -153,6 +171,7 @@ def assign_source_to_channel(request, channel_id: int, payload: ChannelSourceAss source_name=source.name, rule_mode=rule.rule_mode, weight=float(rule.weight), + schedule_block_label=rule.schedule_block_label, ) @router.delete("/{channel_id}/sources/{rule_id}", response={204: None}) diff --git a/api/routers/schedule.py b/api/routers/schedule.py index 8d1014c..92ead22 100644 --- a/api/routers/schedule.py +++ b/api/routers/schedule.py @@ -1,5 +1,6 @@ from ninja import Router, Schema from typing import List, Optional +from datetime import date, time from core.models import ScheduleTemplate, Channel, ScheduleBlock from django.shortcuts import get_object_or_404 from datetime import date @@ -27,6 +28,28 @@ class ScheduleTemplateCreateSchema(Schema): is_active: bool = True 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]) def list_schedule_templates(request): return ScheduleTemplate.objects.all() @@ -92,3 +115,29 @@ def generate_schedule_today(request, channel_id: int): generator = ScheduleGenerator(channel=channel) airings_created = generator.generate_for_date(date.today()) 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 diff --git a/core/management/commands/run_cache_worker.py b/core/management/commands/run_cache_worker.py new file mode 100644 index 0000000..9a323a3 --- /dev/null +++ b/core/management/commands/run_cache_worker.py @@ -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) diff --git a/core/migrations/0003_channelsourcerule_schedule_block_label_and_more.py b/core/migrations/0003_channelsourcerule_schedule_block_label_and_more.py new file mode 100644 index 0000000..b449573 --- /dev/null +++ b/core/migrations/0003_channelsourcerule_schedule_block_label_and_more.py @@ -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, + ), + ), + ] diff --git a/core/models.py b/core/models.py index 3e0c2a6..1d444df 100644 --- a/core/models.py +++ b/core/models.py @@ -255,6 +255,7 @@ class ChannelSourceRule(models.Model): channel = models.ForeignKey(Channel, on_delete=models.CASCADE) 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) + schedule_block_label = models.CharField(max_length=255, blank=True, null=True) class RuleMode(models.TextChoices): ALLOW = 'allow', 'Allow' @@ -347,6 +348,16 @@ class ScheduleBlock(models.Model): end_local_time = models.TimeField() day_of_week_mask = models.SmallIntegerField() # 1 to 127 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) 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='+') diff --git a/core/services/cache.py b/core/services/cache.py index b0e79f0..2b07452 100644 --- a/core/services/cache.py +++ b/core/services/cache.py @@ -40,44 +40,66 @@ def run_cache(hours: int = 24, prune_only: bool = False) -> dict: ) 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 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(): - # 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) + # Otherwise, attempt download normally + 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", diff --git a/core/services/scheduler.py b/core/services/scheduler.py index 743dc2e..830678b 100644 --- a/core/services/scheduler.py +++ b/core/services/scheduler.py @@ -46,7 +46,7 @@ class ScheduleGenerator: return 0 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 for block in blocks: @@ -60,7 +60,7 @@ class ScheduleGenerator: if end_dt <= start_dt: 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( channel=self.channel, starts_at__gte=start_dt, @@ -71,8 +71,18 @@ class ScheduleGenerator: if not available_items: 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( - template, block, start_dt, end_dt, available_items + template, block, actual_start_dt, end_dt, available_items ) return airings_created @@ -88,14 +98,20 @@ class ScheduleGenerator: ).order_by('-priority') 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. + 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 weight (rounded to nearest int, min 1) so random.choice() gives the right probability distribution without needing numpy. """ + if block.block_type == ScheduleBlock.BlockType.OFF_AIR: + return [] + rules = list( ChannelSourceRule.objects.filter(channel=self.channel) .select_related('media_source') @@ -109,6 +125,10 @@ class ScheduleGenerator: source_weights: dict[int, float] = {} 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 mode = rule.rule_mode w = float(rule.weight or 1.0) @@ -148,6 +168,14 @@ class ScheduleGenerator: if 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) if not items: return [] @@ -208,3 +236,51 @@ class ScheduleGenerator: created += 1 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']) diff --git a/core/services/youtube.py b/core/services/youtube.py index 12a5354..67a7376 100644 --- a/core/services/youtube.py +++ b/core/services/youtube.py @@ -238,7 +238,36 @@ def download_for_airing(media_item: MediaItem) -> Path: # Persist the cache location on the model 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 diff --git a/frontend/src/api.js b/frontend/src/api.js index fe3e39c..fe28d86 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -34,6 +34,13 @@ export const deleteTemplate = async (id) => { await apiClient.delete(`/schedule/ export const generateScheduleToday = async (channelId) => (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 export const fetchScheduleGenerations = async (channelId) => (await apiClient.post(`/schedule/generate/${channelId}`)).data; diff --git a/frontend/src/components/ChannelTuner.jsx b/frontend/src/components/ChannelTuner.jsx index eaac1af..fd9c2d0 100644 --- a/frontend/src/components/ChannelTuner.jsx +++ b/frontend/src/components/ChannelTuner.jsx @@ -142,6 +142,24 @@ export default function ChannelTuner({ onOpenGuide }) { muted={!isCurrent} loop 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) => { if (e.target.src !== chan.fallbackFile) { console.warn(`Video failed to load: ${e.target.src}, falling back.`); diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx index f9cc2e4..9e4dc38 100644 --- a/frontend/src/components/Settings.jsx +++ b/frontend/src/components/Settings.jsx @@ -4,6 +4,7 @@ import { fetchChannels, createChannel, deleteChannel, updateChannel, fetchChannelSources, assignSourceToChannel, removeSourceFromChannel, fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday, + fetchTemplateBlocks, createTemplateBlock, deleteTemplateBlock, fetchSources, createSource, syncSource, deleteSource, fetchLibraries, fetchDownloadStatus, triggerCacheUpcoming, downloadItem, @@ -156,7 +157,7 @@ function ChannelsTab() { 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 [assignForm, setAssignForm] = useState({ source_id: '', rule_mode: 'allow', weight: 1.0, schedule_block_label: '' }); const [syncingId, setSyncingId] = useState(null); const [feedback, setFeedback, ok, err] = useFeedback(); @@ -210,9 +211,10 @@ function ChannelsTab() { source_id: parseInt(assignForm.source_id), rule_mode: assignForm.rule_mode, weight: parseFloat(assignForm.weight), + schedule_block_label: assignForm.schedule_block_label || null, }); 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.'); } catch { err('Failed to assign source.'); } }; @@ -367,6 +369,13 @@ function ChannelsTab() { style={{ width: 60 }} title="Weight (higher = more airings)" /> + 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" + /> @@ -690,12 +699,35 @@ function SchedulingTab() { const [form, setForm] = useState({ name: '', channel_id: '', timezone_name: 'America/New_York', priority: 10 }); 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(() => { Promise.all([fetchTemplates(), fetchChannels()]) .then(([t, c]) => { setTemplates(t); setChannels(c); }) .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) => { e.preventDefault(); try { @@ -780,25 +812,105 @@ function SchedulingTab() {
{templates.length === 0 && } - {templates.map(t => ( -
-
๐Ÿ“„
-
- {t.name} - {channelName(t.channel_id)} ยท {t.timezone_name} - - Priority {t.priority} - {t.is_active - ? Active - : Inactive - } - + {templates.map(t => { + const isExpanded = expandedTmplId === t.id; + const blocks = templateBlocks[t.id] || []; + return ( +
+
toggleExpand(t)}> +
๐Ÿ“„
+
+ {t.name} + {channelName(t.channel_id)} ยท {t.timezone_name} + + Priority {t.priority} + {t.is_active + ? Active + : Inactive + } + +
+
e.stopPropagation()}> + handleDelete(t)} /> + {isExpanded ? 'โ–ฒ' : 'โ–ผ'} +
+
+ + {isExpanded && ( +
+

Schedule Blocks

+ + {blocks.length === 0 && ( +
+ 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. +
+ )} + +
+ {blocks.map(b => ( +
+ {b.name} + {b.start_local_time.slice(0,5)} - {b.end_local_time.slice(0,5)} + {b.block_type} + {b.target_content_rating && Rating Tier: {b.target_content_rating}} +
+ { + 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.'); } + }} /> +
+ ))} +
+ +
{ + 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'); } + }}> +
+ setBlockForm(f => ({...f, name: e.target.value}))} /> + + setBlockForm(f => ({...f, start_local_time: e.target.value}))} /> + to + setBlockForm(f => ({...f, end_local_time: e.target.value}))} /> +
+
+ + +
+
+ +
+ )}
-
- handleDelete(t)} /> -
-
- ))} + ); + })}
); diff --git a/tests/test_channel_signaling.py b/tests/test_channel_signaling.py new file mode 100644 index 0000000..34726db --- /dev/null +++ b/tests/test_channel_signaling.py @@ -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