|
|
|
|
@@ -5,9 +5,10 @@ import {
|
|
|
|
|
fetchChannelSources, assignSourceToChannel, removeSourceFromChannel,
|
|
|
|
|
fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday,
|
|
|
|
|
fetchTemplateBlocks, createTemplateBlock, deleteTemplateBlock,
|
|
|
|
|
fetchSources, createSource, syncSource, deleteSource,
|
|
|
|
|
fetchSources, createSource, syncSource, deleteSource, updateSource,
|
|
|
|
|
fetchLibraries, fetchCollections,
|
|
|
|
|
fetchDownloadStatus, triggerCacheUpcoming, downloadItem, fetchDownloadProgress,
|
|
|
|
|
fetchChannelStatus, triggerChannelDownload,
|
|
|
|
|
} from '../api';
|
|
|
|
|
|
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────────
|
|
|
|
|
@@ -164,10 +165,12 @@ function ChannelsTab() {
|
|
|
|
|
const [templateBlocks, setTemplateBlocks] = useState({}); // { templateId: [blocks] }
|
|
|
|
|
const [expandedId, setExpandedId] = useState(null);
|
|
|
|
|
const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] }
|
|
|
|
|
const [channelStatuses, setChannelStatuses] = useState({}); // { channelId: statusData }
|
|
|
|
|
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, schedule_block_label: '' });
|
|
|
|
|
const [syncingId, setSyncingId] = useState(null);
|
|
|
|
|
const [downloadingId, setDownloadingId] = useState(null);
|
|
|
|
|
const [feedback, setFeedback, ok, err] = useFeedback();
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
@@ -185,14 +188,24 @@ function ChannelsTab() {
|
|
|
|
|
.catch(() => err('Failed to load channels'));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const loadChannelStatus = async (channelId) => {
|
|
|
|
|
try {
|
|
|
|
|
const status = await fetchChannelStatus(channelId);
|
|
|
|
|
setChannelStatuses(prev => ({ ...prev, [channelId]: status }));
|
|
|
|
|
} catch { err('Failed to load channel caching status'); }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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'); }
|
|
|
|
|
if (next) {
|
|
|
|
|
if (!channelSources[next]) {
|
|
|
|
|
try {
|
|
|
|
|
const rules = await fetchChannelSources(ch.id);
|
|
|
|
|
setChannelSources(cs => ({ ...cs, [ch.id]: rules }));
|
|
|
|
|
} catch { err('Failed to load channel sources'); }
|
|
|
|
|
}
|
|
|
|
|
loadChannelStatus(next);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@@ -250,10 +263,21 @@ function ChannelsTab() {
|
|
|
|
|
try {
|
|
|
|
|
const result = await generateScheduleToday(ch.id);
|
|
|
|
|
ok(`Schedule generated for "${ch.name}": ${result.airings_created} airings created.`);
|
|
|
|
|
if (expandedId === ch.id) loadChannelStatus(ch.id);
|
|
|
|
|
} catch { err('Failed to generate schedule.'); }
|
|
|
|
|
finally { setSyncingId(null); }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDownload = async (ch) => {
|
|
|
|
|
setDownloadingId(ch.id);
|
|
|
|
|
try {
|
|
|
|
|
await triggerChannelDownload(ch.id);
|
|
|
|
|
ok(`Download triggered for "${ch.name}".`);
|
|
|
|
|
if (expandedId === ch.id) loadChannelStatus(ch.id);
|
|
|
|
|
} catch { err('Failed to trigger download.'); }
|
|
|
|
|
finally { setDownloadingId(null); }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSetFallback = async (ch, collectionId) => {
|
|
|
|
|
try {
|
|
|
|
|
const updated = await updateChannel(ch.id, { fallback_collection_id: collectionId ? parseInt(collectionId) : null });
|
|
|
|
|
@@ -327,6 +351,14 @@ function ChannelsTab() {
|
|
|
|
|
>
|
|
|
|
|
{syncingId === ch.id ? '...' : '▶ Schedule'}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="btn-sync"
|
|
|
|
|
onClick={() => handleDownload(ch)}
|
|
|
|
|
disabled={downloadingId === ch.id}
|
|
|
|
|
title="Download upcoming airings for this channel"
|
|
|
|
|
>
|
|
|
|
|
{downloadingId === ch.id ? '...' : '⬇ Download'}
|
|
|
|
|
</button>
|
|
|
|
|
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(ch)} />
|
|
|
|
|
<span className="expand-chevron">{isExpanded ? '▲' : '▼'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
@@ -336,6 +368,23 @@ function ChannelsTab() {
|
|
|
|
|
{isExpanded && (
|
|
|
|
|
<div className="channel-expand-panel">
|
|
|
|
|
|
|
|
|
|
{/* ─── Channel Status ──────────────────────────────────── */}
|
|
|
|
|
{channelStatuses[ch.id] && (
|
|
|
|
|
<div style={{ marginBottom: '1.25rem', padding: '0.75rem', background: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', borderRadius: '6px' }}>
|
|
|
|
|
<div style={{ fontWeight: 600, marginBottom: '0.4rem', color: '#60a5fa' }}>Schedule Status (Next 24 Hours)</div>
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', fontSize: '0.9rem' }}>
|
|
|
|
|
<span><strong>Total Upcoming:</strong> {channelStatuses[ch.id].total_upcoming_airings}</span>
|
|
|
|
|
<span><strong>Cached:</strong> {channelStatuses[ch.id].total_cached_airings} ({Math.round(channelStatuses[ch.id].percent_cached)}%)</span>
|
|
|
|
|
</div>
|
|
|
|
|
{channelStatuses[ch.id].missing_items?.length > 0 && (
|
|
|
|
|
<div style={{ marginTop: '0.75rem', fontSize: '0.8rem', opacity: 0.8 }}>
|
|
|
|
|
<strong>Missing Downloads:</strong> {channelStatuses[ch.id].missing_items.slice(0, 3).map(i => `[${i.source_name}] ${i.title}`).join(', ')}
|
|
|
|
|
{channelStatuses[ch.id].missing_items.length > 3 ? ` +${channelStatuses[ch.id].missing_items.length - 3} more` : ''}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* ─── Fallback block selector ───────────────────────── */}
|
|
|
|
|
<div style={{ marginBottom: '1.25rem', padding: '0.75rem', background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: '6px' }}>
|
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', fontSize: '0.9rem' }}>
|
|
|
|
|
@@ -454,7 +503,8 @@ function SourcesTab() {
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [syncingId, setSyncingId] = useState(null);
|
|
|
|
|
const [showForm, setShowForm] = useState(false);
|
|
|
|
|
const [form, setForm] = useState({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60 });
|
|
|
|
|
const [editingId, setEditingId] = useState(null);
|
|
|
|
|
const [form, setForm] = useState({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60, min_video_length_seconds: '', max_video_length_seconds: '', min_repeat_gap_hours: '', max_age_days: '' });
|
|
|
|
|
const [feedback, setFeedback, ok, err] = useFeedback();
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
@@ -463,20 +513,51 @@ function SourcesTab() {
|
|
|
|
|
.catch(() => { err('Failed to load sources'); setLoading(false); });
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleCreate = async (e) => {
|
|
|
|
|
const handleEdit = (src) => {
|
|
|
|
|
setForm({
|
|
|
|
|
name: src.name,
|
|
|
|
|
source_type: src.source_type,
|
|
|
|
|
uri: src.uri,
|
|
|
|
|
library_id: src.library_id,
|
|
|
|
|
max_videos: src.max_videos || 50,
|
|
|
|
|
scan_interval_minutes: src.scan_interval_minutes || '',
|
|
|
|
|
min_video_length_seconds: src.min_video_length_seconds || '',
|
|
|
|
|
max_video_length_seconds: src.max_video_length_seconds || '',
|
|
|
|
|
min_repeat_gap_hours: src.min_repeat_gap_hours || '',
|
|
|
|
|
max_age_days: src.max_age_days || '',
|
|
|
|
|
});
|
|
|
|
|
setEditingId(src.id);
|
|
|
|
|
setShowForm(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async (e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!form.library_id) { err('Please select a library.'); return; }
|
|
|
|
|
try {
|
|
|
|
|
const src = await createSource({
|
|
|
|
|
const payload = {
|
|
|
|
|
...form,
|
|
|
|
|
library_id: parseInt(form.library_id),
|
|
|
|
|
scan_interval_minutes: parseInt(form.scan_interval_minutes) || null
|
|
|
|
|
});
|
|
|
|
|
setSources(s => [...s, src]);
|
|
|
|
|
scan_interval_minutes: parseInt(form.scan_interval_minutes) || null,
|
|
|
|
|
min_video_length_seconds: parseInt(form.min_video_length_seconds) || null,
|
|
|
|
|
max_video_length_seconds: parseInt(form.max_video_length_seconds) || null,
|
|
|
|
|
min_repeat_gap_hours: parseInt(form.min_repeat_gap_hours) || null,
|
|
|
|
|
max_age_days: parseInt(form.max_age_days) || null
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (editingId) {
|
|
|
|
|
const updated = await updateSource(editingId, payload);
|
|
|
|
|
setSources(s => s.map(x => x.id === editingId ? updated : x));
|
|
|
|
|
ok(`Source "${updated.name}" updated.`);
|
|
|
|
|
} else {
|
|
|
|
|
const src = await createSource(payload);
|
|
|
|
|
setSources(s => [...s, src]);
|
|
|
|
|
ok(`Source "${src.name}" registered. Hit Sync to import videos.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setShowForm(false);
|
|
|
|
|
setForm({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60 });
|
|
|
|
|
ok(`Source "${src.name}" registered. Hit Sync to import videos.`);
|
|
|
|
|
} catch { err('Failed to create source.'); }
|
|
|
|
|
setEditingId(null);
|
|
|
|
|
setForm({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60, min_video_length_seconds: '', max_video_length_seconds: '', min_repeat_gap_hours: '', max_age_days: '' });
|
|
|
|
|
} catch { err(`Failed to ${editingId ? 'update' : 'create'} source.`); }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSync = async (src) => {
|
|
|
|
|
@@ -507,13 +588,21 @@ function SourcesTab() {
|
|
|
|
|
<Feedback fb={feedback} clear={() => setFeedback(null)} />
|
|
|
|
|
<div className="settings-section-title">
|
|
|
|
|
<h3>Media Sources</h3>
|
|
|
|
|
<button className="btn-accent" onClick={() => setShowForm(f => !f)}>
|
|
|
|
|
<button className="btn-accent" onClick={() => {
|
|
|
|
|
if (showForm) {
|
|
|
|
|
setShowForm(false);
|
|
|
|
|
setEditingId(null);
|
|
|
|
|
setForm({ name: '', source_type: 'youtube_playlist', uri: '', library_id: '', max_videos: 50, scan_interval_minutes: 60, min_video_length_seconds: '', max_video_length_seconds: '', min_repeat_gap_hours: '', max_age_days: '' });
|
|
|
|
|
} else {
|
|
|
|
|
setShowForm(true);
|
|
|
|
|
}
|
|
|
|
|
}}>
|
|
|
|
|
{showForm ? '— Cancel' : '+ Add Source'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{showForm && (
|
|
|
|
|
<form className="settings-form" onSubmit={handleCreate}>
|
|
|
|
|
<form className="settings-form" onSubmit={handleSubmit}>
|
|
|
|
|
<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
|
|
|
|
|
@@ -548,7 +637,21 @@ function SourcesTab() {
|
|
|
|
|
title="How often background workers should fetch new metadata updates" />
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<button type="submit" className="btn-accent">Register Source</button>
|
|
|
|
|
<div className="form-row">
|
|
|
|
|
<label>Min Length (sec)
|
|
|
|
|
<input type="number" placeholder="none" value={form.min_video_length_seconds} onChange={e => setForm(f => ({ ...f, min_video_length_seconds: e.target.value }))} />
|
|
|
|
|
</label>
|
|
|
|
|
<label>Max Length (sec)
|
|
|
|
|
<input type="number" placeholder="none" value={form.max_video_length_seconds} onChange={e => setForm(f => ({ ...f, max_video_length_seconds: e.target.value }))} />
|
|
|
|
|
</label>
|
|
|
|
|
<label>Min Repeat Gap (hrs)
|
|
|
|
|
<input type="number" placeholder="none" value={form.min_repeat_gap_hours} onChange={e => setForm(f => ({ ...f, min_repeat_gap_hours: e.target.value }))} />
|
|
|
|
|
</label>
|
|
|
|
|
<label>Max Age (days)
|
|
|
|
|
<input type="number" placeholder="none" value={form.max_age_days} onChange={e => setForm(f => ({ ...f, max_age_days: e.target.value }))} title="Skip videos uploaded older than this" />
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<button type="submit" className="btn-accent">{editingId ? 'Update Source' : 'Register Source'}</button>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
@@ -570,9 +673,14 @@ function SourcesTab() {
|
|
|
|
|
? <span className="badge badge-ok">Synced {new Date(synced).toLocaleDateString()}</span>
|
|
|
|
|
: <span className="badge badge-warn">Not synced</span>
|
|
|
|
|
}
|
|
|
|
|
{src.min_video_length_seconds && <span className="badge badge-info">Min {src.min_video_length_seconds}s</span>}
|
|
|
|
|
{src.max_video_length_seconds && <span className="badge badge-info">Max {src.max_video_length_seconds}s</span>}
|
|
|
|
|
{src.min_repeat_gap_hours && <span className="badge badge-info">Gap {src.min_repeat_gap_hours}h</span>}
|
|
|
|
|
{src.max_age_days && <span className="badge badge-info">Age {'<='} {src.max_age_days}d</span>}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="row-actions">
|
|
|
|
|
<IconBtn icon="✎" onClick={() => handleEdit(src)} title="Edit Source" />
|
|
|
|
|
{isYT(src) && (
|
|
|
|
|
<button className="btn-sync" onClick={() => handleSync(src)} disabled={isSyncing}>
|
|
|
|
|
{isSyncing ? '…' : '↻ Sync'}
|
|
|
|
|
|