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
.venv
node_modules/
mock/
cache/

View File

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

View File

@@ -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)

View File

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

View File

@@ -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()

View File

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

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)
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)

View File

@@ -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: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:
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)
# 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:
logger.error(f"Cannot replace airing {original_airing.id}: No downloaded items available for block {original_airing.schedule_block.name}")
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

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)
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

Binary file not shown.

View File

@@ -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:

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) =>
(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;

View File

@@ -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 <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 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 (
<div className="tuner-container">
{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 (
<div
key={chan.id}
className={`tuner-video ${stateClass}`}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#000' }}
>
<img
src={TEST_CARD_SRC}
alt="No Signal"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</div>
);
}
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);
}}
/>
);
@@ -182,16 +181,10 @@ export default function ChannelTuner({ onOpenGuide }) {
<span className="debug-value">{airing ? airing.id : 'N/A'}</span>
<span className="debug-label">Media Item:</span>
<span className="debug-value">{airing && airing.media_item_title ? airing.media_item_title : 'N/A'}</span>
<span className="debug-value">{airing?.media_item_title ?? 'N/A'}</span>
<span className="debug-label">Stream URL:</span>
<span className="debug-value debug-url">{(() => {
if (airing && airing.media_item_path) {
if (airing.media_item_path.startsWith('http')) return airing.media_item_path;
return `/media/${airing.media_item_path.replace(/^\/?(media)?\/*/, '')}`;
}
return currentChan.fallbackFile;
})()}</span>
<span className="debug-value debug-url">{getVideoSrc(currentChan) ?? '(test card — no video)'}</span>
</div>
</div>
)}
@@ -208,20 +201,20 @@ export default function ChannelTuner({ onOpenGuide }) {
<div className="osd-info-box glass-panel">
<div className="osd-meta">
<span className="osd-badge">
{airing ? airing.slot_kind.toUpperCase() : 'LIVE'}
{airing ? airing.slot_kind?.toUpperCase() : 'NO SIGNAL'}
</span>
{airing && (
<span>
{new Date(airing.starts_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} -
{new Date(airing.ends_at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}
{new Date(airing.starts_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {' '}
{new Date(airing.ends_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
<h1 className="osd-title">
{airing ? airing.media_item_title : 'Loading Program...'}
{airing ? airing.media_item_title : '— No Programming —'}
</h1>
<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>
</div>
</div>

View File

@@ -1,8 +1,7 @@
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 PX_PER_MS = HOUR_WIDTH_PX / (60 * 60 * 1000);
@@ -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 (
<div className="guide-container open">
<div className="guide-container open" style={{ paddingBottom: 0 }}>
{/* Header */}
<div className="guide-header">
<h1>PYTV Guide</h1>
<div className="guide-clock">{currentTimeStr}</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">
<div className="epg-corner"></div>
<div className="epg-grid-rows">
{/* Left sticky channel column */}
<div className="epg-channels-col" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div className="epg-corner" />
<div ref={rowsScrollRef} className="epg-grid-rows" style={{ overflowY: 'hidden', flex: 1 }}>
{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-name">{chan.name}</div>
</div>
@@ -117,13 +198,13 @@ export default function Guide({ onClose, onSelectChannel }) {
</div>
</div>
{/* Scrollable Timeline */}
{/* Scrollable timeline */}
<div className="epg-timeline-scroll" ref={scrollRef}>
<div
className="epg-timeline-container"
style={{ width: `${containerWidthPx}px`, backgroundSize: `${HOUR_WIDTH_PX / 2}px 100%` }}
>
{/* Time Axis Row */}
{/* Time axis */}
<div className="epg-time-axis">
{timeSlots.map((slot, i) => (
<div key={i} className="epg-time-slot" style={{ left: `${slot.left}px` }}>
@@ -132,32 +213,31 @@ export default function Guide({ onClose, onSelectChannel }) {
))}
</div>
{/* Live Playhead Line */}
{/* Live playhead */}
{playheadPx > 0 && playheadPx < containerWidthPx && (
<div className="epg-playhead" style={{ left: `${playheadPx}px` }} />
)}
{/* Grid Rows for Airings */}
{/* Program rows */}
<div className="epg-grid-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 (
<div key={chan.id} className={`epg-row ${idx === selectedIndex ? 'active' : ''}`}>
{isLoading && <div className="epg-loading">Loading...</div>}
{!isLoading && chanAirings?.length === 0 && (
<div
key={chan.id}
className={`epg-row ${isActiveRow ? 'active' : ''}`}
>
{isLoading && <div className="epg-loading">Loading</div>}
{!isLoading && rowAirings.length === 0 && (
<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 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 (
<div
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` }}
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-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>
);
})}
@@ -185,11 +279,54 @@ export default function Guide({ onClose, onSelectChannel }) {
</div>
</div>
</div>
</div>
<div style={{ padding: '0 3rem', flexShrink: 0, marginTop: '2rem', color: 'var(--pytv-text-dim)', textAlign: 'center' }}>
Press <span style={{ color: '#fff' }}>Enter</span> to tune &middot; <span style={{ color: '#fff' }}>&uarr;&darr;</span> to navigate channels &middot; <span style={{ color: '#fff' }}>Escape</span> to close
{/* ── Detail panel ─────────────────────────────────────────────────── */}
<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>
);

View File

@@ -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 <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 }) {
return (
<button
@@ -151,8 +157,11 @@ function UsersTab() {
function ChannelsTab() {
const [channels, setChannels] = useState([]);
const [sources, setSources] = useState([]);
const [collections, setCollections] = useState([]);
const [libraries, setLibraries] = useState([]);
const [users, setUsers] = useState([]);
const [templates, setTemplates] = useState([]);
const [templateBlocks, setTemplateBlocks] = useState({}); // { templateId: [blocks] }
const [expandedId, setExpandedId] = useState(null);
const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] }
const [showForm, setShowForm] = useState(false);
@@ -162,8 +171,17 @@ function ChannelsTab() {
const [feedback, setFeedback, ok, err] = useFeedback();
useEffect(() => {
Promise.all([fetchChannels(), fetchSources(), fetchLibraries(), fetchUsers()])
.then(([c, s, l, u]) => { setChannels(c); setSources(s); setLibraries(l); setUsers(u); })
Promise.all([fetchChannels(), fetchSources(), fetchLibraries(), fetchUsers(), fetchCollections(), fetchTemplates()])
.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'));
}, []);
@@ -236,6 +254,14 @@ function ChannelsTab() {
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 (
<div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} />
@@ -309,6 +335,29 @@ function ChannelsTab() {
{/* Expanded: source assignment panel */}
{isExpanded && (
<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>
{!hasRules && (
@@ -369,13 +418,20 @@ function ChannelsTab() {
style={{ width: 60 }}
title="Weight (higher = more airings)"
/>
<input
placeholder="Target Block Label (Optional)"
<select
value={assignForm.schedule_block_label}
onChange={e => setAssignForm(f => ({ ...f, schedule_block_label: e.target.value }))}
style={{ flex: 1 }}
title="If set, this source will ONLY play during blocks with this exact name"
/>
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>
</div>
@@ -398,7 +454,7 @@ function SourcesTab() {
const [loading, setLoading] = useState(true);
const [syncingId, setSyncingId] = useState(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50 });
const [form, setForm] = useState({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60 });
const [feedback, setFeedback, ok, err] = useFeedback();
useEffect(() => {
@@ -411,10 +467,14 @@ function SourcesTab() {
e.preventDefault();
if (!form.library_id) { err('Please select a library.'); return; }
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]);
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.`);
} catch { err('Failed to create source.'); }
};
@@ -482,6 +542,11 @@ function SourcesTab() {
onChange={e => setForm(f => ({ ...f, max_videos: e.target.value }))} />
</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>
<button type="submit" className="btn-accent">Register Source</button>
</form>
@@ -537,6 +602,7 @@ function DownloadsTab() {
const [running, setRunning] = useState(false);
const [runResult, setRunResult] = useState(null);
const [downloadingId, setDownloadingId] = useState(null);
const [downloadProgress, setDownloadProgress] = useState(null); // '45.1%'
const [filterSource, setFilterSource] = useState('all');
const [feedback, setFeedback, ok, err] = useFeedback();
@@ -565,6 +631,15 @@ function DownloadsTab() {
const handleDownloadOne = async (item) => {
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 {
await downloadItem(item.id);
ok(`Downloaded "${item.title.slice(0, 50)}".`);
@@ -575,7 +650,11 @@ function DownloadsTab() {
}));
} catch (e) {
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))] : [];
@@ -676,13 +755,17 @@ function DownloadsTab() {
<button
className="btn-sync"
onClick={() => handleDownloadOne(item)}
disabled={downloadingId === item.id}
disabled={downloadingId !== null}
title="Download this video now"
>
{downloadingId === item.id ? '…' : '⬇'}
{downloadingId === item.id ? `Downloading ${downloadProgress || '...'}` : '⬇ Download'}
</button>
)}
</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>
@@ -846,6 +929,33 @@ function SchedulingTab() {
</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' }}>
{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' }}>

View File

@@ -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 {

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