diff --git a/.DS_Store b/.DS_Store
index ec6114f..e21c266 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/.gitignore b/.gitignore
index 505a3b1..3d5cd29 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,7 @@ wheels/
# Virtual environments
.venv
+
+node_modules/
+mock/
+cache/
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 138e399..1c9d132 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,14 @@
FROM python:3.12-slim-bookworm
# System dependencies for psycopg/postgres and general utilities
+# System dependencies for psycopg/postgres, nodejs, and general utilities
RUN apt-get update && apt-get install -y \
libpq-dev \
gcc \
curl \
+ gnupg \
+ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
+ && apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install uv globally
@@ -19,6 +23,9 @@ COPY . /app
# --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 yt-dlp
+# Install npm dependencies
+RUN npm install
+
# Expose Django default port
EXPOSE 8000
diff --git a/api/api.py b/api/api.py
index cd6565e..1dbdb20 100644
--- a/api/api.py
+++ b/api/api.py
@@ -2,13 +2,14 @@ from ninja import NinjaAPI
api = NinjaAPI(title="PYTV API")
-from api.routers.library import router as library_router
+from api.routers.library import router as library_router, collections_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("/collections/", collections_router)
api.add_router("/channel/", channel_router)
api.add_router("/schedule/", schedule_router)
api.add_router("/user/", user_router)
diff --git a/api/routers/channel.py b/api/routers/channel.py
index bf69ba2..729df45 100644
--- a/api/routers/channel.py
+++ b/api/routers/channel.py
@@ -16,6 +16,7 @@ class ChannelSchema(Schema):
scheduling_mode: str
library_id: int
owner_user_id: int
+ fallback_collection_id: Optional[int] = None
class ChannelCreateSchema(Schema):
name: str
@@ -24,6 +25,7 @@ class ChannelCreateSchema(Schema):
description: Optional[str] = None
library_id: int
owner_user_id: int # Mock Auth User
+ fallback_collection_id: Optional[int] = None
class ChannelUpdateSchema(Schema):
name: Optional[str] = None
@@ -32,6 +34,7 @@ class ChannelUpdateSchema(Schema):
scheduling_mode: Optional[str] = None
visibility: Optional[str] = None
is_active: Optional[bool] = None
+ fallback_collection_id: Optional[int] = None
class ChannelSourceRuleSchema(Schema):
id: int
@@ -103,7 +106,11 @@ class AiringSchema(Schema):
@router.get("/", response=List[ChannelSchema])
def list_channels(request):
- return Channel.objects.all()
+ from django.db.models import F
+ return Channel.objects.order_by(
+ F('channel_number').asc(nulls_last=True)
+ )
+
@router.get("/{channel_id}", response=ChannelSchema)
def get_channel(request, channel_id: int):
@@ -120,7 +127,8 @@ def create_channel(request, payload: ChannelCreateSchema):
name=payload.name,
slug=payload.slug,
channel_number=payload.channel_number,
- description=payload.description
+ description=payload.description,
+ fallback_collection_id=payload.fallback_collection_id
)
return 201, channel
diff --git a/api/routers/library.py b/api/routers/library.py
index d29a85b..803c15a 100644
--- a/api/routers/library.py
+++ b/api/routers/library.py
@@ -1,9 +1,10 @@
from ninja import Router, Schema
from typing import List, Optional
-from core.models import Library, AppUser
+from core.models import Library, AppUser, MediaCollection
from django.shortcuts import get_object_or_404
router = Router(tags=["library"])
+collections_router = Router(tags=["collections"])
class LibrarySchema(Schema):
id: int
@@ -18,6 +19,12 @@ class LibraryCreateSchema(Schema):
visibility: Optional[str] = 'private'
owner_user_id: int # In a real app with auth, this would come from request.user
+class MediaCollectionSchema(Schema):
+ id: int
+ name: str
+ library_id: int
+ collection_type: str
+
@router.get("/", response=List[LibrarySchema])
def list_libraries(request):
return Library.objects.all()
@@ -36,3 +43,8 @@ def create_library(request, payload: LibraryCreateSchema):
visibility=payload.visibility
)
return 201, library
+
+@collections_router.get("/", response=List[MediaCollectionSchema])
+def list_collections(request):
+ """List all MediaCollections across all libraries."""
+ return MediaCollection.objects.select_related('library').all()
diff --git a/api/routers/sources.py b/api/routers/sources.py
index e29c4bb..7fcfccd 100644
--- a/api/routers/sources.py
+++ b/api/routers/sources.py
@@ -176,3 +176,21 @@ def download_item(request, item_id: int):
except Exception as exc:
from ninja.errors import HttpError
raise HttpError(500, f"Download failed: {exc}")
+
+
+@router.get("/{item_id}/progress", response=dict)
+def download_progress(request, item_id: int):
+ """
+ Return the current download progress string (e.g. '45.1%') from the yt-dlp hook.
+ """
+ from django.core.cache import cache
+ 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.")
+
+ if item.cached_file_path:
+ return {"status": "finished", "progress": "100%"}
+
+ pct = cache.get(f"yt_progress_{item.youtube_video_id}")
+ return {"status": "downloading" if pct else "unknown", "progress": pct or "0%"}
diff --git a/assets/TestCard.svg b/assets/TestCard.svg
new file mode 100644
index 0000000..d0d7c54
--- /dev/null
+++ b/assets/TestCard.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/core/migrations/0004_channel_fallback_collection.py b/core/migrations/0004_channel_fallback_collection.py
new file mode 100644
index 0000000..3d3cc39
--- /dev/null
+++ b/core/migrations/0004_channel_fallback_collection.py
@@ -0,0 +1,25 @@
+# Generated by Django 6.0.3 on 2026-03-09 12:36
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("core", "0003_channelsourcerule_schedule_block_label_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="channel",
+ name="fallback_collection",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="fallback_for_channels",
+ to="core.mediacollection",
+ ),
+ ),
+ ]
diff --git a/core/models.py b/core/models.py
index 1d444df..4c54081 100644
--- a/core/models.py
+++ b/core/models.py
@@ -203,6 +203,7 @@ class Channel(models.Model):
owner_user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
library = models.ForeignKey(Library, on_delete=models.CASCADE)
default_genre = models.ForeignKey(Genre, on_delete=models.SET_NULL, blank=True, null=True)
+ fallback_collection = models.ForeignKey('MediaCollection', on_delete=models.SET_NULL, blank=True, null=True, related_name='fallback_for_channels')
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=64, unique=True)
channel_number = models.IntegerField(blank=True, null=True)
diff --git a/core/services/scheduler.py b/core/services/scheduler.py
index 830678b..4a53144 100644
--- a/core/services/scheduler.py
+++ b/core/services/scheduler.py
@@ -41,10 +41,18 @@ class ScheduleGenerator:
Idempotent generation of airings for `target_date`.
Returns the number of new Airing rows created.
"""
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
template = self._get_template()
if not template:
return 0
+ # Resolve the template's local timezone (fall back to UTC)
+ try:
+ local_tz = ZoneInfo(template.timezone_name or 'UTC')
+ except (ZoneInfoNotFoundError, Exception):
+ local_tz = ZoneInfo('UTC')
+
target_weekday_bit = 1 << target_date.weekday()
blocks = template.scheduleblock_set.all().order_by('start_local_time')
airings_created = 0
@@ -53,10 +61,14 @@ class ScheduleGenerator:
if not (block.day_of_week_mask & target_weekday_bit):
continue
- 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)
+ # Convert local block times to UTC-aware datetimes
+ start_local = datetime.combine(target_date, block.start_local_time, tzinfo=local_tz)
+ end_local = datetime.combine(target_date, block.end_local_time, tzinfo=local_tz)
- # Midnight-wrap support (e.g. 23:00–02:00)
+ start_dt = start_local.astimezone(timezone.utc)
+ end_dt = end_local.astimezone(timezone.utc)
+
+ # Midnight-wrap support (e.g. 23:00–02:00 local)
if end_dt <= start_dt:
end_dt += timedelta(days=1)
@@ -81,12 +93,18 @@ class ScheduleGenerator:
if latest_prior_airing and latest_prior_airing.ends_at > start_dt:
actual_start_dt = latest_prior_airing.ends_at
+ # If the prior block ran all the way through this block's window, skip
+ if actual_start_dt >= end_dt:
+ continue
+
airings_created += self._fill_block(
template, block, actual_start_dt, end_dt, available_items
)
return airings_created
+
+
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@@ -248,13 +266,21 @@ class ScheduleGenerator:
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
+ # 1. First check if the channel has a dedicated error fallback collection
+ safe_items = []
+ if getattr(self.channel, 'fallback_collection', None):
+ safe_items = list(self.channel.fallback_collection.media_items.exclude(
+ cached_file_path__isnull=True,
+ media_source__source_type__in=['youtube', 'youtube_channel', 'youtube_playlist']
+ ))
+ # 2. If no fallback collection or it yielded no valid items, try block sources
+ if not safe_items:
+ 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 fallback or 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
diff --git a/core/services/youtube.py b/core/services/youtube.py
index 67a7376..706a3ec 100644
--- a/core/services/youtube.py
+++ b/core/services/youtube.py
@@ -207,6 +207,21 @@ def download_for_airing(media_item: MediaItem) -> Path:
logger.info("cache hit: %s already at %s", video_id, existing)
return existing
+ from django.core.cache import cache
+
+ def progress_hook(d):
+ if d['status'] == 'downloading':
+ # Note: _percent_str includes ANSI escape codes sometimes, but yt_dlp usually cleans it if not a tty
+ pct = d.get('_percent_str', '').strip()
+ # Clean ANSI just in case
+ import re
+ pct_clean = re.sub(r'\x1b\[[0-9;]*m', '', pct).strip()
+ if pct_clean:
+ # Store the string "xx.x%" into Django cache. Expire after 5 mins so it cleans itself up.
+ cache.set(f"yt_progress_{video_id}", pct_clean, timeout=300)
+ elif d['status'] == 'finished':
+ cache.set(f"yt_progress_{video_id}", "100%", timeout=300)
+
ydl_opts = {
"quiet": True,
"no_warnings": True,
@@ -218,6 +233,7 @@ def download_for_airing(media_item: MediaItem) -> Path:
# 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",
+ "progress_hooks": [progress_hook],
}
url = media_item.file_path # URL stored here by sync_source
diff --git a/db.sqlite3 b/db.sqlite3
index dc12785..8086e5c 100644
Binary files a/db.sqlite3 and b/db.sqlite3 differ
diff --git a/docker-compose.yml b/docker-compose.yml
index 3b728a1..50bfd13 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -17,6 +17,8 @@ services:
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/app
+ - ./mock:/mock
+ - ./cache:/tmp/pytv_cache
ports:
- "8000:8000"
environment:
@@ -26,5 +28,19 @@ services:
depends_on:
- db
+ cache_worker:
+ build: .
+ command: python manage.py run_cache_worker
+ volumes:
+ - .:/app
+ - ./mock:/mock
+ - ./cache:/tmp/pytv_cache
+ environment:
+ - DEBUG=True
+ - SECRET_KEY=django-insecure-development-key-replace-in-production
+ - DATABASE_URL=postgres://pytv_user:pytv_password@db:5432/pytv_db
+ depends_on:
+ - db
+
volumes:
postgres_data:
diff --git a/frontend/public/testcard.svg b/frontend/public/testcard.svg
new file mode 100644
index 0000000..d0d7c54
--- /dev/null
+++ b/frontend/public/testcard.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/api.js b/frontend/src/api.js
index fe28d86..23219d1 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -59,9 +59,11 @@ export const fetchDownloadStatus = async () => (await apiClient.get('/sources/do
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;
+export const fetchDownloadProgress = async (itemId) => (await apiClient.get(`/sources/${itemId}/progress`)).data;
// ── Libraries ─────────────────────────────────────────────────────────────
export const fetchLibraries = async () => (await apiClient.get('/library/')).data;
+export const fetchCollections = async () => (await apiClient.get('/collections/')).data;
// ── Users ─────────────────────────────────────────────────────────────────
export const fetchUsers = async () => (await apiClient.get('/user/')).data;
diff --git a/frontend/src/components/ChannelTuner.jsx b/frontend/src/components/ChannelTuner.jsx
index fd9c2d0..fa17b09 100644
--- a/frontend/src/components/ChannelTuner.jsx
+++ b/frontend/src/components/ChannelTuner.jsx
@@ -2,11 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useRemoteControl } from '../hooks/useRemoteControl';
import { fetchChannels, fetchChannelNow } from '../api';
-const FALLBACK_VIDEOS = [
- 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
- 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4',
- 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4'
-];
+const TEST_CARD_SRC = '/testcard.svg';
export default function ChannelTuner({ onOpenGuide }) {
const [channels, setChannels] = useState([]);
@@ -17,7 +13,6 @@ export default function ChannelTuner({ onOpenGuide }) {
const [nowPlaying, setNowPlaying] = useState({}); // { channelId: airingData }
const osdTimerRef = useRef(null);
- // The 3 buffer indices
const getPrevIndex = (index) => (index - 1 + channels.length) % channels.length;
const getNextIndex = (index) => (index + 1) % channels.length;
@@ -30,107 +25,114 @@ export default function ChannelTuner({ onOpenGuide }) {
osdTimerRef.current = setTimeout(() => setShowOSD(false), 5000);
}, []);
- const wrapChannelUp = () => {
- setCurrentIndex(getNextIndex);
- triggerOSD();
- };
-
- const wrapChannelDown = () => {
- setCurrentIndex(getPrevIndex);
- triggerOSD();
- };
+ const wrapChannelUp = () => { setCurrentIndex(getNextIndex); triggerOSD(); };
+ const wrapChannelDown = () => { setCurrentIndex(getPrevIndex); triggerOSD(); };
useRemoteControl({
onChannelUp: wrapChannelUp,
onChannelDown: wrapChannelDown,
onSelect: triggerOSD,
- onBack: onOpenGuide
+ onBack: onOpenGuide,
});
- // Debug Info Toggle
+ // Debug toggle (i key)
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);
- }
+ if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) 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
+ // Fetch channels — always sorted by channel_number (API guarantees this; sort defensively here too)
useEffect(() => {
- fetchChannels().then(data => {
- const mapped = data.map((ch, idx) => ({
- ...ch,
- fallbackFile: FALLBACK_VIDEOS[idx % FALLBACK_VIDEOS.length]
- }));
- if (mapped.length === 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: 'Offline', fallbackFile: FALLBACK_VIDEOS[0] }]);
- setLoading(false);
- });
+ fetchChannels()
+ .then(data => {
+ const sorted = [...data].sort((a, b) => {
+ if (a.channel_number == null) return 1;
+ if (b.channel_number == null) return -1;
+ return a.channel_number - b.channel_number;
+ });
+ setChannels(sorted.length > 0
+ ? sorted
+ : [{ id: 99, channel_number: 99, name: 'Default Feed' }]
+ );
+ setLoading(false);
+ })
+ .catch(err => {
+ console.error(err);
+ setChannels([{ id: 99, channel_number: 99, name: 'Offline' }]);
+ setLoading(false);
+ });
}, []);
- // Fetch "Now Playing" metadata for the current channel group whenever currentIndex changes
+ // Fetch "Now Playing" for active triple-buffer
useEffect(() => {
if (channels.length === 0) return;
-
- const activeIndices = [currentIndex, prevIndex, nextIndex];
- activeIndices.forEach(idx => {
+ [currentIndex, prevIndex, nextIndex].forEach(idx => {
const chan = channels[idx];
- fetchChannelNow(chan.id).then(data => {
- setNowPlaying(prev => ({ ...prev, [chan.id]: data }));
- }).catch(() => {
- setNowPlaying(prev => ({ ...prev, [chan.id]: null }));
- });
+ if (!chan) return;
+ 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
+ // Initial OSD
useEffect(() => {
if (!loading) triggerOSD();
return () => clearTimeout(osdTimerRef.current);
}, [loading, triggerOSD]);
if (loading || channels.length === 0) {
- return
Connecting to PYTV Backend...
;
+ return (
+
+

+
+ );
}
const currentChan = channels[currentIndex];
const airing = nowPlaying[currentChan.id];
+ /** Build the video source for a channel, or null if nothing playable. */
+ function getVideoSrc(chan) {
+ const currentAiring = nowPlaying[chan.id];
+ if (!currentAiring || !currentAiring.media_item_path) return null;
+ const path = currentAiring.media_item_path;
+ if (path.startsWith('http')) return path;
+ const clean = path.replace(/^\/?(?:media)?\/*/,'');
+ return `/media/${clean}`;
+ }
+
+
return (
{channels.map((chan, index) => {
const isCurrent = index === currentIndex;
- const isPrev = index === prevIndex;
- const isNext = index === nextIndex;
-
+ const isPrev = index === prevIndex;
+ const isNext = index === nextIndex;
if (!isCurrent && !isPrev && !isNext) return null;
- let stateClass = 'buffering';
- if (isCurrent) stateClass = 'playing';
+ const stateClass = isCurrent ? 'playing' : 'buffering';
+ const videoSrc = getVideoSrc(chan);
- // 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}`;
- }
+ // If no video available, show the test card image for this slot
+ if (!videoSrc) {
+ return (
+
+

+
+ );
}
return (
@@ -138,33 +140,30 @@ export default function ChannelTuner({ onOpenGuide }) {
key={chan.id}
src={videoSrc}
className={`tuner-video ${stateClass}`}
- autoPlay={true}
+ autoPlay
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;
- }
+ const currentAiring = nowPlaying[chan.id];
+ if (currentAiring?.starts_at) {
+ const offsetSeconds = (Date.now() - new Date(currentAiring.starts_at).getTime()) / 1000;
+ if (offsetSeconds > 0 && video.duration > 0) {
+ video.currentTime = offsetSeconds % video.duration;
}
}
}}
onError={(e) => {
- if (e.target.src !== chan.fallbackFile) {
- console.warn(`Video failed to load: ${e.target.src}, falling back.`);
- e.target.src = chan.fallbackFile;
- }
+ // Replace video with test card on error
+ console.warn(`Video failed to load: ${e.target.src} — showing test card`);
+ const parent = e.target.parentNode;
+ if (!parent) return;
+ const img = document.createElement('img');
+ img.src = TEST_CARD_SRC;
+ img.alt = 'No Signal';
+ img.style.cssText = 'width:100%;height:100%;object-fit:contain;position:absolute;top:0;left:0;background:#000';
+ e.target.replaceWith(img);
}}
/>
);
@@ -177,21 +176,15 @@ export default function ChannelTuner({ onOpenGuide }) {
Channel:
{currentChan.channel_number} - {currentChan.name} (ID: {currentChan.id})
-
+
Airing ID:
{airing ? airing.id : 'N/A'}
-
+
Media Item:
- {airing && airing.media_item_title ? airing.media_item_title : 'N/A'}
-
+ {airing?.media_item_title ?? 'N/A'}
+
Stream 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;
- })()}
+ {getVideoSrc(currentChan) ?? '(test card — no video)'}
)}
@@ -200,7 +193,7 @@ export default function ChannelTuner({ onOpenGuide }) {
- {currentChan.channel_number}
+ {currentChan.channel_number}
{currentChan.name}
@@ -208,20 +201,20 @@ export default function ChannelTuner({ onOpenGuide }) {
- {airing ? airing.slot_kind.toUpperCase() : 'LIVE'}
+ {airing ? airing.slot_kind?.toUpperCase() : 'NO SIGNAL'}
{airing && (
- {new Date(airing.starts_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} -
- {new Date(airing.ends_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}
+ {new Date(airing.starts_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} –{' '}
+ {new Date(airing.ends_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
)}
- {airing ? airing.media_item_title : 'Loading Program...'}
+ {airing ? airing.media_item_title : '— No Programming —'}
- Watching {currentChan.name}. Press Escape to load the EPG Guide.
+ Watching {currentChan.name}. Press Escape for the EPG Guide.
diff --git a/frontend/src/components/Guide.jsx b/frontend/src/components/Guide.jsx
index 572d791..d7d208f 100644
--- a/frontend/src/components/Guide.jsx
+++ b/frontend/src/components/Guide.jsx
@@ -1,10 +1,9 @@
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useRemoteControl } from '../hooks/useRemoteControl';
import { fetchChannels, fetchChannelAirings } from '../api';
-// Hours to show in the EPG and width configs
const EPG_HOURS = 4;
-const HOUR_WIDTH_PX = 360;
+const HOUR_WIDTH_PX = 360;
const PX_PER_MS = HOUR_WIDTH_PX / (60 * 60 * 1000);
function fmtTime(iso) {
@@ -12,26 +11,88 @@ function fmtTime(iso) {
return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
+function fmtDuration(starts_at, ends_at) {
+ if (!starts_at || !ends_at) return '';
+ const mins = Math.round((new Date(ends_at) - new Date(starts_at)) / 60000);
+ if (mins < 60) return `${mins}m`;
+ const h = Math.floor(mins / 60), m = mins % 60;
+ return m ? `${h}h ${m}m` : `${h}h`;
+}
+
export default function Guide({ onClose, onSelectChannel }) {
const [channels, setChannels] = useState([]);
- const [airings, setAirings] = useState({}); // { channelId: [airing, ...] }
- const [selectedIndex, setSelectedIndex] = useState(0);
+ const [airings, setAirings] = useState({});
+ const [selectedRow, setSelectedRow] = useState(0);
+ const [selectedProgram, setSelectedProgram] = useState(0);
const [now, setNow] = useState(Date.now());
const scrollRef = useRef(null);
+ const programRefs = useRef({}); // { `${rowIdx}-${progIdx}`: el }
+ const rowsScrollRef = 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());
+ // Build filtered + sorted airing list for a channel
+ const getAiringsForRow = useCallback((chanId) => {
+ const list = airings[chanId] || [];
+ return list
+ .filter(a => new Date(a.ends_at).getTime() > anchorTime.current)
+ .sort((a, b) => new Date(a.starts_at) - new Date(b.starts_at));
+ }, [airings]);
+
+ // Clamp selectedProgram when row changes
+ useEffect(() => {
+ const ch = channels[selectedRow];
+ if (!ch) return;
+ const rowAirings = getAiringsForRow(ch.id);
+ setSelectedProgram(prev => Math.min(prev, Math.max(0, rowAirings.length - 1)));
+ }, [selectedRow, channels, getAiringsForRow]);
+
+ // Auto-scroll focused program into view (timeline scroll)
+ useEffect(() => {
+ const key = `${selectedRow}-${selectedProgram}`;
+ const el = programRefs.current[key];
+ if (el && scrollRef.current) {
+ const containerRect = scrollRef.current.getBoundingClientRect();
+ const elRect = el.getBoundingClientRect();
+ const relLeft = elRect.left - containerRect.left + scrollRef.current.scrollLeft;
+ const targetScroll = relLeft - containerRect.width / 4;
+ scrollRef.current.scrollTo({ left: Math.max(0, targetScroll), behavior: 'smooth' });
+ }
+ }, [selectedRow, selectedProgram]);
+
+ // Auto-scroll focused row into view (rows column scroll)
+ useEffect(() => {
+ if (rowsScrollRef.current) {
+ const rowHeight = 80;
+ const visibleHeight = rowsScrollRef.current.clientHeight;
+ const rowTop = selectedRow * rowHeight;
+ const rowBottom = rowTop + rowHeight;
+ const currentScroll = rowsScrollRef.current.scrollTop;
+ if (rowTop < currentScroll) {
+ rowsScrollRef.current.scrollTop = rowTop;
+ } else if (rowBottom > currentScroll + visibleHeight) {
+ rowsScrollRef.current.scrollTop = rowBottom - visibleHeight;
+ }
+ }
+ }, [selectedRow]);
+
useRemoteControl({
- 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,
+ onUp: () => setSelectedRow(prev => Math.max(0, prev - 1)),
+ onDown: () => setSelectedRow(prev => Math.min(channels.length - 1, prev + 1)),
+ onLeft: () => setSelectedProgram(prev => Math.max(0, prev - 1)),
+ onRight: () => {
+ const ch = channels[selectedRow];
+ if (!ch) return;
+ const rowAirings = getAiringsForRow(ch.id);
+ setSelectedProgram(prev => Math.min(rowAirings.length - 1, prev + 1));
+ },
+ onSelect: () => channels[selectedRow] && onSelectChannel(channels[selectedRow].id),
+ onBack: onClose,
});
// Clock tick
useEffect(() => {
- const timer = setInterval(() => setNow(Date.now()), 15000); // 15s refresh
+ const timer = setInterval(() => setNow(Date.now()), 15000);
return () => clearInterval(timer);
}, []);
@@ -39,13 +100,17 @@ export default function Guide({ onClose, onSelectChannel }) {
useEffect(() => {
fetchChannels()
.then(data => {
- if (!data || data.length === 0) {
+ const sorted = [...data].sort((a, b) => {
+ if (a.channel_number == null) return 1;
+ if (b.channel_number == null) return -1;
+ return a.channel_number - b.channel_number;
+ });
+ if (sorted.length === 0) {
setChannels([{ id: 99, channel_number: 99, name: 'No Channels Found' }]);
return;
}
- setChannels(data);
+ setChannels(sorted);
- // Fetch overlapping data for timeline
Promise.allSettled(
data.map(ch =>
fetchChannelAirings(ch.id, EPG_HOURS)
@@ -55,61 +120,77 @@ export default function Guide({ onClose, onSelectChannel }) {
).then(results => {
const map = {};
for (const r of results) {
- if (r.status === 'fulfilled') {
- map[r.value.channelId] = r.value.list;
- }
+ if (r.status === 'fulfilled') map[r.value.channelId] = r.value.list;
}
setAirings(map);
});
})
- .catch((err) => {
- console.error("Guide fetch error:", err);
+ .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
+ // Initial scroll to now
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;
+ scrollRef.current.scrollLeft = Math.max(0, pxOffset - HOUR_WIDTH_PX / 2);
}
- }, [now, airings]);
+ }, [airings]); // only run once airings load
const currentTimeStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const playheadPx = (now - anchorTime.current) * PX_PER_MS;
+ const containerWidthPx = EPG_HOURS * HOUR_WIDTH_PX + HOUR_WIDTH_PX;
- // 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));
+ 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)
+ left: i * (HOUR_WIDTH_PX / 2),
};
});
- // Background pattern width
- const containerWidthPx = (EPG_HOURS * HOUR_WIDTH_PX) + HOUR_WIDTH_PX;
+ // Focused program info for the detail panel
+ const focusedChannel = channels[selectedRow];
+ const focusedAirings = focusedChannel ? getAiringsForRow(focusedChannel.id) : [];
+ const focusedAiring = focusedAirings[selectedProgram] || null;
+
+ const nowStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ const isLive = focusedAiring
+ ? now >= new Date(focusedAiring.starts_at).getTime() && now < new Date(focusedAiring.ends_at).getTime()
+ : false;
+
+ // Progress % for live program
+ const liveProgress = isLive && focusedAiring
+ ? Math.min(100, Math.round(
+ (now - new Date(focusedAiring.starts_at).getTime()) /
+ (new Date(focusedAiring.ends_at).getTime() - new Date(focusedAiring.starts_at).getTime()) * 100
+ ))
+ : 0;
return (
-
+
+ {/* Header */}
PYTV Guide
{currentTimeStr}
-
-
- {/* Left fixed column for Channels */}
-
-
-
+ {/* EPG grid — takes available space between header and detail panel */}
+
+
+ {/* Left sticky channel column */}
+
+
+
{channels.map((chan, idx) => (
-
+
{ setSelectedRow(idx); setSelectedProgram(0); }}
+ >
{chan.channel_number}
{chan.name}
@@ -117,13 +198,13 @@ export default function Guide({ onClose, onSelectChannel }) {
- {/* Scrollable Timeline */}
+ {/* Scrollable timeline */}
-
- {/* Time Axis Row */}
+ {/* Time axis */}
{timeSlots.map((slot, i) => (
@@ -132,32 +213,31 @@ export default function Guide({ onClose, onSelectChannel }) {
))}
- {/* Live Playhead Line */}
+ {/* Live playhead */}
{playheadPx > 0 && playheadPx < containerWidthPx && (
)}
- {/* Grid Rows for Airings */}
+ {/* Program rows */}
- {channels.map((chan, idx) => {
- const chanAirings = airings[chan.id];
- const isLoading = !chanAirings && chan.id !== 99;
+ {channels.map((chan, rowIdx) => {
+ const rowAirings = getAiringsForRow(chan.id);
+ const isLoading = !airings[chan.id] && chan.id !== 99;
+ const isActiveRow = rowIdx === selectedRow;
return (
-
- {isLoading &&
Loading...
}
- {!isLoading && chanAirings?.length === 0 && (
+
+ {isLoading &&
Loading…
}
+ {!isLoading && rowAirings.length === 0 && (
No scheduled programs
)}
- {chanAirings && chanAirings.map(a => {
+ {rowAirings.map((a, progIdx) => {
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);
@@ -165,17 +245,31 @@ export default function Guide({ onClose, onSelectChannel }) {
let stateClass = 'future';
if (now >= sTs && now < eTs) stateClass = 'current';
- if (now >= eTs) stateClass = 'past';
+ else if (now >= eTs) stateClass = 'past';
+
+ const isFocused = isActiveRow && progIdx === selectedProgram;
return (
-
{
+ const key = `${rowIdx}-${progIdx}`;
+ if (el) programRefs.current[key] = el;
+ }}
+ className={`epg-program ${stateClass}${isFocused ? ' epg-program-focused' : ''}`}
style={{ left: `${startPx}px`, width: `${widthPx}px` }}
- onClick={() => onSelectChannel(chan.id)}
+ onClick={() => {
+ setSelectedRow(rowIdx);
+ setSelectedProgram(progIdx);
+ onSelectChannel(chan.id);
+ }}
+ onMouseEnter={() => {
+ setSelectedRow(rowIdx);
+ setSelectedProgram(progIdx);
+ }}
>
{a.media_item_title}
-
{fmtTime(a.starts_at)} - {fmtTime(a.ends_at)}
+
{fmtTime(a.starts_at)} – {fmtTime(a.ends_at)}
);
})}
@@ -185,11 +279,54 @@ export default function Guide({ onClose, onSelectChannel }) {
-
-
- Press
Enter to tune ·
↑↓ to navigate channels ·
Escape to close
+ {/* ── Detail panel ─────────────────────────────────────────────────── */}
+
+ {focusedAiring ? (
+ <>
+
+ {isLive &&
● LIVE}
+
{focusedAiring.media_item_title || '—'}
+
+ {fmtTime(focusedAiring.starts_at)} – {fmtTime(focusedAiring.ends_at)}
+ ·
+ {fmtDuration(focusedAiring.starts_at, focusedAiring.ends_at)}
+ {focusedChannel && (
+ <>
+ ·
+ CH {focusedChannel.channel_number} · {focusedChannel.name}
+ >
+ )}
+
+ {isLive && (
+
+ )}
+
+
+
+ ↑↓ Channel ←→ Program Enter Watch Esc Close
+
+ {isLive && (
+
+ )}
+
+ >
+ ) : focusedChannel ? (
+
+
{focusedChannel.name}
+
No programs scheduled — ↑↓ to navigate channels
+
+ ) : (
+
+
↑↓ Channel ←→ Program Enter Watch
+
+ )}
);
diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx
index 9e4dc38..6dd40e8 100644
--- a/frontend/src/components/Settings.jsx
+++ b/frontend/src/components/Settings.jsx
@@ -6,8 +6,8 @@ import {
fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday,
fetchTemplateBlocks, createTemplateBlock, deleteTemplateBlock,
fetchSources, createSource, syncSource, deleteSource,
- fetchLibraries,
- fetchDownloadStatus, triggerCacheUpcoming, downloadItem,
+ fetchLibraries, fetchCollections,
+ fetchDownloadStatus, triggerCacheUpcoming, downloadItem, fetchDownloadProgress,
} from '../api';
// ─── Constants ────────────────────────────────────────────────────────────
@@ -57,6 +57,12 @@ function EmptyState({ text }) {
return
{text}
;
}
+function timeToPct(timeStr) {
+ if (!timeStr) return 0;
+ const [h, m] = timeStr.split(':').map(Number);
+ return ((h * 60 + m) / 1440) * 100;
+}
+
function IconBtn({ icon, label, kind = 'default', onClick, disabled, title }) {
return (
+ {/* Inject a progress bar right under the item if it's currently downloading */}
+ {downloadingId === item.id && downloadProgress && (
+
+ )}
))}
@@ -846,6 +929,33 @@ function SchedulingTab() {
)}
+ {blocks.length > 0 && (
+
+ {/* Timeline tick marks */}
+ {[0, 6, 12, 18].map(h => (
+
+ {h}:00
+
+ ))}
+ {blocks.map(b => {
+ const startPct = timeToPct(b.start_local_time);
+ let endPct = timeToPct(b.end_local_time);
+ if (endPct <= startPct) endPct = 100; // spills past midnight visually
+ const width = endPct - startPct;
+ const color = b.block_type === 'OFF_AIR' ? 'rgba(248, 113, 113, 0.8)' : 'rgba(96, 165, 250, 0.8)'; // red vs blue
+ return (
+
+ {width > 8 ? b.name : ''}
+
+ );
+ })}
+
+ )}
+
{blocks.map(b => (
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 8e2b4dd..a34ef47 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -472,6 +472,114 @@ body {
font-style: italic;
}
+/* Keyboard/remote cursor focus ring on a program cell */
+.epg-program-focused {
+ outline: 2px solid var(--pytv-accent) !important;
+ outline-offset: -2px;
+ background: rgba(0, 212, 255, 0.20) !important;
+ box-shadow: 0 0 18px var(--pytv-accent-glow);
+ z-index: 4;
+}
+.epg-program-focused .epg-program-title {
+ color: #fff;
+}
+
+/* ─── Detail Panel ──────────────────────────────────────────────────────── */
+
+.epg-detail-panel {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 2rem;
+ padding: 1.25rem 3rem;
+ background: rgba(10, 10, 20, 0.95);
+ border-top: 1px solid var(--pytv-glass-border);
+ min-height: 90px;
+}
+
+.epg-detail-left {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ min-width: 0;
+ flex: 1;
+}
+
+.epg-detail-right {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.6rem;
+ flex-shrink: 0;
+}
+
+.epg-live-badge {
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 1.5px;
+ color: var(--pytv-accent);
+ text-shadow: 0 0 8px var(--pytv-accent-glow);
+ text-transform: uppercase;
+}
+
+.epg-detail-title {
+ font-size: 1.35rem;
+ font-weight: 700;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--pytv-text);
+}
+
+.epg-detail-meta {
+ font-size: 0.85rem;
+ color: var(--pytv-text-dim);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ align-items: center;
+}
+
+.epg-detail-dot {
+ opacity: 0.4;
+}
+
+/* Live progress bar under detail title */
+.epg-progress-bar {
+ height: 3px;
+ background: rgba(255,255,255,0.12);
+ border-radius: 2px;
+ overflow: hidden;
+ margin-top: 0.2rem;
+ width: min(400px, 100%);
+}
+.epg-progress-fill {
+ height: 100%;
+ background: var(--pytv-accent);
+ box-shadow: 0 0 6px var(--pytv-accent-glow);
+ border-radius: 2px;
+ transition: width 1s linear;
+}
+
+/* Keyboard hints */
+.epg-detail-hint {
+ font-size: 0.76rem;
+ color: var(--pytv-text-dim);
+ opacity: 0.6;
+}
+.epg-detail-hint kbd {
+ display: inline-block;
+ background: rgba(255,255,255,0.1);
+ border: 1px solid rgba(255,255,255,0.18);
+ border-radius: 4px;
+ padding: 0.05em 0.4em;
+ font-family: var(--font-osd);
+ font-size: 0.8em;
+ color: var(--pytv-text);
+}
+
+
/* Animations */
@keyframes slideUp {
from {
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..744f0e7
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,22 @@
+{
+ "name": "pytv",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "pytv",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "ffprobe-static": "^3.1.0"
+ }
+ },
+ "node_modules/ffprobe-static": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/ffprobe-static/-/ffprobe-static-3.1.0.tgz",
+ "integrity": "sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..54b664a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "pytv",
+ "version": "1.0.0",
+ "description": "Simple app to replicate cable experience. Note this app has been developed with the heavy assistence of Generative AI as an experiment for myself. Therefore **it should not be considered secure**",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git@git.algebrist.com:tboudreaux/PYTV.git"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "ffprobe-static": "^3.1.0"
+ }
+}