feat(main): main

This commit is contained in:
2026-03-09 08:26:45 -04:00
parent f37382d2b8
commit f14454b4c8
12 changed files with 598 additions and 62 deletions

View File

@@ -34,6 +34,13 @@ export const deleteTemplate = async (id) => { await apiClient.delete(`/schedule/
export const generateScheduleToday = async (channelId) =>
(await apiClient.post(`/schedule/generate-today/${channelId}`)).data;
export const fetchTemplateBlocks = async (templateId) =>
(await apiClient.get(`/schedule/template/${templateId}/blocks`)).data;
export const createTemplateBlock = async (payload) =>
(await apiClient.post('/schedule/block/', payload)).data;
export const deleteTemplateBlock = async (blockId) =>
(await apiClient.delete(`/schedule/block/${blockId}`)).data;
// Legacy used by guide
export const fetchScheduleGenerations = async (channelId) =>
(await apiClient.post(`/schedule/generate/${channelId}`)).data;

View File

@@ -142,6 +142,24 @@ export default function ChannelTuner({ onOpenGuide }) {
muted={!isCurrent}
loop
playsInline
onLoadedMetadata={(e) => {
const video = e.target;
if (currentAiring && currentAiring.starts_at) {
const startTime = new Date(currentAiring.starts_at).getTime();
const nowTime = Date.now();
if (nowTime > startTime) {
const offsetSeconds = (nowTime - startTime) / 1000;
// If the video is shorter than the offset (e.g. repeating a short clip),
// modulo the offset by duration to emulate a continuous loop.
if (video.duration && video.duration > 0) {
video.currentTime = offsetSeconds % video.duration;
} else {
video.currentTime = offsetSeconds;
}
}
}
}}
onError={(e) => {
if (e.target.src !== chan.fallbackFile) {
console.warn(`Video failed to load: ${e.target.src}, falling back.`);

View File

@@ -4,6 +4,7 @@ import {
fetchChannels, createChannel, deleteChannel, updateChannel,
fetchChannelSources, assignSourceToChannel, removeSourceFromChannel,
fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday,
fetchTemplateBlocks, createTemplateBlock, deleteTemplateBlock,
fetchSources, createSource, syncSource, deleteSource,
fetchLibraries,
fetchDownloadStatus, triggerCacheUpcoming, downloadItem,
@@ -156,7 +157,7 @@ function ChannelsTab() {
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 [assignForm, setAssignForm] = useState({ source_id: '', rule_mode: 'allow', weight: 1.0, schedule_block_label: '' });
const [syncingId, setSyncingId] = useState(null);
const [feedback, setFeedback, ok, err] = useFeedback();
@@ -210,9 +211,10 @@ function ChannelsTab() {
source_id: parseInt(assignForm.source_id),
rule_mode: assignForm.rule_mode,
weight: parseFloat(assignForm.weight),
schedule_block_label: assignForm.schedule_block_label || null,
});
setChannelSources(cs => ({ ...cs, [channelId]: [...(cs[channelId] || []), rule] }));
setAssignForm({ source_id: '', rule_mode: 'allow', weight: 1.0 });
setAssignForm({ source_id: '', rule_mode: 'allow', weight: 1.0, schedule_block_label: '' });
ok('Source assigned to channel.');
} catch { err('Failed to assign source.'); }
};
@@ -367,6 +369,13 @@ function ChannelsTab() {
style={{ width: 60 }}
title="Weight (higher = more airings)"
/>
<input
placeholder="Target Block Label (Optional)"
value={assignForm.schedule_block_label}
onChange={e => setAssignForm(f => ({ ...f, schedule_block_label: e.target.value }))}
style={{ flex: 1 }}
title="If set, this source will ONLY play during blocks with this exact name"
/>
<button className="btn-sync sm" onClick={() => handleAssign(ch.id)}>+ Add Rule</button>
</div>
@@ -690,12 +699,35 @@ function SchedulingTab() {
const [form, setForm] = useState({ name: '', channel_id: '', timezone_name: 'America/New_York', priority: 10 });
const [feedback, setFeedback, ok, err] = useFeedback();
// Block Editor State
const [expandedTmplId, setExpandedTmplId] = useState(null);
const [templateBlocks, setTemplateBlocks] = useState({}); // { tmplId: [blocks] }
const [blockForm, setBlockForm] = useState({
name: 'A Block',
block_type: 'PROGRAM',
start_local_time: '08:00',
end_local_time: '12:00',
day_of_week_mask: 127,
target_content_rating: ''
});
useEffect(() => {
Promise.all([fetchTemplates(), fetchChannels()])
.then(([t, c]) => { setTemplates(t); setChannels(c); })
.catch(() => err('Failed to load schedule data'));
}, []);
const toggleExpand = async (tmpl) => {
const next = expandedTmplId === tmpl.id ? null : tmpl.id;
setExpandedTmplId(next);
if (next && !templateBlocks[next]) {
try {
const blocks = await fetchTemplateBlocks(tmpl.id);
setTemplateBlocks(tb => ({ ...tb, [tmpl.id]: blocks }));
} catch { err('Failed to load blocks'); }
}
};
const handleCreate = async (e) => {
e.preventDefault();
try {
@@ -780,25 +812,105 @@ function SchedulingTab() {
<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>
{templates.map(t => {
const isExpanded = expandedTmplId === t.id;
const blocks = templateBlocks[t.id] || [];
return (
<div key={t.id} className={`settings-row-expandable ${isExpanded ? 'expanded' : ''}`}>
<div className="settings-row" onClick={() => toggleExpand(t)}>
<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" onClick={e => e.stopPropagation()}>
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(t)} />
<span className="expand-chevron">{isExpanded ? '' : ''}</span>
</div>
</div>
{isExpanded && (
<div className="channel-expand-panel block-editor" style={{ background: 'rgba(0,0,0,0.1)', borderTop: 'none', padding: '1rem', borderBottomLeftRadius: '6px', borderBottomRightRadius: '6px' }}>
<h4 style={{ margin: '0 0 1rem 0', opacity: 0.9 }}>Schedule Blocks</h4>
{blocks.length === 0 && (
<div style={{ fontSize: '0.9rem', opacity: 0.7, marginBottom: '1rem' }}>
No blocks defined. By default, PYTV acts as if there is a single 24/7 block. If you define blocks here, you must completely cover the 24 hours of a day to avoid dead air.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.5rem' }}>
{blocks.map(b => (
<div key={b.id} style={{ display: 'flex', gap: '0.5rem', background: '#353b48', padding: '0.5rem', borderRadius: '4px', alignItems: 'center', fontSize: '0.9rem' }}>
<strong style={{ minWidth: 100 }}>{b.name}</strong>
<span style={{ fontFamily: 'monospace', opacity: 0.8 }}>{b.start_local_time.slice(0,5)} - {b.end_local_time.slice(0,5)}</span>
<span className={`badge ${b.block_type === 'OFF_AIR' ? 'badge-warn' : 'badge-ok'}`}>{b.block_type}</span>
{b.target_content_rating && <span className="badge badge-type">Rating Tier: {b.target_content_rating}</span>}
<div style={{ flex: 1 }} />
<IconBtn icon="✕" kind="danger" onClick={async () => {
try {
await deleteTemplateBlock(b.id);
setTemplateBlocks(tb => ({ ...tb, [t.id]: tb[t.id].filter(x => x.id !== b.id) }));
ok('Block deleted.');
} catch { err('Failed to delete block.'); }
}} />
</div>
))}
</div>
<form className="assign-form" style={{ background: '#2f3640' }} onSubmit={async (e) => {
e.preventDefault();
if (!blockForm.name || !blockForm.start_local_time || !blockForm.end_local_time) { err('Fill req fields'); return; }
try {
const nb = await createTemplateBlock({
schedule_template_id: t.id,
name: blockForm.name,
block_type: blockForm.block_type,
start_local_time: blockForm.start_local_time,
end_local_time: blockForm.end_local_time,
day_of_week_mask: parseInt(blockForm.day_of_week_mask),
target_content_rating: blockForm.target_content_rating ? parseInt(blockForm.target_content_rating) : null,
});
setTemplateBlocks(tb => ({ ...tb, [t.id]: [...(tb[t.id] || []), nb] }));
ok('Block created.');
} catch { err('Failed to create block'); }
}}>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input placeholder="Block Name (e.g. Morning News)" required style={{ flex: 1 }} value={blockForm.name} onChange={e => setBlockForm(f => ({...f, name: e.target.value}))} />
<select value={blockForm.block_type} onChange={e => setBlockForm(f => ({...f, block_type: e.target.value}))}>
<option value="PROGRAM">Programming</option>
<option value="OFF_AIR">Off Air / Dead Time</option>
</select>
<input type="time" required value={blockForm.start_local_time} onChange={e => setBlockForm(f => ({...f, start_local_time: e.target.value}))} />
<span style={{ opacity: 0.5 }}>to</span>
<input type="time" required value={blockForm.end_local_time} onChange={e => setBlockForm(f => ({...f, end_local_time: e.target.value}))} />
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginTop: '0.5rem' }}>
<select value={blockForm.target_content_rating} onChange={e => setBlockForm(f => ({...f, target_content_rating: e.target.value}))}>
<option value="">Any content rating</option>
<option value="1">TV-Y</option>
<option value="2">TV-Y7</option>
<option value="3">TV-G</option>
<option value="4">TV-PG</option>
<option value="5">TV-14</option>
<option value="6">TV-MA</option>
</select>
<button type="submit" className="btn-sync sm">+ Add Block</button>
</div>
</form>
</div>
)}
</div>
<div className="row-actions">
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(t)} />
</div>
</div>
))}
);
})}
</div>
</div>
);