feat(main): commit

This commit is contained in:
2026-03-08 16:48:58 -04:00
parent 567766eaed
commit f37382d2b8
29 changed files with 3735 additions and 223 deletions

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useRemoteControl } from '../hooks/useRemoteControl';
import { fetchChannels } from '../api';
import { fetchChannels, fetchChannelNow } from '../api';
const FALLBACK_VIDEOS = [
'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
@@ -13,6 +13,8 @@ export default function ChannelTuner({ onOpenGuide }) {
const [loading, setLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [showOSD, setShowOSD] = useState(true);
const [showDebug, setShowDebug] = useState(false);
const [nowPlaying, setNowPlaying] = useState({}); // { channelId: airingData }
const osdTimerRef = useRef(null);
// The 3 buffer indices
@@ -22,11 +24,11 @@ export default function ChannelTuner({ onOpenGuide }) {
const prevIndex = getPrevIndex(currentIndex);
const nextIndex = getNextIndex(currentIndex);
const triggerOSD = () => {
const triggerOSD = useCallback(() => {
setShowOSD(true);
if (osdTimerRef.current) clearTimeout(osdTimerRef.current);
osdTimerRef.current = setTimeout(() => setShowOSD(false), 5000);
};
}, []);
const wrapChannelUp = () => {
setCurrentIndex(getNextIndex);
@@ -42,89 +44,166 @@ export default function ChannelTuner({ onOpenGuide }) {
onChannelUp: wrapChannelUp,
onChannelDown: wrapChannelDown,
onSelect: triggerOSD,
onBack: onOpenGuide // Often on TVs 'Menu' or 'Back' opens Guide/App list
onBack: onOpenGuide
});
// Debug Info Toggle
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);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// Fetch channels from Django API
useEffect(() => {
fetchChannels().then(data => {
// If db gives us channels, pad them with a fallback video stream based on index
const mapped = data.map((ch, idx) => ({
...ch,
file: FALLBACK_VIDEOS[idx % FALLBACK_VIDEOS.length]
fallbackFile: FALLBACK_VIDEOS[idx % FALLBACK_VIDEOS.length]
}));
if (mapped.length === 0) {
// Fallback if db is completely empty
mapped.push({ id: 99, channel_number: '99', name: 'Default Local feed', file: FALLBACK_VIDEOS[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: 'Error Offline', file: FALLBACK_VIDEOS[0] }]);
setChannels([{ id: 99, channel_number: '99', name: 'Offline', fallbackFile: FALLBACK_VIDEOS[0] }]);
setLoading(false);
});
}, []);
// Fetch "Now Playing" metadata for the current channel group whenever currentIndex changes
useEffect(() => {
if (channels.length === 0) return;
const activeIndices = [currentIndex, prevIndex, nextIndex];
activeIndices.forEach(idx => {
const chan = channels[idx];
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
useEffect(() => {
if (!loading) triggerOSD();
return () => clearTimeout(osdTimerRef.current);
}, [loading]);
}, [loading, triggerOSD]);
if (loading) {
if (loading || channels.length === 0) {
return <div style={{position: 'absolute', top: '50%', left: '50%', color: 'white'}}>Connecting to PYTV Backend...</div>;
}
const currentChan = channels[currentIndex];
const airing = nowPlaying[currentChan.id];
return (
<div className="tuner-container">
{/*
We map over all channels, but selectively apply 'playing' or 'buffering'
classes to only the surrounding 3 elements. The rest are completely unrendered
to save immense DOM and memory resources.
*/}
{channels.map((chan, index) => {
const isCurrent = index === currentIndex;
const isPrev = index === prevIndex;
const isNext = index === nextIndex;
// Only mount the node if it's one of the 3 active buffers
if (!isCurrent && !isPrev && !isNext) return null;
let stateClass = 'buffering';
if (isCurrent) stateClass = 'playing';
// 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}`;
}
}
return (
<video
key={chan.id}
src={chan.file}
src={videoSrc}
className={`tuner-video ${stateClass}`}
autoPlay={true}
muted={!isCurrent} // Always mute background buffers instantly
muted={!isCurrent}
loop
playsInline
onError={(e) => {
if (e.target.src !== chan.fallbackFile) {
console.warn(`Video failed to load: ${e.target.src}, falling back.`);
e.target.src = chan.fallbackFile;
}
}}
/>
);
})}
{/* Debug Info Overlay */}
{showDebug && (
<div className="debug-panel glass-panel">
<h3>PYTV Debug Info</h3>
<div className="debug-grid">
<span className="debug-label">Channel:</span>
<span className="debug-value">{currentChan.channel_number} - {currentChan.name} (ID: {currentChan.id})</span>
<span className="debug-label">Airing ID:</span>
<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-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>
</div>
</div>
)}
{/* OSD Layer */}
<div className={`osd-overlay ${showOSD ? '' : 'hidden'}`}>
<div className="osd-top">
<div className="osd-channel-bug">
<span className="ch-num">{channels[currentIndex].channel_number}</span>
{channels[currentIndex].name}
<span className="ch-num">{currentChan.channel_number}</span>
{currentChan.name}
</div>
</div>
<div className="osd-bottom">
<div className="osd-info-box glass-panel">
<div className="osd-meta">
<span className="osd-badge">LIVE</span>
<span>12:00 PM - 2:00 PM</span>
<span className="osd-badge">
{airing ? airing.slot_kind.toUpperCase() : 'LIVE'}
</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'})}
</span>
)}
</div>
<h1 className="osd-title">Sample Movie Feed</h1>
<h1 className="osd-title">
{airing ? airing.media_item_title : 'Loading Program...'}
</h1>
<p style={{ color: 'var(--pytv-text-dim)' }}>
A classic broadcast playing locally via PYTV. Use Up/Down arrows to switch channels seamlessly.
Press Escape to load the EPG Guide.
Watching {currentChan.name}. Press Escape to load the EPG Guide.
</p>
</div>
</div>

View File

@@ -1,69 +1,195 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useRemoteControl } from '../hooks/useRemoteControl';
import { fetchChannels } from '../api';
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);
function fmtTime(iso) {
if (!iso) return '';
return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
export default function Guide({ onClose, onSelectChannel }) {
const [channels, setChannels] = useState([]);
const [airings, setAirings] = useState({}); // { channelId: [airing, ...] }
const [selectedIndex, setSelectedIndex] = useState(0);
const [now, setNow] = useState(Date.now());
const scrollRef = 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());
useRemoteControl({
onUp: () => setSelectedIndex(prev => (prev - 1 + channels.length) % channels.length),
onDown: () => setSelectedIndex(prev => (prev + 1) % channels.length),
onSelect: () => onSelectChannel(channels[selectedIndex].id),
onBack: onClose
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,
});
const [currentTime, setCurrentTime] = useState(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
// Clock tick
useEffect(() => {
fetchChannels().then(data => {
// Map channels securely, providing a fallback block if properties are missing
const mapped = data.map(ch => ({
...ch,
currentlyPlaying: ch.currentlyPlaying || { title: 'Live Broadcast', time: 'Now Playing' }
}));
if (mapped.length > 0) {
setChannels(mapped);
} else {
setChannels([{id: 99, channel_number: '99', name: 'No Channels Found', currentlyPlaying: {title: 'Empty Database', time: '--'}}]);
}
}).catch(err => {
console.error(err);
setChannels([{id: 99, channel_number: '99', name: 'Network Error', currentlyPlaying: {title: 'Could not reach PyTV server', time: '--'}}]);
});
}, []);
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
}, 60000);
const timer = setInterval(() => setNow(Date.now()), 15000); // 15s refresh
return () => clearInterval(timer);
}, []);
// Fetch channels and airings
useEffect(() => {
fetchChannels()
.then(data => {
if (!data || data.length === 0) {
setChannels([{ id: 99, channel_number: 99, name: 'No Channels Found' }]);
return;
}
setChannels(data);
// Fetch overlapping data for timeline
Promise.allSettled(
data.map(ch =>
fetchChannelAirings(ch.id, EPG_HOURS)
.then(list => ({ channelId: ch.id, list }))
.catch(() => ({ channelId: ch.id, list: [] }))
)
).then(results => {
const map = {};
for (const r of results) {
if (r.status === 'fulfilled') {
map[r.value.channelId] = r.value.list;
}
}
setAirings(map);
});
})
.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
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;
}
}, [now, airings]);
const currentTimeStr = new Date(now).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const playheadPx = (now - anchorTime.current) * PX_PER_MS;
// 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));
return {
label: ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
left: i * (HOUR_WIDTH_PX / 2)
};
});
// Background pattern width
const containerWidthPx = (EPG_HOURS * HOUR_WIDTH_PX) + HOUR_WIDTH_PX;
return (
<div className="guide-container open">
<div className="guide-header">
<h1>PYTV Guide</h1>
<div className="guide-clock">{currentTime}</div>
<div className="guide-clock">{currentTimeStr}</div>
</div>
<div className="guide-grid">
{channels.length === 0 ? <p style={{color: 'white'}}>Loading TV Guide...</p> :
channels.map((chan, idx) => (
<div key={chan.id} className={`guide-row ${idx === selectedIndex ? 'active' : ''}`}>
<div className="guide-ch-col">
{chan.channel_number}
<div className="epg-wrapper">
{/* Left fixed column for Channels */}
<div className="epg-channels-col">
<div className="epg-corner"></div>
<div className="epg-grid-rows">
{channels.map((chan, idx) => (
<div key={chan.id} className={`epg-row ${idx === selectedIndex ? 'active' : ''}`}>
<div className="epg-ch-num">{chan.channel_number}</div>
<div className="epg-ch-name">{chan.name}</div>
</div>
))}
</div>
</div>
{/* 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 */}
<div className="epg-time-axis">
{timeSlots.map((slot, i) => (
<div key={i} className="epg-time-slot" style={{ left: `${slot.left}px` }}>
{slot.label}
</div>
))}
</div>
<div className="guide-prog-col">
<div className="guide-prog-title">{chan.name} - {chan.currentlyPlaying.title}</div>
<div className="guide-prog-time">{chan.currentlyPlaying.time}</div>
{/* Live Playhead Line */}
{playheadPx > 0 && playheadPx < containerWidthPx && (
<div className="epg-playhead" style={{ left: `${playheadPx}px` }} />
)}
{/* Grid Rows for Airings */}
<div className="epg-grid-rows">
{channels.map((chan, idx) => {
const chanAirings = airings[chan.id];
const isLoading = !chanAirings && chan.id !== 99;
return (
<div key={chan.id} className={`epg-row ${idx === selectedIndex ? 'active' : ''}`}>
{isLoading && <div className="epg-loading">Loading...</div>}
{!isLoading && chanAirings?.length === 0 && (
<div className="epg-empty">No scheduled programs</div>
)}
{chanAirings && chanAirings.map(a => {
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);
const widthPx = Math.max(2, endPx - startPx);
let stateClass = 'future';
if (now >= sTs && now < eTs) stateClass = 'current';
if (now >= eTs) stateClass = 'past';
return (
<div
key={a.id}
className={`epg-program ${stateClass}`}
style={{ left: `${startPx}px`, width: `${widthPx}px` }}
onClick={() => onSelectChannel(chan.id)}
>
<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>
);
})}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
<div style={{ marginTop: '2rem', color: 'var(--pytv-text-dim)', textAlign: 'center' }}>
Press <span style={{color: '#fff'}}>Enter</span> to tune to the selected channel. Press <span style={{color: '#fff'}}>Escape</span> to exit guide.
<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
</div>
</div>
);

View File

@@ -0,0 +1,846 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
fetchUsers, createUser, deleteUser,
fetchChannels, createChannel, deleteChannel, updateChannel,
fetchChannelSources, assignSourceToChannel, removeSourceFromChannel,
fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday,
fetchSources, createSource, syncSource, deleteSource,
fetchLibraries,
fetchDownloadStatus, triggerCacheUpcoming, downloadItem,
} from '../api';
// ─── Constants ────────────────────────────────────────────────────────────
const TABS = [
{ id: 'channels', label: '📺 Channels' },
{ id: 'sources', label: '📡 Sources' },
{ id: 'downloads', label: '⬇ Downloads' },
{ id: 'schedule', label: '📅 Scheduling' },
{ id: 'users', label: '👤 Users' },
];
const SOURCE_TYPE_OPTIONS = [
{ value: 'youtube_channel', label: '▶ YouTube Channel' },
{ value: 'youtube_playlist', label: '▶ YouTube Playlist' },
{ value: 'local_directory', label: '📁 Local Directory' },
{ value: 'stream', label: '📡 Live Stream' },
];
const RULE_MODE_OPTIONS = [
{ value: 'allow', label: 'Allow' },
{ value: 'prefer', label: 'Prefer' },
{ value: 'avoid', label: 'Avoid' },
{ value: 'block', label: 'Block' },
];
// ─── Shared helpers ────────────────────────────────────────────────────────
function useFeedback() {
const [feedback, setFeedback] = useState(null);
const ok = (text) => setFeedback({ kind: 'success', text });
const err = (text) => setFeedback({ kind: 'error', text });
return [feedback, setFeedback, ok, err];
}
function Feedback({ fb, clear }) {
if (!fb) return null;
return (
<div className={`settings-feedback ${fb.kind}`}>
<span>{fb.text}</span>
<button onClick={clear}></button>
</div>
);
}
function EmptyState({ text }) {
return <p className="settings-empty">{text}</p>;
}
function IconBtn({ icon, label, kind = 'default', onClick, disabled, title }) {
return (
<button
onClick={onClick}
disabled={disabled}
title={title || label}
className={`icon-btn icon-btn-${kind}`}
>
{icon}
</button>
);
}
// ─── Users Tab ────────────────────────────────────────────────────────────
function UsersTab() {
const [users, setUsers] = useState([]);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ username: '', email: '', password: '', is_superuser: false });
const [feedback, setFeedback, ok, err] = useFeedback();
useEffect(() => { fetchUsers().then(setUsers).catch(() => err('Failed to load users')); }, []);
const handleCreate = async (e) => {
e.preventDefault();
try {
const user = await createUser(form);
setUsers(u => [...u, user]);
setForm({ username: '', email: '', password: '', is_superuser: false });
setShowForm(false);
ok(`User "${user.username}" created.`);
} catch { err('Failed to create user.'); }
};
const handleDelete = async (user) => {
if (!confirm(`Delete user "${user.username}"?`)) return;
try {
await deleteUser(user.id);
setUsers(u => u.filter(x => x.id !== user.id));
ok(`User "${user.username}" deleted.`);
} catch { err('Failed to delete user.'); }
};
return (
<div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} />
<div className="settings-section-title">
<h3>Users</h3>
<button className="btn-accent" onClick={() => setShowForm(f => !f)}>
{showForm ? '— Cancel' : '+ Add User'}
</button>
</div>
{showForm && (
<form className="settings-form" onSubmit={handleCreate}>
<label>Username<input required value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))} /></label>
<label>Email<input type="email" required value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} /></label>
<label>Password<input type="password" required value={form.password} onChange={e => setForm(f => ({ ...f, password: e.target.value }))} /></label>
<label className="checkbox-label">
<input type="checkbox" checked={form.is_superuser} onChange={e => setForm(f => ({ ...f, is_superuser: e.target.checked }))} />
Admin (superuser)
</label>
<button type="submit" className="btn-accent">Create User</button>
</form>
)}
<div className="settings-row-list">
{users.length === 0 && <EmptyState text="No users found." />}
{users.map(u => (
<div key={u.id} className="settings-row">
<div className="row-avatar">{u.username[0].toUpperCase()}</div>
<div className="row-info">
<strong>{u.username}</strong>
<span className="row-sub">{u.email}</span>
<span className="row-badges">
{u.is_superuser && <span className="badge badge-accent">Admin</span>}
{!u.is_active && <span className="badge badge-muted">Disabled</span>}
</span>
</div>
<div className="row-actions">
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(u)} title="Delete user" />
</div>
</div>
))}
</div>
</div>
);
}
// ─── Channels Tab ─────────────────────────────────────────────────────────
function ChannelsTab() {
const [channels, setChannels] = useState([]);
const [sources, setSources] = useState([]);
const [libraries, setLibraries] = useState([]);
const [users, setUsers] = useState([]);
const [expandedId, setExpandedId] = useState(null);
const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] }
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '' });
const [assignForm, setAssignForm] = useState({ source_id: '', rule_mode: 'allow', weight: 1.0 });
const [syncingId, setSyncingId] = useState(null);
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); })
.catch(() => err('Failed to load channels'));
}, []);
const toggleExpand = async (ch) => {
const next = expandedId === ch.id ? null : ch.id;
setExpandedId(next);
if (next && !channelSources[next]) {
try {
const rules = await fetchChannelSources(ch.id);
setChannelSources(cs => ({ ...cs, [ch.id]: rules }));
} catch { err('Failed to load channel sources'); }
}
};
const handleCreate = async (e) => {
e.preventDefault();
try {
const ch = await createChannel({
...form,
channel_number: form.channel_number ? parseInt(form.channel_number) : undefined,
library_id: parseInt(form.library_id),
owner_user_id: parseInt(form.owner_user_id),
});
setChannels(c => [...c, ch]);
setShowForm(false);
setForm({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '' });
ok(`Channel "${ch.name}" created.`);
} catch { err('Failed to create channel. Check slug is unique.'); }
};
const handleDelete = async (ch) => {
if (!confirm(`Delete channel "${ch.name}"?`)) return;
try {
await deleteChannel(ch.id);
setChannels(c => c.filter(x => x.id !== ch.id));
if (expandedId === ch.id) setExpandedId(null);
ok(`Channel "${ch.name}" deleted.`);
} catch { err('Failed to delete channel.'); }
};
const handleAssign = async (channelId) => {
if (!assignForm.source_id) { err('Select a source first.'); return; }
try {
const rule = await assignSourceToChannel(channelId, {
source_id: parseInt(assignForm.source_id),
rule_mode: assignForm.rule_mode,
weight: parseFloat(assignForm.weight),
});
setChannelSources(cs => ({ ...cs, [channelId]: [...(cs[channelId] || []), rule] }));
setAssignForm({ source_id: '', rule_mode: 'allow', weight: 1.0 });
ok('Source assigned to channel.');
} catch { err('Failed to assign source.'); }
};
const handleRemoveRule = async (channelId, ruleId) => {
try {
await removeSourceFromChannel(channelId, ruleId);
setChannelSources(cs => ({ ...cs, [channelId]: cs[channelId].filter(r => r.id !== ruleId) }));
ok('Source removed from channel.');
} catch { err('Failed to remove source.'); }
};
const handleGenerateToday = async (ch) => {
setSyncingId(ch.id);
try {
const result = await generateScheduleToday(ch.id);
ok(`Schedule generated for "${ch.name}": ${result.airings_created} airings created.`);
} catch { err('Failed to generate schedule.'); }
finally { setSyncingId(null); }
};
return (
<div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} />
<div className="settings-section-title">
<h3>Channels</h3>
<button className="btn-accent" onClick={() => setShowForm(f => !f)}>
{showForm ? '— Cancel' : '+ Add Channel'}
</button>
</div>
{showForm && (
<form className="settings-form" onSubmit={handleCreate}>
<div className="form-row">
<label>Name<input required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></label>
<label>Slug<input required placeholder="unique-slug" value={form.slug} onChange={e => setForm(f => ({ ...f, slug: e.target.value }))} /></label>
</div>
<div className="form-row">
<label>Channel #<input type="number" min="1" value={form.channel_number} onChange={e => setForm(f => ({ ...f, channel_number: e.target.value }))} /></label>
<label>Library
<select required value={form.library_id} onChange={e => setForm(f => ({ ...f, library_id: e.target.value }))}>
<option value=""> select </option>
{libraries.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label>Owner
<select required value={form.owner_user_id} onChange={e => setForm(f => ({ ...f, owner_user_id: e.target.value }))}>
<option value=""> select </option>
{users.map(u => <option key={u.id} value={u.id}>{u.username}</option>)}
</select>
</label>
</div>
<label>Description<input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} /></label>
<button type="submit" className="btn-accent">Create Channel</button>
</form>
)}
<div className="settings-row-list">
{channels.length === 0 && <EmptyState text="No channels configured." />}
{channels.map(ch => {
const isExpanded = expandedId === ch.id;
const rules = channelSources[ch.id] || [];
const hasRules = rules.length > 0;
return (
<div key={ch.id} className={`settings-row-expandable ${isExpanded ? 'expanded' : ''}`}>
{/* Main row */}
<div className="settings-row" onClick={() => toggleExpand(ch)}>
<div className="row-avatar ch-num">{ch.channel_number ?? '?'}</div>
<div className="row-info">
<strong>{ch.name}</strong>
<span className="row-sub">{ch.slug} · {ch.scheduling_mode}</span>
<span className="row-badges">
{!hasRules && isExpanded === false && channelSources[ch.id] !== undefined && (
<span className="badge badge-warn" style={{ marginLeft: '0.2rem' }}> Fallback Library Mode</span>
)}
</span>
</div>
<div className="row-actions" onClick={e => e.stopPropagation()}>
<button
className="btn-sync"
onClick={() => handleGenerateToday(ch)}
disabled={syncingId === ch.id}
title="Generate today's schedule"
>
{syncingId === ch.id ? '...' : '▶ Schedule'}
</button>
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(ch)} />
<span className="expand-chevron">{isExpanded ? '▲' : '▼'}</span>
</div>
</div>
{/* Expanded: source assignment panel */}
{isExpanded && (
<div className="channel-expand-panel">
<h4 className="expand-section-title">Assigned Sources</h4>
{!hasRules && (
<div className="settings-feedback error" style={{ marginBottom: '1rem', display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '0.5rem' }}>
<strong> No sources assigned.</strong>
<span style={{ fontSize: '0.9rem' }}>The scheduler will fall back to using ALL items in the library. This is usually not what you want. Please assign a specific source below.</span>
</div>
)}
{rules.length > 0 && rules.map(r => (
<div key={r.id} className="rule-row">
<span className="rule-source">{r.source_name}</span>
<span className={`rule-mode badge badge-mode-${r.rule_mode}`}>{r.rule_mode}</span>
<span className="rule-weight">×{r.weight}</span>
<IconBtn icon="✕" kind="danger" onClick={() => handleRemoveRule(ch.id, r.id)} title="Remove assignment" />
</div>
))}
{/* Assign form */}
<div className="assign-form" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<select
value={assignForm.source_id}
onChange={e => setAssignForm(f => ({ ...f, source_id: e.target.value }))}
style={{ flex: 1 }}
>
<option value=""> Select source </option>
{sources.map(s => <option key={s.id} value={s.id}>{s.name} ({s.source_type})</option>)}
</select>
<button
className="btn-accent"
onClick={() => {
if (!assignForm.source_id) { err('Select a source first.'); return; }
setAssignForm(f => ({ ...f, rule_mode: 'prefer', weight: 10.0 }));
// We wait for re-render state update before submit
setTimeout(() => handleAssign(ch.id), 0);
}}
title="Quick add as heavily preferred source to ensure it dominates schedule"
>
Set as Primary Source
</button>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', opacity: 0.8, fontSize: '0.9rem' }}>
<span>Or custom rule:</span>
<select
value={assignForm.rule_mode}
onChange={e => setAssignForm(f => ({ ...f, rule_mode: e.target.value }))}
>
{RULE_MODE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<input
type="number" min="0.1" max="10" step="0.1"
value={assignForm.weight}
onChange={e => setAssignForm(f => ({ ...f, weight: e.target.value }))}
style={{ width: 60 }}
title="Weight (higher = more airings)"
/>
<button className="btn-sync sm" onClick={() => handleAssign(ch.id)}>+ Add Rule</button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
// ─── Sources Tab ──────────────────────────────────────────────────────────
function SourcesTab() {
const [sources, setSources] = useState([]);
const [libraries, setLibraries] = useState([]);
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 [feedback, setFeedback, ok, err] = useFeedback();
useEffect(() => {
Promise.all([fetchSources(), fetchLibraries()])
.then(([s, l]) => { setSources(s); setLibraries(l); setLoading(false); })
.catch(() => { err('Failed to load sources'); setLoading(false); });
}, []);
const handleCreate = async (e) => {
e.preventDefault();
if (!form.library_id) { err('Please select a library.'); return; }
try {
const src = await createSource({ ...form, library_id: parseInt(form.library_id) });
setSources(s => [...s, src]);
setShowForm(false);
setForm({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50 });
ok(`Source "${src.name}" registered. Hit Sync to import videos.`);
} catch { err('Failed to create source.'); }
};
const handleSync = async (src) => {
setSyncingId(src.id);
setFeedback(null);
try {
const result = await syncSource(src.id, src.source_type.startsWith('youtube') ? form.max_videos || 50 : undefined);
setSources(s => s.map(x => x.id === src.id ? { ...x, last_scanned_at: new Date().toISOString() } : x));
ok(`Sync done: ${result.created} new, ${result.updated} updated, ${result.skipped} skipped.`);
} catch (e) {
err(`Sync failed: ${e?.response?.data?.detail || e.message}`);
} finally { setSyncingId(null); }
};
const handleDelete = async (src) => {
if (!confirm(`Delete "${src.name}"? This removes all its media items.`)) return;
try {
await deleteSource(src.id);
setSources(s => s.filter(x => x.id !== src.id));
ok(`Deleted "${src.name}".`);
} catch { err('Failed to delete source.'); }
};
const isYT = (src) => src.source_type.startsWith('youtube');
return (
<div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} />
<div className="settings-section-title">
<h3>Media Sources</h3>
<button className="btn-accent" onClick={() => setShowForm(f => !f)}>
{showForm ? '— Cancel' : '+ Add Source'}
</button>
</div>
{showForm && (
<form className="settings-form" onSubmit={handleCreate}>
<div className="form-row">
<label>Name<input required placeholder="ABC News" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></label>
<label>Type
<select value={form.source_type} onChange={e => setForm(f => ({ ...f, source_type: e.target.value }))}>
{SOURCE_TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
</div>
<label>URL / Path
<input required
placeholder={form.source_type.startsWith('youtube') ? 'https://www.youtube.com/@Channel' : '/mnt/media/movies'}
value={form.uri}
onChange={e => setForm(f => ({ ...f, uri: e.target.value }))}
/>
</label>
<div className="form-row">
<label>Library
<select required value={form.library_id} onChange={e => setForm(f => ({ ...f, library_id: e.target.value }))}>
<option value=""> select </option>
{libraries.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
{form.source_type.startsWith('youtube') && (
<label>Max Videos (sync cap)
<input type="number" min="1" max="5000" value={form.max_videos}
onChange={e => setForm(f => ({ ...f, max_videos: e.target.value }))} />
</label>
)}
</div>
<button type="submit" className="btn-accent">Register Source</button>
</form>
)}
<div className="settings-row-list">
{loading && <p className="settings-loading">Loading</p>}
{!loading && sources.length === 0 && <EmptyState text="No sources configured. Add one above." />}
{sources.map(src => {
const isSyncing = syncingId === src.id;
const synced = src.last_scanned_at;
return (
<div key={src.id} className="settings-row">
<div className="row-avatar">{isYT(src) ? '▶' : '📁'}</div>
<div className="row-info">
<strong>{src.name}</strong>
<span className="row-sub">{src.uri}</span>
<span className="row-badges">
<span className="badge badge-type">{src.source_type.replace('_', ' ')}</span>
{synced
? <span className="badge badge-ok">Synced {new Date(synced).toLocaleDateString()}</span>
: <span className="badge badge-warn">Not synced</span>
}
</span>
</div>
<div className="row-actions">
{isYT(src) && (
<button className="btn-sync" onClick={() => handleSync(src)} disabled={isSyncing}>
{isSyncing ? '…' : '↻ Sync'}
</button>
)}
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(src)} />
</div>
</div>
);
})}
</div>
<p className="settings-hint">
After syncing, assign the source to a channel in the <strong>Channels</strong> tab.
Downloads happen automatically via <code>manage.py cache_upcoming</code>.
</p>
</div>
);
}
// ─── Downloads Tab ───────────────────────────────────────────────────────
function DownloadsTab() {
const [status, setStatus] = useState(null); // { total, cached, items }
const [loading, setLoading] = useState(true);
const [hours, setHours] = useState(24);
const [running, setRunning] = useState(false);
const [runResult, setRunResult] = useState(null);
const [downloadingId, setDownloadingId] = useState(null);
const [filterSource, setFilterSource] = useState('all');
const [feedback, setFeedback, ok, err] = useFeedback();
const load = async () => {
setLoading(true);
try { setStatus(await fetchDownloadStatus()); }
catch { err('Failed to load download status.'); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const handleBulkDownload = async () => {
setRunning(true);
setRunResult(null);
setFeedback(null);
try {
const result = await triggerCacheUpcoming(hours);
setRunResult(result);
ok(`Done — ${result.downloaded} downloaded, ${result.already_cached} already cached, ${result.failed} failed.`);
await load(); // refresh the item list
} catch (e) {
err(`Failed: ${e?.response?.data?.detail || e.message}`);
} finally { setRunning(false); }
};
const handleDownloadOne = async (item) => {
setDownloadingId(item.id);
try {
await downloadItem(item.id);
ok(`Downloaded "${item.title.slice(0, 50)}".`);
setStatus(s => ({
...s,
cached: s.cached + 1,
items: s.items.map(i => i.id === item.id ? { ...i, cached: true } : i),
}));
} catch (e) {
err(`Download failed: ${e?.response?.data?.detail || e.message}`);
} finally { setDownloadingId(null); }
};
const sources = status ? [...new Set(status.items.map(i => i.source_name))] : [];
const visibleItems = status
? (filterSource === 'all' ? status.items : status.items.filter(i => i.source_name === filterSource))
: [];
const pct = status && status.total > 0 ? Math.round((status.cached / status.total) * 100) : 0;
return (
<div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} />
{/* Stats header */}
<div className="download-stats-header">
<div className="download-stat">
<span className="stat-val">{status?.total ?? '—'}</span>
<span className="stat-label">Total videos</span>
</div>
<div className="download-stat">
<span className="stat-val" style={{ color: '#86efac' }}>{status?.cached ?? '—'}</span>
<span className="stat-label">Cached locally</span>
</div>
<div className="download-stat">
<span className="stat-val" style={{ color: '#fcd34d' }}>{status ? status.total - status.cached : '—'}</span>
<span className="stat-label">Not downloaded</span>
</div>
<div className="download-cache-bar">
<div className="cache-bar-fill" style={{ width: `${pct}%` }} />
<span className="cache-bar-label">{pct}% cached</span>
</div>
</div>
{/* Bulk trigger */}
<div className="download-trigger-row">
<label className="trigger-hours-label">
Download window
<select value={hours} onChange={e => setHours(Number(e.target.value))}>
<option value={6}>Next 6 hours</option>
<option value={12}>Next 12 hours</option>
<option value={24}>Next 24 hours</option>
<option value={48}>Next 48 hours</option>
<option value={72}>Next 72 hours</option>
</select>
</label>
<button className="btn-accent" onClick={handleBulkDownload} disabled={running}>
{running ? '⏳ Downloading…' : '⬇ Download Upcoming'}
</button>
<button className="btn-sync" onClick={load} disabled={loading} title="Refresh status">
</button>
</div>
{runResult && (
<div className="run-result-box">
<span className="rr-item rr-ok"> {runResult.downloaded} downloaded</span>
<span className="rr-item rr-cached"> {runResult.already_cached} already cached</span>
<span className="rr-item rr-pruned">🗑 {runResult.pruned} pruned</span>
{runResult.failed > 0 && <span className="rr-item rr-fail"> {runResult.failed} failed</span>}
</div>
)}
{/* Filter by source */}
{sources.length > 1 && (
<div className="download-filter-row">
<span className="filter-label">Filter by source:</span>
<select value={filterSource} onChange={e => setFilterSource(e.target.value)}>
<option value="all">All sources</option>
{sources.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
)}
{/* Item list */}
<div className="settings-row-list">
{loading && <p className="settings-loading">Loading</p>}
{!loading && visibleItems.length === 0 && (
<EmptyState text="No YouTube videos found. Sync a source first." />
)}
{visibleItems.map(item => (
<div key={item.id} className={`settings-row download-item-row ${item.cached ? 'is-cached' : ''}`}>
<div className="row-avatar" style={{ fontSize: '0.8rem', background: item.cached ? 'rgba(34,197,94,0.12)' : 'rgba(255,255,255,0.05)', borderColor: item.cached ? 'rgba(34,197,94,0.3)' : 'var(--pytv-glass-border)', color: item.cached ? '#86efac' : 'var(--pytv-text-dim)' }}>
{item.cached ? '✓' : '▶'}
</div>
<div className="row-info">
<strong>{item.title}</strong>
<span className="row-sub">{item.source_name} · {item.runtime_seconds}s</span>
<span className="row-badges">
{item.cached
? <span className="badge badge-ok">Cached</span>
: <span className="badge badge-warn">Not downloaded</span>
}
<span className="badge badge-muted">{item.youtube_video_id}</span>
</span>
</div>
<div className="row-actions">
{!item.cached && (
<button
className="btn-sync"
onClick={() => handleDownloadOne(item)}
disabled={downloadingId === item.id}
title="Download this video now"
>
{downloadingId === item.id ? '…' : '⬇'}
</button>
)}
</div>
</div>
))}
</div>
</div>
);
}
// ─── Scheduling Tab ───────────────────────────────────────────────────────
function SchedulingTab() {
const [templates, setTemplates] = useState([]);
const [channels, setChannels] = useState([]);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ name: '', channel_id: '', timezone_name: 'America/New_York', priority: 10 });
const [feedback, setFeedback, ok, err] = useFeedback();
useEffect(() => {
Promise.all([fetchTemplates(), fetchChannels()])
.then(([t, c]) => { setTemplates(t); setChannels(c); })
.catch(() => err('Failed to load schedule data'));
}, []);
const handleCreate = async (e) => {
e.preventDefault();
try {
const tmpl = await createTemplate({
...form,
channel_id: parseInt(form.channel_id),
priority: parseInt(form.priority),
is_active: true,
});
setTemplates(t => [...t, tmpl]);
setShowForm(false);
setForm({ name: '', channel_id: '', timezone_name: 'America/New_York', priority: 10 });
ok(`Template "${tmpl.name}" created.`);
} catch { err('Failed to create template.'); }
};
const handleDelete = async (tmpl) => {
if (!confirm(`Delete template "${tmpl.name}"?`)) return;
try {
await deleteTemplate(tmpl.id);
setTemplates(t => t.filter(x => x.id !== tmpl.id));
ok(`Template deleted.`);
} catch { err('Failed to delete template.'); }
};
const handleGenerateAll = async () => {
const chIds = [...new Set(templates.map(t => t.channel_id))];
let total = 0;
for (const id of chIds) {
try { const r = await generateScheduleToday(id); total += r.airings_created; }
catch {}
}
ok(`Generated today's schedule: ${total} total airings created.`);
};
const channelName = (id) => channels.find(c => c.id === id)?.name ?? `Channel ${id}`;
return (
<div className="tab-content">
<Feedback fb={feedback} clear={() => setFeedback(null)} />
<div className="settings-section-title">
<h3>Schedule Templates</h3>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn-sync" onClick={handleGenerateAll}> Generate All Today</button>
<button className="btn-accent" onClick={() => setShowForm(f => !f)}>
{showForm ? '— Cancel' : '+ New Template'}
</button>
</div>
</div>
{showForm && (
<form className="settings-form" onSubmit={handleCreate}>
<div className="form-row">
<label>Template Name<input required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></label>
<label>Channel
<select required value={form.channel_id} onChange={e => setForm(f => ({ ...f, channel_id: e.target.value }))}>
<option value=""> select </option>
{channels.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</label>
</div>
<div className="form-row">
<label>Timezone
<input value={form.timezone_name} onChange={e => setForm(f => ({ ...f, timezone_name: e.target.value }))} />
</label>
<label>Priority (higher = wins)
<input type="number" min="0" max="100" value={form.priority}
onChange={e => setForm(f => ({ ...f, priority: e.target.value }))} />
</label>
</div>
<button type="submit" className="btn-accent">Create Template</button>
</form>
)}
<div className="settings-hint schedule-hint">
<strong>How scheduling works:</strong> Each channel can have multiple schedule templates.
Templates are tried in priority order. The scheduler fills the day by picking random
media items from the channel's assigned sources. <code>▶ Generate All Today</code> runs
this for every channel right now.
</div>
<div className="settings-row-list">
{templates.length === 0 && <EmptyState text="No schedule templates yet. Create one above." />}
{templates.map(t => (
<div key={t.id} className="settings-row">
<div className="row-avatar" style={{ fontSize: '1.2rem' }}>📄</div>
<div className="row-info">
<strong>{t.name}</strong>
<span className="row-sub">{channelName(t.channel_id)} · {t.timezone_name}</span>
<span className="row-badges">
<span className="badge badge-type">Priority {t.priority}</span>
{t.is_active
? <span className="badge badge-ok">Active</span>
: <span className="badge badge-muted">Inactive</span>
}
</span>
</div>
<div className="row-actions">
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(t)} />
</div>
</div>
))}
</div>
</div>
);
}
// ─── Root Settings Component ──────────────────────────────────────────────
export default function Settings({ onClose }) {
const [activeTab, setActiveTab] = useState('channels');
return (
<div className="settings-overlay">
<div className="settings-panel">
{/* Header */}
<div className="settings-header">
<span className="settings-logo">PYTV</span>
<h2>Settings</h2>
<button className="settings-close-btn" onClick={onClose}>✕ Close</button>
</div>
{/* Tab Bar */}
<div className="settings-tab-bar">
{TABS.map(tab => (
<button
key={tab.id}
className={`settings-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div className="tab-content-wrapper">
{activeTab === 'channels' && <ChannelsTab />}
{activeTab === 'sources' && <SourcesTab />}
{activeTab === 'downloads' && <DownloadsTab />}
{activeTab === 'schedule' && <SchedulingTab />}
{activeTab === 'users' && <UsersTab />}
</div>
</div>
</div>
);
}