feat(main): main

This commit is contained in:
2026-03-09 13:29:23 -04:00
parent f14454b4c8
commit b1a93161c0
22 changed files with 719 additions and 192 deletions

BIN
.DS_Store vendored

Binary file not shown.

4
.gitignore vendored
View File

@@ -8,3 +8,7 @@ wheels/
# Virtual environments # Virtual environments
.venv .venv
node_modules/
mock/
cache/

View File

@@ -1,10 +1,14 @@
FROM python:3.12-slim-bookworm FROM python:3.12-slim-bookworm
# System dependencies for psycopg/postgres and general utilities # 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 \ RUN apt-get update && apt-get install -y \
libpq-dev \ libpq-dev \
gcc \ gcc \
curl \ curl \
gnupg \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install uv globally # Install uv globally
@@ -19,6 +23,9 @@ COPY . /app
# --system ensures it installs into the global python environment rather than venv # --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 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 Django default port
EXPOSE 8000 EXPOSE 8000

View File

@@ -2,13 +2,14 @@ from ninja import NinjaAPI
api = NinjaAPI(title="PYTV API") 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.channel import router as channel_router
from api.routers.schedule import router as schedule_router from api.routers.schedule import router as schedule_router
from api.routers.user import router as user_router from api.routers.user import router as user_router
from api.routers.sources import router as sources_router from api.routers.sources import router as sources_router
api.add_router("/library/", library_router) api.add_router("/library/", library_router)
api.add_router("/collections/", collections_router)
api.add_router("/channel/", channel_router) api.add_router("/channel/", channel_router)
api.add_router("/schedule/", schedule_router) api.add_router("/schedule/", schedule_router)
api.add_router("/user/", user_router) api.add_router("/user/", user_router)

View File

@@ -16,6 +16,7 @@ class ChannelSchema(Schema):
scheduling_mode: str scheduling_mode: str
library_id: int library_id: int
owner_user_id: int owner_user_id: int
fallback_collection_id: Optional[int] = None
class ChannelCreateSchema(Schema): class ChannelCreateSchema(Schema):
name: str name: str
@@ -24,6 +25,7 @@ class ChannelCreateSchema(Schema):
description: Optional[str] = None description: Optional[str] = None
library_id: int library_id: int
owner_user_id: int # Mock Auth User owner_user_id: int # Mock Auth User
fallback_collection_id: Optional[int] = None
class ChannelUpdateSchema(Schema): class ChannelUpdateSchema(Schema):
name: Optional[str] = None name: Optional[str] = None
@@ -32,6 +34,7 @@ class ChannelUpdateSchema(Schema):
scheduling_mode: Optional[str] = None scheduling_mode: Optional[str] = None
visibility: Optional[str] = None visibility: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
fallback_collection_id: Optional[int] = None
class ChannelSourceRuleSchema(Schema): class ChannelSourceRuleSchema(Schema):
id: int id: int
@@ -103,7 +106,11 @@ class AiringSchema(Schema):
@router.get("/", response=List[ChannelSchema]) @router.get("/", response=List[ChannelSchema])
def list_channels(request): 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) @router.get("/{channel_id}", response=ChannelSchema)
def get_channel(request, channel_id: int): def get_channel(request, channel_id: int):
@@ -120,7 +127,8 @@ def create_channel(request, payload: ChannelCreateSchema):
name=payload.name, name=payload.name,
slug=payload.slug, slug=payload.slug,
channel_number=payload.channel_number, channel_number=payload.channel_number,
description=payload.description description=payload.description,
fallback_collection_id=payload.fallback_collection_id
) )
return 201, channel return 201, channel

View File

@@ -1,9 +1,10 @@
from ninja import Router, Schema from ninja import Router, Schema
from typing import List, Optional 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 from django.shortcuts import get_object_or_404
router = Router(tags=["library"]) router = Router(tags=["library"])
collections_router = Router(tags=["collections"])
class LibrarySchema(Schema): class LibrarySchema(Schema):
id: int id: int
@@ -18,6 +19,12 @@ class LibraryCreateSchema(Schema):
visibility: Optional[str] = 'private' visibility: Optional[str] = 'private'
owner_user_id: int # In a real app with auth, this would come from request.user 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]) @router.get("/", response=List[LibrarySchema])
def list_libraries(request): def list_libraries(request):
return Library.objects.all() return Library.objects.all()
@@ -36,3 +43,8 @@ def create_library(request, payload: LibraryCreateSchema):
visibility=payload.visibility visibility=payload.visibility
) )
return 201, library 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()

View File

@@ -176,3 +176,21 @@ def download_item(request, item_id: int):
except Exception as exc: except Exception as exc:
from ninja.errors import HttpError from ninja.errors import HttpError
raise HttpError(500, f"Download failed: {exc}") 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%"}

1
assets/TestCard.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="672" height="504"><path fill="#131313" d="M0 0h672v504H0z"/><path fill="silver" d="M0 0h96v336H0z"/><path fill="#c0c000" d="M96 0v336h96V0z"/><path fill="#00c0c0" d="M192 0v336h96V0z"/><path fill="#00c000" d="M288 0v336h96V0z"/><path fill="#c000c0" d="M384 0v336h96V0z"/><path fill="#c00000" d="M480 0v336h96V0z"/><path fill="#0000c0" d="M576 0v336h96V0zM0 336h96v42H0z"/><path fill="#c000c0" d="M192 336h96v42H192z"/><path fill="#00c0c0" d="M384 336h96v42H384z"/><path fill="silver" d="M576 336h96v42H576z"/><path fill="#00214c" d="M0 378h120v126H0z"/><path fill="#fff" d="M120 378h120v126H120z"/><path fill="#32006a" d="M240 378h120v126H240z"/><path fill="#090909" d="M480 378h32v126H480z"/><path fill="#1d1d1d" d="M544 378h32v126H544z"/></svg>

After

Width:  |  Height:  |  Size: 793 B

View File

@@ -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",
),
),
]

View File

