feat(main): main

This commit is contained in:
2026-03-10 08:39:28 -04:00
parent b1a93161c0
commit af3076342a
18 changed files with 826 additions and 38 deletions

View File

@@ -26,6 +26,12 @@ export const removeSourceFromChannel = async (channelId, ruleId) => {
await apiClient.delete(`/channel/${channelId}/sources/${ruleId}`);
};
// Channel Actions
export const fetchChannelStatus = async (channelId) =>
(await apiClient.get(`/channel/${channelId}/status`)).data;
export const triggerChannelDownload = async (channelId) =>
(await apiClient.post(`/channel/${channelId}/download`)).data;
// ── Schedule ──────────────────────────────────────────────────────────────
export const fetchTemplates = async () => (await apiClient.get('/schedule/template/')).data;
export const createTemplate = async (payload) =>
@@ -48,6 +54,7 @@ export const fetchScheduleGenerations = async (channelId) =>
// ── Media Sources (YouTube / local) ───────────────────────────────────────
export const fetchSources = async () => (await apiClient.get('/sources/')).data;
export const createSource = async (payload) => (await apiClient.post('/sources/', payload)).data;
export const updateSource = async (id, payload) => (await apiClient.put(`/sources/${id}`, payload)).data;
export const syncSource = async (sourceId, maxVideos) => {
const url = maxVideos ? `/sources/${sourceId}/sync?max_videos=${maxVideos}` : `/sources/${sourceId}/sync`;
return (await apiClient.post(url)).data;

View File

@@ -144,13 +144,37 @@ export default function ChannelTuner({ onOpenGuide }) {
muted={!isCurrent}
loop
playsInline
ref={(video) => {
if (video && video.readyState >= 1) { // HAVE_METADATA or higher
const currentAiring = nowPlaying[chan.id];
if (currentAiring && video.dataset.airingId !== String(currentAiring.id)) {
video.dataset.airingId = currentAiring.id;
if (currentAiring.exact_playback_offset_seconds !== undefined) {
let offset = currentAiring.exact_playback_offset_seconds;
if (video.duration && video.duration > 0 && !isNaN(video.duration)) {
offset = offset % video.duration;
}
video.currentTime = offset;
}
}
}
}}
onLoadedMetadata={(e) => {
const video = e.target;
const currentAiring = nowPlaying[chan.id];
if (currentAiring?.starts_at) {
const offsetSeconds = (Date.now() - new Date(currentAiring.starts_at).getTime()) / 1000;
if (offsetSeconds > 0 && video.duration > 0) {
video.currentTime = offsetSeconds % video.duration;
if (currentAiring && video.dataset.airingId !== String(currentAiring.id)) {
video.dataset.airingId = currentAiring.id;
if (currentAiring.exact_playback_offset_seconds !== undefined) {
let offset = currentAiring.exact_playback_offset_seconds;
if (video.duration && video.duration > 0 && !isNaN(video.duration)) {
offset = offset % video.duration;
}
video.currentTime = offset;
} else if (currentAiring.starts_at) {
const offsetSeconds = (Date.now() - new Date(currentAiring.starts_at).getTime()) / 1000;
if (offsetSeconds > 0 && video.duration > 0) {
video.currentTime = offsetSeconds % video.duration;
}
}
}
}}

View File

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

View File

@@ -12,8 +12,7 @@ export default defineConfig({
},
'/media': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/media/, '')
changeOrigin: true
}
}
}