feat(main): commit
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 · <span style={{ color: '#fff' }}>↑↓</span> to navigate channels · <span style={{ color: '#fff' }}>Escape</span> to close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
846
frontend/src/components/Settings.jsx
Normal file
846
frontend/src/components/Settings.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user