@@ -203,6 +203,7 @@ class Channel(models.Model):
owner_user = models.ForeignKey(AppUser, on_delete=models.CASCADE) owner_user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
library = models.ForeignKey(Library, 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) 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) name = models.CharField(max_length=255)
slug = models.SlugField(max_length=64, unique=True) slug = models.SlugField(max_length=64, unique=True)
channel_number = models.IntegerField(blank=True, null=True) channel_number = models.IntegerField(blank=True, null=True)

View File

@@ -41,10 +41,18 @@ class ScheduleGenerator:
Idempotent generation of airings for `target_date`. Idempotent generation of airings for `target_date`.
Returns the number of new Airing rows created. Returns the number of new Airing rows created.
""" """
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
template = self._get_template() template = self._get_template()
if not template: if not template:
return 0 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() target_weekday_bit = 1 << target_date.weekday()
blocks = template.scheduleblock_set.all().order_by('start_local_time') blocks = template.scheduleblock_set.all().order_by('start_local_time')
airings_created = 0 airings_created = 0
@@ -53,10 +61,14 @@ class ScheduleGenerator:
if not (block.day_of_week_mask & target_weekday_bit): if not (block.day_of_week_mask & target_weekday_bit):
continue continue
start_dt = datetime.combine(target_date, block.start_local_time, tzinfo=timezone.utc) # Convert local block times to UTC-aware datetimes
end_dt = datetime.combine(target_date, block.end_local_time, tzinfo=timezone.utc) 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:0002:00) start_dt = start_local.astimezone(timezone.utc)
end_dt = end_local.astimezone(timezone.utc)
# Midnight-wrap support (e.g. 23:0002:00 local)
if end_dt <= start_dt: if end_dt <= start_dt:
end_dt += timedelta(days=1) end_dt += timedelta(days=1)
@@ -81,12 +93,18 @@ class ScheduleGenerator:
if latest_prior_airing and latest_prior_airing.ends_at > start_dt: if latest_prior_airing and latest_prior_airing.ends_at > start_dt:
actual_start_dt = latest_prior_airing.ends_at 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( airings_created += self._fill_block(
template, block, actual_start_dt, end_dt, available_items template, block, actual_start_dt, end_dt, available_items
) )
return airings_created return airings_created
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Helpers # Helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -248,13 +266,21 @@ class ScheduleGenerator:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
for original_airing in airings: for original_airing in airings:
# 1. Fetch available downloaded items for this block # 1. First check if the channel has a dedicated error fallback collection
safe_items = self._get_weighted_items(original_airing.schedule_block, require_downloaded=True) safe_items = []
if not safe_items: if getattr(self.channel, 'fallback_collection', None):
logger.error(f"Cannot replace airing {original_airing.id}: No downloaded items available for block {original_airing.schedule_block.name}") safe_items = list(self.channel.fallback_collection.media_items.exclude(
continue 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 # 2. Pick a random valid fallback item
fallback_item = random.choice(safe_items) fallback_item = random.choice(safe_items)
old_duration = original_airing.ends_at - original_airing.starts_at old_duration = original_airing.ends_at - original_airing.starts_at

View File

@@ -207,6 +207,21 @@ def download_for_airing(media_item: MediaItem) -> Path:
logger.info("cache hit: %s already at %s", video_id, existing) logger.info("cache hit: %s already at %s", video_id, existing)
return 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 = { ydl_opts = {
"quiet": True, "quiet": True,
"no_warnings": True, "no_warnings": True,
@@ -218,6 +233,7 @@ def download_for_airing(media_item: MediaItem) -> Path:
# 3. Any pre-muxed webm # 3. Any pre-muxed webm
# 4. Anything pre-muxed (no merger needed) # 4. Anything pre-muxed (no merger needed)
"format": "best[ext=mp4][height<=1080]/best[ext=mp4]/best[ext=webm]/best", "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 url = media_item.file_path # URL stored here by sync_source

Binary file not shown.

View File

@@ -17,6 +17,8 @@ services:
command: python manage.py runserver 0.0.0.0:8000 command: python manage.py runserver 0.0.0.0:8000
volumes: volumes:
- .:/app - .:/app
- ./mock:/mock
- ./cache:/tmp/pytv_cache
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
@@ -26,5 +28,19 @@ services:
depends_on: depends_on:
- db - 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: volumes:
postgres_data: postgres_data:

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="672" height="504"><path fill="#131313" d="M0 0h672v504H0z"/><path fill="silver" d="M0 0h96v336H0z"/><path fill="#c0c000" d="M96 0v336h96V0z"/><path fill="#00c0c0" d="M192 0v336h96V0z"/><path fill="#00c000" d="M288 0v336h96V0z"/><path fill="#c000c0" d="M384 0v336h96V0z"/><path fill="#c00000" d="M480 0v336h96V0z"/><path fill="#0000c0" d="M576 0v336h96V0zM0 336h96v42H0z"/><path fill="#c000c0" d="M192 336h96v42H192z"/><path fill="#00c0c0" d="M384 336h96v42H384z"/><path fill="silver" d="M576 336h96v42H576z"/><path fill="#00214c" d="M0 378h120v126H0z"/><path fill="#fff" d="M120 378h120v126H120z"/><path fill="#32006a" d="M240 378h120v126H240z"/><path fill="#090909" d="M480 378h32v126H480z"/><path fill="#1d1d1d" d="M544 378h32v126H544z"/></svg>

After

Width:  |  Height:  |  Size: 793 B

View File

@@ -59,9 +59,11 @@ export const fetchDownloadStatus = async () => (await apiClient.get('/sources/do
export const triggerCacheUpcoming = async (hours = 24, pruneOnly = false) => export const triggerCacheUpcoming = async (hours = 24, pruneOnly = false) =>
(await apiClient.post(`/sources/cache-upcoming?hours=${hours}&prune_only=${pruneOnly}`)).data; (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 downloadItem = async (itemId) => (await apiClient.post(`/sources/${itemId}/download`)).data;
export const fetchDownloadProgress = async (itemId) => (await apiClient.get(`/sources/${itemId}/progress`)).data;
// ── Libraries ───────────────────────────────────────────────────────────── // ── Libraries ─────────────────────────────────────────────────────────────
export const fetchLibraries = async () => (await apiClient.get('/library/')).data; export const fetchLibraries = async () => (await apiClient.get('/library/')).data;
export const fetchCollections = async () => (await apiClient.get('/collections/')).data;
// ── Users ───────────────────────────────────────────────────────────────── // ── Users ─────────────────────────────────────────────────────────────────
export const fetchUsers = async () => (await apiClient.get('/user/')).data; export const fetchUsers = async () => (await apiClient.get('/user/')).data;

View File

@@ -2,11 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useRemoteControl } from '../hooks/useRemoteControl'; import { useRemoteControl } from '../hooks/useRemoteControl';
import { fetchChannels, fetchChannelNow } from '../api'; import { fetchChannels, fetchChannelNow } from '../api';
const FALLBACK_VIDEOS = [ const TEST_CARD_SRC = '/testcard.svg';
'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'
];
export default function ChannelTuner({ onOpenGuide }) { export default function ChannelTuner({ onOpenGuide }) {
const [channels, setChannels] = useState([]); const [channels, setChannels] = useState([]);
@@ -17,7 +13,6 @@ export default function ChannelTuner({ onOpenGuide }) {
const [nowPlaying, setNowPlaying] = useState({}); // { channelId: airingData } const [nowPlaying, setNowPlaying] = useState({}); // { channelId: airingData }
const osdTimerRef = useRef(null); const osdTimerRef = useRef(null);
// The 3 buffer indices
const getPrevIndex = (index) => (index - 1 + channels.length) % channels.length; const getPrevIndex = (index) => (index - 1 + channels.length) % channels.length;
const getNextIndex = (index) => (index + 1) % channels.length; const getNextIndex = (index) => (index + 1) % channels.length;
@@ -30,107 +25,114 @@ export default function ChannelTuner({ onOpenGuide }) {
osdTimerRef.current = setTimeout(() => setShowOSD(false), 5000); osdTimerRef.current = setTimeout(() => setShowOSD(false), 5000);
}, []); }, []);
const wrapChannelUp = () => { const wrapChannelUp = () => { setCurrentIndex(getNextIndex); triggerOSD(); };
setCurrentIndex(getNextIndex); const wrapChannelDown = () => { setCurrentIndex(getPrevIndex); triggerOSD(); };
triggerOSD();
};
const wrapChannelDown = () => {
setCurrentIndex(getPrevIndex);
triggerOSD();
};
useRemoteControl({ useRemoteControl({
onChannelUp: wrapChannelUp, onChannelUp: wrapChannelUp,
onChannelDown: wrapChannelDown, onChannelDown: wrapChannelDown,
onSelect: triggerOSD, onSelect: triggerOSD,
onBack: onOpenGuide onBack: onOpenGuide,
}); });
// Debug Info Toggle // Debug toggle (i key)
useEffect(() => { useEffect(() => {
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
// Ignore if user is typing in an input if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return; if (e.key === 'i' || e.key === 'I') setShowDebug(prev => !prev);
if (e.key === 'i' || e.key === 'I') {
setShowDebug(prev => !prev);
}
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('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(() => { useEffect(() => {
fetchChannels().then(data => { fetchChannels()
const mapped = data.map((ch, idx) => ({ .then(data => {
...ch, const sorted = [...data].sort((a, b) => {
fallbackFile: FALLBACK_VIDEOS[idx % FALLBACK_VIDEOS.length] if (a.channel_number == null) return 1;
})); if (b.channel_number == null) return -1;
if (mapped.length === 0) { return a.channel_number - b.channel_number;
mapped.push({ id: 99, channel_number: '99', name: 'Default Feed', fallbackFile: FALLBACK_VIDEOS[0] }); });
} setChannels(sorted.length > 0
setChannels(mapped); ? sorted
setLoading(false); : [{ id: 99, channel_number: 99, name: 'Default Feed' }]
}).catch(err => { );
console.error(err); setLoading(false);
setChannels([{ id: 99, channel_number: '99', name: 'Offline', fallbackFile: FALLBACK_VIDEOS[0] }]); })
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(() => { useEffect(() => {
if (channels.length === 0) return; if (channels.length === 0) return;
[currentIndex, prevIndex, nextIndex].forEach(idx => {
const activeIndices = [currentIndex, prevIndex, nextIndex];
activeIndices.forEach(idx => {
const chan = channels[idx]; const chan = channels[idx];
fetchChannelNow(chan.id).then(data => { if (!chan) return;
setNowPlaying(prev => ({ ...prev, [chan.id]: data })); fetchChannelNow(chan.id)
}).catch(() => { .then(data => setNowPlaying(prev => ({ ...prev, [chan.id]: data })))
setNowPlaying(prev => ({ ...prev, [chan.id]: null })); .catch(() => setNowPlaying(prev => ({ ...prev, [chan.id]: null })));
});
}); });
}, [currentIndex, channels, prevIndex, nextIndex]); }, [currentIndex, channels, prevIndex, nextIndex]);
// Initial OSD hide // Initial OSD
useEffect(() => { useEffect(() => {
if (!loading) triggerOSD(); if (!loading) triggerOSD();
return () => clearTimeout(osdTimerRef.current); return () => clearTimeout(osdTimerRef.current);
}, [loading, triggerOSD]); }, [loading, triggerOSD]);
if (loading || channels.length === 0) { if (loading || channels.length === 0) {
return <div style={{position: 'absolute', top: '50%', left: '50%', color: 'white'}}>Connecting to PYTV Backend...</div>; return (
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<img src={TEST_CARD_SRC} alt="Test Card" style={{ maxHeight: '100%', maxWidth: '100%', objectFit: 'contain' }} />
</div>
);
} }
const currentChan = channels[currentIndex]; const currentChan = channels[currentIndex];
const airing = nowPlaying[currentChan.id]; 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 ( return (
<div className="tuner-container"> <div className="tuner-container">
{channels.map((chan, index) => { {channels.map((chan, index) => {
const isCurrent = index === currentIndex; const isCurrent = index === currentIndex;
const isPrev = index === prevIndex; const isPrev = index === prevIndex;
const isNext = index === nextIndex; const isNext = index === nextIndex;
if (!isCurrent && !isPrev && !isNext) return null; if (!isCurrent && !isPrev && !isNext) return null;
let stateClass = 'buffering'; const stateClass = isCurrent ? 'playing' : 'buffering';
if (isCurrent) stateClass = 'playing'; const videoSrc = getVideoSrc(chan);
// Use the current airing's media item file if available, else fallback // If no video available, show the test card image for this slot
const currentAiring = nowPlaying[chan.id]; if (!videoSrc) {
let videoSrc = chan.fallbackFile; return (
if (currentAiring && currentAiring.media_item_path) { <div
if (currentAiring.media_item_path.startsWith('http')) { key={chan.id}
videoSrc = currentAiring.media_item_path; className={`tuner-video ${stateClass}`}
} else { style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#000' }}
// Django serves cached media at root, Vite proxies /media to root >
// Remove leading slashes or /media/ to avoid double slashes like /media//mock <img
const cleanPath = currentAiring.media_item_path.replace(/^\/?(media)?\/*/, ''); src={TEST_CARD_SRC}
videoSrc = `/media/${cleanPath}`; alt="No Signal"
} style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</div>
);
} }
return ( return (
@@ -138,33 +140,30 @@ export default function ChannelTuner({ onOpenGuide }) {
key={chan.id} key={chan.id}
src={videoSrc} src={videoSrc}
className={`tuner-video ${stateClass}`} className={`tuner-video ${stateClass}`}
autoPlay={true} autoPlay
muted={!isCurrent} muted={!isCurrent}
loop loop
playsInline playsInline
onLoadedMetadata={(e) => { onLoadedMetadata={(e) => {
const video = e.target; const video = e.target;
if (currentAiring && currentAiring.starts_at) { const currentAiring = nowPlaying[chan.id];
const startTime = new Date(currentAiring.starts_at).getTime(); if (currentAiring?.starts_at) {
const nowTime = Date.now(); const offsetSeconds = (Date.now() - new Date(currentAiring.starts_at).getTime()) / 1000;
if (offsetSeconds > 0 && video.duration > 0) {
if (nowTime > startTime) { video.currentTime = offsetSeconds % video.duration;
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) { // Replace video with test card on error
console.warn(`Video failed to load: ${e.target.src}, falling back.`); console.warn(`Video failed to load: ${e.target.src} — showing test card`);
e.target.src = chan.fallbackFile; 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 }) {
<div className="debug-grid"> <div className="debug-grid">
<span className="debug-label">Channel:</span> <span className="debug-label">Channel:</span>
<span className="debug-value">{currentChan.channel_number} - {currentChan.name} (ID: {currentChan.id})</span> <span className="debug-value">{currentChan.channel_number} - {currentChan.name} (ID: {currentChan.id})</span>
<span className="debug-label">Airing ID:</span> <span className="debug-label">Airing ID:</span>
<span className="debug-value">{airing ? airing.id : 'N/A'}</span> <span className="debug-value">{airing ? airing.id : 'N/A'}</span>
<span className="debug-label">Media Item:</span> <span className="debug-label">Media Item:</span>
<span className="debug-value">{airing && airing.media_item_title ? airing.media_item_title : 'N/A'}</span> <span className="debug-value">{airing?.media_item_title ?? 'N/A'}</span>
<span className="debug-label">Stream URL:</span> <span className="debug-label">Stream URL:</span>
<span className="debug-value debug-url">{(() => { <span className="debug-value debug-url">{getVideoSrc(currentChan) ?? '(test card — no video)'}</span>
if (airing && airing.media_item_path) {
if (airing.media_item_path.startsWith('http')) return airing.media_item_path;
return `/media/${airing.media_item_path.replace(/^\/?(media)?\/*/, '')}`;
}
return currentChan.fallbackFile;
})()}</span>
</div> </div>
</div> </div>
)} )}
@@ -200,7 +193,7 @@ export default function ChannelTuner({ onOpenGuide }) {
<div className={`osd-overlay ${showOSD ? '' : 'hidden'}`}> <div className={`osd-overlay ${showOSD ? '' : 'hidden'}`}>
<div className="osd-top"> <div className="osd-top">
<div className="osd-channel-bug"> <div className="osd-channel-bug">
<span className="ch-num">{currentChan.channel_number}</span> <span className="ch-num">{currentChan.channel_number}</span>
{currentChan.name} {currentChan.name}
</div> </div>
</div> </div>
@@ -208,20 +201,20 @@ export default function ChannelTuner({ onOpenGuide }) {
<div className="osd-info-box glass-panel"> <div className="osd-info-box glass-panel">
<div className="osd-meta"> <div className="osd-meta">
<span className="osd-badge"> <span className="osd-badge">
{airing ? airing.slot_kind.toUpperCase() : 'LIVE'} {airing ? airing.slot_kind?.toUpperCase() : 'NO SIGNAL'}
</span> </span>
{airing && ( {airing && (
<span> <span>
{new Date(airing.starts_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'})} {new Date(airing.ends_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span> </span>
)} )}
</div> </div>
<h1 className="osd-title"> <h1 className="osd-title">
{airing ? airing.media_item_title : 'Loading Program...'} {airing ? airing.media_item_title : '— No Programming —'}
</h1> </h1>
<p style={{ color: 'var(--pytv-text-dim)' }}> <p style={{ color: 'var(--pytv-text-dim)' }}>
Watching {currentChan.name}. Press Escape to load the EPG Guide. Watching {currentChan.name}. Press Escape for the EPG Guide.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -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 { useRemoteControl } from '../hooks/useRemoteControl';
import { fetchChannels, fetchChannelAirings } from '../api'; import { fetchChannels, fetchChannelAirings } from '../api';
// Hours to show in the EPG and width configs
const EPG_HOURS = 4; const EPG_HOURS = 4;
const HOUR_WIDTH_PX = 360; const HOUR_WIDTH_PX = 360;
const PX_PER_MS = HOUR_WIDTH_PX / (60 * 60 * 1000); const PX_PER_MS = HOUR_WIDTH_PX / (60 * 60 * 1000);
function fmtTime(iso) { function fmtTime(iso) {
@@ -12,26 +11,88 @@ function fmtTime(iso) {
return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); 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 }) { export default function Guide({ onClose, onSelectChannel }) {
const [channels, setChannels] = useState([]); const [channels, setChannels] = useState([]);
const [airings, setAirings] = useState({}); // { channelId: [airing, ...] } const [airings, setAirings] = useState({});
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedRow, setSelectedRow] = useState(0);
const [selectedProgram, setSelectedProgram] = useState(0);
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
const scrollRef = useRef(null); 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()); 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({ useRemoteControl({
onUp: () => setSelectedIndex(prev => (prev - 1 + channels.length) % channels.length), onUp: () => setSelectedRow(prev => Math.max(0, prev - 1)),
onDown: () => setSelectedIndex(prev => (prev + 1) % channels.length), onDown: () => setSelectedRow(prev => Math.min(channels.length - 1, prev + 1)),
onSelect: () => channels[selectedIndex] && onSelectChannel(channels[selectedIndex].id), onLeft: () => setSelectedProgram(prev => Math.max(0, prev - 1)),
onBack: onClose, 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 // Clock tick
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 15000); // 15s refresh const timer = setInterval(() => setNow(Date.now()), 15000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
@@ -39,13 +100,17 @@ export default function Guide({ onClose, onSelectChannel }) {
useEffect(() => { useEffect(() => {
fetchChannels() fetchChannels()
.then(data => { .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' }]); setChannels([{ id: 99, channel_number: 99, name: 'No Channels Found' }]);
return; return;
} }
setChannels(data); setChannels(sorted);
// Fetch overlapping data for timeline
Promise.allSettled( Promise.allSettled(
data.map(ch => data.map(ch =>
fetchChannelAirings(ch.id, EPG_HOURS) fetchChannelAirings(ch.id, EPG_HOURS)
@@ -55,61 +120,77 @@ export default function Guide({ onClose, onSelectChannel }) {
).then(results => { ).then(results => {
const map = {}; const map = {};
for (const r of results) { for (const r of results) {
if (r.status === 'fulfilled') { if (r.status === 'fulfilled') map[r.value.channelId] = r.value.list;
map[r.value.channelId] = r.value.list;
}
} }
setAirings(map); setAirings(map);
}); });
}) })
.catch((err) => { .catch(err => {
console.error("Guide fetch error:", err); console.error('Guide fetch error:', err);
setChannels([{ id: 99, channel_number: 99, name: 'Network Error' }]); setChannels([{ id: 99, channel_number: 99, name: 'Network Error' }]);
}); });
}, []); }, []);
// Auto-scroll the timeline to keep the playhead visible but leave // Initial scroll to now
// ~0.5 hours of padding on the left
useEffect(() => { useEffect(() => {
if (scrollRef.current) { if (scrollRef.current) {
const msOffset = now - anchorTime.current; const msOffset = now - anchorTime.current;
const pxOffset = msOffset * PX_PER_MS; const pxOffset = msOffset * PX_PER_MS;
// scroll left to (current time - 30 mins) scrollRef.current.scrollLeft = Math.max(0, pxOffset - HOUR_WIDTH_PX / 2);
const targetScroll = Math.max(0, pxOffset - (HOUR_WIDTH_PX / 2));
scrollRef.current.scrollLeft = targetScroll;
} }
}, [now, airings]); }, [airings]); // only run once airings load
const currentTimeStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const currentTimeStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const playheadPx = (now - anchorTime.current) * PX_PER_MS; 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 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 { return {
label: ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), label: ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
left: i * (HOUR_WIDTH_PX / 2) left: i * (HOUR_WIDTH_PX / 2),
}; };
}); });
// Background pattern width // Focused program info for the detail panel
const containerWidthPx = (EPG_HOURS * HOUR_WIDTH_PX) + HOUR_WIDTH_PX; 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 ( return (
<div className="guide-container open"> <div className="guide-container open" style={{ paddingBottom: 0 }}>
{/* Header */}
<div className="guide-header"> <div className="guide-header">
<h1>PYTV Guide</h1> <h1>PYTV Guide</h1>
<div className="guide-clock">{currentTimeStr}</div> <div className="guide-clock">{currentTimeStr}</div>
</div> </div>
<div className="epg-wrapper"> {/* EPG grid — takes available space between header and detail panel */}
<div className="epg-wrapper" style={{ flex: 1, minHeight: 0 }}>
{/* Left fixed column for Channels */}
<div className="epg-channels-col"> {/* Left sticky channel column */}
<div className="epg-corner"></div> <div className="epg-channels-col" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div className="epg-grid-rows"> <div className="epg-corner" />
<div ref={rowsScrollRef} className="epg-grid-rows" style={{ overflowY: 'hidden', flex: 1 }}>
{channels.map((chan, idx) => ( {channels.map((chan, idx) => (
<div key={chan.id} className={`epg-row ${idx === selectedIndex ? 'active' : ''}`}> <div
key={chan.id}
className={`epg-row ${idx === selectedRow ? 'active' : ''}`}
onClick={() => { setSelectedRow(idx); setSelectedProgram(0); }}
>
<div className="epg-ch-num">{chan.channel_number}</div> <div className="epg-ch-num">{chan.channel_number}</div>
<div className="epg-ch-name">{chan.name}</div> <div className="epg-ch-name">{chan.name}</div>
</div> </div>
@@ -117,13 +198,13 @@ export default function Guide({ onClose, onSelectChannel }) {
</div> </div>
</div> </div>
{/* Scrollable Timeline */} {/* Scrollable timeline */}
<div className="epg-timeline-scroll" ref={scrollRef}> <div className="epg-timeline-scroll" ref={scrollRef}>
<div <div
className="epg-timeline-container" className="epg-timeline-container"
style={{ width: `${containerWidthPx}px`, backgroundSize: `${HOUR_WIDTH_PX / 2}px 100%` }} style={{ width: `${containerWidthPx}px`, backgroundSize: `${HOUR_WIDTH_PX / 2}px 100%` }}
> >
{/* Time Axis Row */} {/* Time axis */}
<div className="epg-time-axis"> <div className="epg-time-axis">
{timeSlots.map((slot, i) => ( {timeSlots.map((slot, i) => (
<div key={i} className="epg-time-slot" style={{ left: `${slot.left}px` }}> <div key={i} className="epg-time-slot" style={{ left: `${slot.left}px` }}>
@@ -132,32 +213,31 @@ export default function Guide({ onClose, onSelectChannel }) {
))} ))}
</div> </div>
{/* Live Playhead Line */} {/* Live playhead */}
{playheadPx > 0 && playheadPx < containerWidthPx && ( {playheadPx > 0 && playheadPx < containerWidthPx && (
<div className="epg-playhead" style={{ left: `${playheadPx}px` }} /> <div className="epg-playhead" style={{ left: `${playheadPx}px` }} />
)} )}
{/* Grid Rows for Airings */} {/* Program rows */}
<div className="epg-grid-rows"> <div className="epg-grid-rows">
{channels.map((chan, idx) => { {channels.map((chan, rowIdx) => {
const chanAirings = airings[chan.id]; const rowAirings = getAiringsForRow(chan.id);
const isLoading = !chanAirings && chan.id !== 99; const isLoading = !airings[chan.id] && chan.id !== 99;
const isActiveRow = rowIdx === selectedRow;
return ( return (
<div key={chan.id} className={`epg-row ${idx === selectedIndex ? 'active' : ''}`}> <div
{isLoading && <div className="epg-loading">Loading...</div>} key={chan.id}
{!isLoading && chanAirings?.length === 0 && ( className={`epg-row ${isActiveRow ? 'active' : ''}`}
>
{isLoading && <div className="epg-loading">Loading</div>}
{!isLoading && rowAirings.length === 0 && (
<div className="epg-empty">No scheduled programs</div> <div className="epg-empty">No scheduled programs</div>
)} )}
{chanAirings && chanAirings.map(a => { {rowAirings.map((a, progIdx) => {
const sTs = new Date(a.starts_at).getTime(); const sTs = new Date(a.starts_at).getTime();
const eTs = new Date(a.ends_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 startPx = Math.max(0, (sTs - anchorTime.current) * PX_PER_MS);
const rawEndPx = (eTs - anchorTime.current) * PX_PER_MS; const rawEndPx = (eTs - anchorTime.current) * PX_PER_MS;
const endPx = Math.min(containerWidthPx, rawEndPx); const endPx = Math.min(containerWidthPx, rawEndPx);
@@ -165,17 +245,31 @@ export default function Guide({ onClose, onSelectChannel }) {
let stateClass = 'future'; let stateClass = 'future';
if (now >= sTs && now < eTs) stateClass = 'current'; if (now >= sTs && now < eTs) stateClass = 'current';
if (now >= eTs) stateClass = 'past'; else if (now >= eTs) stateClass = 'past';
const isFocused = isActiveRow && progIdx === selectedProgram;
return ( return (
<div <div
key={a.id} key={a.id}
className={`epg-program ${stateClass}`} ref={el => {
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` }} style={{ left: `${startPx}px`, width: `${widthPx}px` }}
onClick={() => onSelectChannel(chan.id)} onClick={() => {
setSelectedRow(rowIdx);
setSelectedProgram(progIdx);
onSelectChannel(chan.id);
}}
onMouseEnter={() => {
setSelectedRow(rowIdx);
setSelectedProgram(progIdx);
}}
> >
<div className="epg-program-title">{a.media_item_title}</div> <div className="epg-program-title">{a.media_item_title}</div>
<div className="epg-program-time">{fmtTime(a.starts_at)} - {fmtTime(a.ends_at)}</div> <div className="epg-program-time">{fmtTime(a.starts_at)} {fmtTime(a.ends_at)}</div>
</div> </div>
); );
})} })}
@@ -185,11 +279,54 @@ export default function Guide({ onClose, onSelectChannel }) {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div style={{ padding: '0 3rem', flexShrink: 0, marginTop: '2rem', color: 'var(--pytv-text-dim)', textAlign: 'center' }}> {/* ── Detail panel ─────────────────────────────────────────────────── */}
Press <span style={{ color: '#fff' }}>Enter</span> to tune &middot; <span style={{ color: '#fff' }}>&uarr;&darr;</span> to navigate channels &middot; <span style={{ color: '#fff' }}>Escape</span> to close <div className="epg-detail-panel">
{focusedAiring ? (
<>
<div className="epg-detail-left">
{isLive && <span className="epg-live-badge"> LIVE</span>}
<div className="epg-detail-title">{focusedAiring.media_item_title || '—'}</div>
<div className="epg-detail-meta">
<span>{fmtTime(focusedAiring.starts_at)} {fmtTime(focusedAiring.ends_at)}</span>
<span className="epg-detail-dot">·</span>
<span>{fmtDuration(focusedAiring.starts_at, focusedAiring.ends_at)}</span>
{focusedChannel && (
<>
<span className="epg-detail-dot">·</span>
<span>CH {focusedChannel.channel_number} · {focusedChannel.name}</span>
</>
)}
</div>
{isLive && (
<div className="epg-progress-bar">
<div className="epg-progress-fill" style={{ width: `${liveProgress}%` }} />
</div>
)}
</div>
<div className="epg-detail-right">
<div className="epg-detail-hint">
<kbd></kbd> Channel &nbsp; <kbd></kbd> Program &nbsp; <kbd>Enter</kbd> Watch &nbsp; <kbd>Esc</kbd> Close
</div>
{isLive && (
<button className="btn-accent" style={{ fontSize: '0.85rem', padding: '0.4rem 1rem' }}
onClick={() => onSelectChannel(focusedChannel.id)}>
Watch Now
</button>
)}
</div>
</>
) : focusedChannel ? (
<div className="epg-detail-left" style={{ opacity: 0.5 }}>
<div className="epg-detail-title">{focusedChannel.name}</div>
<div className="epg-detail-meta">No programs scheduled <kbd></kbd> to navigate channels</div>
</div>
) : (
<div className="epg-detail-left" style={{ opacity: 0.4 }}>
<div className="epg-detail-meta"><kbd></kbd> Channel &nbsp; <kbd></kbd> Program &nbsp; <kbd>Enter</kbd> Watch</div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -6,8 +6,8 @@ import {
fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday, fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday,
fetchTemplateBlocks, createTemplateBlock, deleteTemplateBlock, fetchTemplateBlocks, createTemplateBlock, deleteTemplateBlock,
fetchSources, createSource, syncSource, deleteSource, fetchSources, createSource, syncSource, deleteSource,
fetchLibraries, fetchLibraries, fetchCollections,
fetchDownloadStatus, triggerCacheUpcoming, downloadItem, fetchDownloadStatus, triggerCacheUpcoming, downloadItem, fetchDownloadProgress,
} from '../api'; } from '../api';
// ─── Constants ──────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────
@@ -57,6 +57,12 @@ function EmptyState({ text }) {
return <p className="settings-empty">{text}</p>; return <p className="settings-empty">{text}</p>;
} }
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 }) { function IconBtn({ icon, label, kind = 'default', onClick, disabled, title }) {
return ( return (
<button <button
@@ -151,8 +157,11 @@ function UsersTab() {
function ChannelsTab() { function ChannelsTab() {
const [channels, setChannels] = useState([]); const [channels, setChannels] = useState([]);
const [sources, setSources] = useState([]); const [sources, setSources] = useState([]);
const [collections, setCollections] = useState([]);
const [libraries, setLibraries] = useState([]); const [libraries, setLibraries] = useState([]);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [templates, setTemplates] = useState([]);
const [templateBlocks, setTemplateBlocks] = useState({}); // { templateId: [blocks] }
const [expandedId, setExpandedId] = useState(null); const [expandedId, setExpandedId] = useState(null);
const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] } const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] }
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@@ -162,8 +171,17 @@ function ChannelsTab() {
const [feedback, setFeedback, ok, err] = useFeedback(); const [feedback, setFeedback, ok, err] = useFeedback();
useEffect(() => { useEffect(() => {
Promise.all([fetchChannels(), fetchSources(), fetchLibraries(), fetchUsers()]) Promise.all([fetchChannels(), fetchSources(), fetchLibraries(), fetchUsers(), fetchCollections(), fetchTemplates()])
.then(([c, s, l, u]) => { setChannels(c); setSources(s); setLibraries(l); setUsers(u); }) .then(([c, s, l, u, col, tmpl]) => {
setChannels(c); setSources(s); setLibraries(l); setUsers(u); setCollections(col);
setTemplates(tmpl);
// Pre-load blocks for each template
tmpl.forEach(t => {
fetchTemplateBlocks(t.id).then(blocks => {
setTemplateBlocks(prev => ({ ...prev, [t.id]: blocks }));
}).catch(() => {});
});
})
.catch(() => err('Failed to load channels')); .catch(() => err('Failed to load channels'));
}, []); }, []);
@@ -236,6 +254,14 @@ function ChannelsTab() {
finally { setSyncingId(null); } finally { setSyncingId(null); }
}; };
const handleSetFallback = async (ch, collectionId) => {
try {
const updated = await updateChannel(ch.id, { fallback_collection_id: collectionId ? parseInt(collectionId) : null });
setChannels(cs => cs.map(c => c.id === updated.id ? updated : c));
ok(collectionId ? 'Fallback collection set.' : 'Fallback collection cleared.');
} catch { err('Failed to update fallback collection.'); }
};
return ( return (
<div className="tab-content"> <div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} /> <Feedback fb={feedback} clear={() => setFeedback(null)} />
@@ -309,6 +335,29 @@ function ChannelsTab() {
{/* Expanded: source assignment panel */} {/* Expanded: source assignment panel */}
{isExpanded && ( {isExpanded && (
<div className="channel-expand-panel"> <div className="channel-expand-panel">
{/* ─── Fallback block selector ───────────────────────── */}
<div style={{ marginBottom: '1.25rem', padding: '0.75rem', background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '6px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', fontSize: '0.9rem' }}>
<span style={{ whiteSpace: 'nowrap', fontWeight: 600 }}> Error Fallback Collection</span>
<select
value={ch.fallback_collection_id ?? ''}
onChange={e => handleSetFallback(ch, e.target.value)}
style={{ flex: 1 }}
title="When scheduled programming cannot play, items from this collection will air instead."
>
<option value=""> None (use block sources) </option>
{collections
.filter(col => col.library_id === ch.library_id)
.map(col => <option key={col.id} value={col.id}>{col.name} ({col.collection_type})</option>)
}
</select>
</label>
<p style={{ margin: '0.4rem 0 0 0', fontSize: '0.78rem', opacity: 0.65 }}>
Items in this collection will air when a scheduled video cannot be played (missing file). If none is set, the scheduler picks a safe video from the block's normal sources.
</p>
</div>
<h4 className="expand-section-title">Assigned Sources</h4> <h4 className="expand-section-title">Assigned Sources</h4>
{!hasRules && ( {!hasRules && (
@@ -369,13 +418,20 @@ function ChannelsTab() {
style={{ width: 60 }} style={{ width: 60 }}
title="Weight (higher = more airings)" title="Weight (higher = more airings)"
/> />
<input <select
placeholder="Target Block Label (Optional)"
value={assignForm.schedule_block_label} value={assignForm.schedule_block_label}
onChange={e => setAssignForm(f => ({ ...f, schedule_block_label: e.target.value }))} onChange={e => setAssignForm(f => ({ ...f, schedule_block_label: e.target.value }))}
style={{ flex: 1 }} style={{ flex: 1 }}
title="If set, this source will ONLY play during blocks with this exact name" title="If set, this source will ONLY play during blocks with this exact name. Leaving it empty applies to all blocks."
/> >
<option value="">— Any Time (Default) —</option>
{Array.from(new Set(
templates.filter(t => t.channel_id === ch.id)
.flatMap(t => (templateBlocks[t.id] || []).map(b => b.name))
)).map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
<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>
@@ -398,7 +454,7 @@ function SourcesTab() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [syncingId, setSyncingId] = useState(null); const [syncingId, setSyncingId] = useState(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50 }); const [form, setForm] = useState({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60 });
const [feedback, setFeedback, ok, err] = useFeedback(); const [feedback, setFeedback, ok, err] = useFeedback();
useEffect(() => { useEffect(() => {
@@ -411,10 +467,14 @@ function SourcesTab() {
e.preventDefault(); e.preventDefault();
if (!form.library_id) { err('Please select a library.'); return; } if (!form.library_id) { err('Please select a library.'); return; }
try { try {
const src = await createSource({ ...form, library_id: parseInt(form.library_id) }); const src = await createSource({
...form,
library_id: parseInt(form.library_id),
scan_interval_minutes: parseInt(form.scan_interval_minutes) || null
});
setSources(s => [...s, src]); setSources(s => [...s, src]);
setShowForm(false); setShowForm(false);
setForm({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50 }); setForm({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60 });
ok(`Source "${src.name}" registered. Hit Sync to import videos.`); ok(`Source "${src.name}" registered. Hit Sync to import videos.`);
} catch { err('Failed to create source.'); } } catch { err('Failed to create source.'); }
}; };
@@ -482,6 +542,11 @@ function SourcesTab() {
onChange={e => setForm(f => ({ ...f, max_videos: e.target.value }))} /> onChange={e => setForm(f => ({ ...f, max_videos: e.target.value }))} />
</label> </label>
)} )}
<label>Scan Interval (mins)
<input type="number" min="15" max="1440" value={form.scan_interval_minutes}
onChange={e => setForm(f => ({ ...f, scan_interval_minutes: e.target.value }))}
title="How often background workers should fetch new metadata updates" />
</label>
</div> </div>
<button type="submit" className="btn-accent">Register Source</button> <button type="submit" className="btn-accent">Register Source</button>
</form> </form>
@@ -537,6 +602,7 @@ function DownloadsTab() {
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [runResult, setRunResult] = useState(null); const [runResult, setRunResult] = useState(null);
const [downloadingId, setDownloadingId] = useState(null); const [downloadingId, setDownloadingId] = useState(null);
const [downloadProgress, setDownloadProgress] = useState(null); // '45.1%'
const [filterSource, setFilterSource] = useState('all'); const [filterSource, setFilterSource] = useState('all');
const [feedback, setFeedback, ok, err] = useFeedback(); const [feedback, setFeedback, ok, err] = useFeedback();
@@ -565,6 +631,15 @@ function DownloadsTab() {
const handleDownloadOne = async (item) => { const handleDownloadOne = async (item) => {
setDownloadingId(item.id); setDownloadingId(item.id);
setDownloadProgress('0%');
let pollInterval = setInterval(async () => {
try {
const res = await fetchDownloadProgress(item.id);
if (res.progress) setDownloadProgress(res.progress);
if (res.status === 'finished') clearInterval(pollInterval);
} catch (e) { console.warn('Progress poll err', e); }
}, 1000);
try { try {
await downloadItem(item.id); await downloadItem(item.id);
ok(`Downloaded "${item.title.slice(0, 50)}".`); ok(`Downloaded "${item.title.slice(0, 50)}".`);
@@ -575,7 +650,11 @@ function DownloadsTab() {
})); }));
} catch (e) { } catch (e) {
err(`Download failed: ${e?.response?.data?.detail || e.message}`); err(`Download failed: ${e?.response?.data?.detail || e.message}`);
} finally { setDownloadingId(null); } } finally {
clearInterval(pollInterval);
setDownloadingId(null);
setDownloadProgress(null);
}
}; };
const sources = status ? [...new Set(status.items.map(i => i.source_name))] : []; const sources = status ? [...new Set(status.items.map(i => i.source_name))] : [];
@@ -676,13 +755,17 @@ function DownloadsTab() {
<button <button
className="btn-sync" className="btn-sync"
onClick={() => handleDownloadOne(item)} onClick={() => handleDownloadOne(item)}
disabled={downloadingId === item.id} disabled={downloadingId !== null}
title="Download this video now" title="Download this video now"
> >
{downloadingId === item.id ? '…' : '⬇'} {downloadingId === item.id ? `Downloading ${downloadProgress || '...'}` : '⬇ Download'}
</button> </button>
)} )}
</div> </div>
{/* Inject a progress bar right under the item if it's currently downloading */}
{downloadingId === item.id && downloadProgress && (
<div style={{ position: 'absolute', bottom: 0, left: 0, height: '3px', background: 'var(--pytv-accent)', width: downloadProgress, transition: 'width 0.3s ease' }} />
)}
</div> </div>
))} ))}
</div> </div>
@@ -846,6 +929,33 @@ function SchedulingTab() {
</div> </div>
)} )}
{blocks.length > 0 && (
<div style={{ position: 'relative', width: '100%', height: '32px', background: '#2f3640', borderRadius: '4px', marginBottom: '1.5rem', overflow: 'hidden', border: '1px solid var(--pytv-glass-border)' }}>
{/* Timeline tick marks */}
{[0, 6, 12, 18].map(h => (
<div key={h} style={{ position: 'absolute', left: `${(h/24)*100}%`, height: '100%', borderLeft: '1px dashed rgba(255,255,255,0.2)', pointerEvents: 'none', paddingLeft: '2px', fontSize: '0.65rem', color: 'rgba(255,255,255,0.4)', paddingTop: '2px' }}>
{h}:00
</div>
))}
{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 (
<div
key={`vis-${b.id}`}
style={{ position: 'absolute', left: `${startPct}%`, width: `${width}%`, height: '100%', background: color, borderRight: '1px solid rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.75rem', color: '#fff', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', padding: '0 4px' }}
title={`${b.name} (${b.start_local_time.slice(0,5)} - ${b.end_local_time.slice(0,5)}) Rating: ${b.target_content_rating || 'Any'}`}
>
{width > 8 ? b.name : ''}
</div>
);
})}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.5rem' }}>
{blocks.map(b => ( {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' }}> <div key={b.id} style={{ display: 'flex', gap: '0.5rem', background: '#353b48', padding: '0.5rem', borderRadius: '4px', alignItems: 'center', fontSize: '0.9rem' }}>

View File

@@ -472,6 +472,114 @@ body {
font-style: italic; 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 */ /* Animations */
@keyframes slideUp { @keyframes slideUp {
from { from {

22
package-lock.json generated Normal file
View File

@@ -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"
}
}
}

19
package.json Normal file
View File

@@ -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"
}
}