Refactor capture engine architecture, rename PictureStream to PictureSource, and split API modules

- Separate CaptureEngine into stateless factory + stateful CaptureStream session
- Add LiveStream/LiveStreamManager for shared capture with reference counting
- Rename PictureStream to PictureSource across storage, API, and UI
- Remove legacy migration logic and unused compatibility code
- Split monolithic routes.py (1935 lines) into 5 focused route modules
- Split schemas.py (480 lines) into 7 schema modules with re-exports
- Extract dependency injection into dedicated dependencies.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 14:27:00 +03:00
parent b8389f080a
commit c3828e10fa
42 changed files with 4047 additions and 3797 deletions

View File

@@ -91,7 +91,7 @@ function closeLightbox(event) {
async function openFullImageLightbox(imageSource) {
try {
const resp = await fetch(`${API_BASE}/picture-streams/full-image?source=${encodeURIComponent(imageSource)}`, {
const resp = await fetch(`${API_BASE}/picture-sources/full-image?source=${encodeURIComponent(imageSource)}`, {
headers: getHeaders()
});
if (!resp.ok) return;
@@ -402,7 +402,7 @@ function updateAllText() {
if (apiKey) {
loadDisplays();
loadDevices();
loadPictureStreams();
loadPictureSources();
}
}
@@ -576,15 +576,11 @@ async function loadDisplays() {
let _cachedDisplays = null;
function switchTab(name) {
// Migrate legacy tab values from localStorage
if (name === 'templates' || name === 'pp-templates') {
name = 'streams';
}
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
localStorage.setItem('activeTab', name);
if (name === 'streams') {
loadPictureStreams();
loadPictureSources();
}
}
@@ -2532,7 +2528,7 @@ async function loadCaptureTemplates() {
const data = await response.json();
_cachedCaptureTemplates = data.templates || [];
// Re-render the streams tab which now contains template sections
renderPictureStreamsList(_cachedStreams);
renderPictureSourcesList(_cachedStreams);
} catch (error) {
console.error('Error loading capture templates:', error);
}
@@ -2540,7 +2536,7 @@ async function loadCaptureTemplates() {
// Get engine icon
function getEngineIcon(engineType) {
return '🖥️';
return '🚀';
}
// Show add template modal
@@ -2786,7 +2782,7 @@ async function loadAvailableEngines() {
availableEngines = data.engines || [];
const select = document.getElementById('template-engine');
select.innerHTML = `<option value="">${t('templates.engine.select')}</option>`;
select.innerHTML = '';
availableEngines.forEach(engine => {
const option = document.createElement('option');
@@ -2798,6 +2794,12 @@ async function loadAvailableEngines() {
}
select.appendChild(option);
});
// Auto-select first available engine if nothing selected
if (!select.value) {
const firstAvailable = availableEngines.find(e => e.available);
if (firstAvailable) select.value = firstAvailable.type;
}
} catch (error) {
console.error('Error loading engines:', error);
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
@@ -3085,14 +3087,14 @@ async function deleteTemplate(templateId) {
}
}
// ===== Picture Streams =====
// ===== Picture Sources =====
let _cachedStreams = [];
let _cachedPPTemplates = [];
let _cachedCaptureTemplates = [];
let _availableFilters = []; // Loaded from GET /filters
async function loadPictureStreams() {
async function loadPictureSources() {
try {
// Always fetch templates, filters, and streams in parallel
// since templates are now rendered inside stream sub-tabs
@@ -3100,7 +3102,7 @@ async function loadPictureStreams() {
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
fetchWithAuth('/postprocessing-templates'),
fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-streams')
fetchWithAuth('/picture-sources')
]);
if (filtersResp && filtersResp.ok) {
@@ -3120,9 +3122,9 @@ async function loadPictureStreams() {
}
const data = await streamsResp.json();
_cachedStreams = data.streams || [];
renderPictureStreamsList(_cachedStreams);
renderPictureSourcesList(_cachedStreams);
} catch (error) {
console.error('Error loading picture streams:', error);
console.error('Error loading picture sources:', error);
document.getElementById('streams-list').innerHTML = `
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
`;
@@ -3139,7 +3141,7 @@ function switchStreamTab(tabKey) {
localStorage.setItem('activeStreamTab', tabKey);
}
function renderPictureStreamsList(streams) {
function renderPictureSourcesList(streams) {
const container = document.getElementById('streams-list');
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
@@ -3157,7 +3159,7 @@ function renderPictureStreamsList(streams) {
detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop" title="${t('streams.display')}">🖥️ ${stream.display_index ?? 0}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</span>
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">📷 ${capTmplName}</span>` : ''}
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">📋 ${capTmplName}</span>` : ''}
</div>`;
} else if (stream.stream_type === 'processed') {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
@@ -3169,7 +3171,7 @@ function renderPictureStreamsList(streams) {
}
detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop" title="${t('streams.source')}">📺 ${sourceName}</span>
${ppTmplName ? `<span class="stream-card-prop" title="${t('streams.pp_template')}">🎨 ${ppTmplName}</span>` : ''}
${ppTmplName ? `<span class="stream-card-prop" title="${t('streams.pp_template')}">📋 ${ppTmplName}</span>` : ''}
</div>`;
} else if (stream.stream_type === 'static_image') {
const src = stream.image_source || '';
@@ -3208,12 +3210,12 @@ function renderPictureStreamsList(streams) {
<button class="card-remove-btn" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
${engineIcon} ${escapeHtml(template.name)}
📋 ${escapeHtml(template.name)}
</div>
</div>
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('templates.engine')}">⚙️ ${template.engine_type.toUpperCase()}</span>
<span class="stream-card-prop" title="${t('templates.engine')}">🚀 ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">🔧 ${configEntries.length}</span>` : ''}
</div>
${configEntries.length > 0 ? `
@@ -3252,7 +3254,7 @@ function renderPictureStreamsList(streams) {
<button class="card-remove-btn" onclick="deletePPTemplate('${tmpl.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="template-card-header">
<div class="template-name">
🎨 ${escapeHtml(tmpl.name)}
📋 ${escapeHtml(tmpl.name)}
</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
@@ -3390,7 +3392,7 @@ async function showAddStreamModal(presetType) {
async function editStream(streamId) {
try {
const response = await fetchWithAuth(`/picture-streams/${streamId}`);
const response = await fetchWithAuth(`/picture-sources/${streamId}`);
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
const stream = await response.json();
@@ -3450,7 +3452,7 @@ async function populateStreamModalDropdowns() {
const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
fetchWithAuth('/picture-streams'),
fetchWithAuth('/picture-sources'),
fetchWithAuth('/postprocessing-templates'),
]);
@@ -3556,12 +3558,12 @@ async function saveStream() {
try {
let response;
if (streamId) {
response = await fetchWithAuth(`/picture-streams/${streamId}`, {
response = await fetchWithAuth(`/picture-sources/${streamId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
} else {
response = await fetchWithAuth('/picture-streams', {
response = await fetchWithAuth('/picture-sources', {
method: 'POST',
body: JSON.stringify(payload)
});
@@ -3574,7 +3576,7 @@ async function saveStream() {
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
closeStreamModal();
await loadPictureStreams();
await loadPictureSources();
} catch (error) {
console.error('Error saving stream:', error);
errorEl.textContent = error.message;
@@ -3587,7 +3589,7 @@ async function deleteStream(streamId) {
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/picture-streams/${streamId}`, {
const response = await fetchWithAuth(`/picture-sources/${streamId}`, {
method: 'DELETE'
});
@@ -3597,7 +3599,7 @@ async function deleteStream(streamId) {
}
showToast(t('streams.deleted'), 'success');
await loadPictureStreams();
await loadPictureSources();
} catch (error) {
console.error('Error deleting stream:', error);
showToast(t('streams.error.delete') + ': ' + error.message, 'error');
@@ -3635,7 +3637,7 @@ async function validateStaticImage() {
previewContainer.style.display = 'none';
try {
const response = await fetchWithAuth('/picture-streams/validate-image', {
const response = await fetchWithAuth('/picture-sources/validate-image', {
method: 'POST',
body: JSON.stringify({ image_source: source }),
});
@@ -3662,7 +3664,7 @@ async function validateStaticImage() {
}
}
// ===== Picture Stream Test =====
// ===== Picture Source Test =====
let _currentTestStreamId = null;
@@ -3701,7 +3703,7 @@ async function runStreamTest() {
showOverlaySpinner(t('streams.test.running'), captureDuration);
try {
const response = await fetchWithAuth(`/picture-streams/${_currentTestStreamId}/test`, {
const response = await fetchWithAuth(`/picture-sources/${_currentTestStreamId}/test`, {
method: 'POST',
body: JSON.stringify({ capture_duration: captureDuration })
});
@@ -3740,7 +3742,7 @@ async function showTestPPTemplateModal(templateId) {
// Ensure streams are cached
if (_cachedStreams.length === 0) {
try {
const resp = await fetchWithAuth('/picture-streams');
const resp = await fetchWithAuth('/picture-sources');
if (resp.ok) { const d = await resp.json(); _cachedStreams = d.streams || []; }
} catch (e) { console.warn('Could not load streams for PP test:', e); }
}
@@ -3842,7 +3844,7 @@ async function loadPPTemplates() {
const data = await response.json();
_cachedPPTemplates = data.templates || [];
// Re-render the streams tab which now contains template sections
renderPictureStreamsList(_cachedStreams);
renderPictureSourcesList(_cachedStreams);
} catch (error) {
console.error('Error loading PP templates:', error);
}
@@ -4163,7 +4165,7 @@ async function showStreamSelector(deviceId) {
try {
const [deviceResponse, streamsResponse, settingsResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetchWithAuth('/picture-streams'),
fetchWithAuth('/picture-sources'),
fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() }),
]);
@@ -4195,7 +4197,7 @@ async function showStreamSelector(deviceId) {
});
}
const currentStreamId = device.picture_stream_id || '';
const currentStreamId = device.picture_source_id || '';
streamSelect.value = currentStreamId;
// Populate LED projection fields
@@ -4238,7 +4240,7 @@ async function updateStreamSelectorInfo(streamId) {
}
try {
const response = await fetchWithAuth(`/picture-streams/${streamId}`);
const response = await fetchWithAuth(`/picture-sources/${streamId}`);
if (!response.ok) {
infoPanel.style.display = 'none';
return;
@@ -4266,12 +4268,12 @@ async function updateStreamSelectorInfo(streamId) {
propsHtml = `
<span class="stream-card-prop" title="${t('streams.display')}">🖥️ ${stream.display_index ?? 0}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</span>
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">📷 ${capTmplName}</span>` : ''}
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">📋 ${capTmplName}</span>` : ''}
`;
} else if (stream.stream_type === 'processed') {
if ((!_cachedStreams || _cachedStreams.length === 0) && stream.source_stream_id) {
try {
const streamsResp = await fetchWithAuth('/picture-streams');
const streamsResp = await fetchWithAuth('/picture-sources');
if (streamsResp.ok) { const d = await streamsResp.json(); _cachedStreams = d.streams || []; }
} catch {}
}
@@ -4292,7 +4294,7 @@ async function updateStreamSelectorInfo(streamId) {
}
propsHtml = `
<span class="stream-card-prop" title="${t('streams.source')}">📺 ${sourceName}</span>
${ppTmplName ? `<span class="stream-card-prop" title="${t('streams.pp_template')}">🎨 ${ppTmplName}</span>` : ''}
${ppTmplName ? `<span class="stream-card-prop" title="${t('streams.pp_template')}">📋 ${ppTmplName}</span>` : ''}
`;
} else if (stream.stream_type === 'static_image') {
const src = stream.image_source || '';
@@ -4313,18 +4315,18 @@ async function updateStreamSelectorInfo(streamId) {
async function saveStreamSelector() {
const deviceId = document.getElementById('stream-selector-device-id').value;
const pictureStreamId = document.getElementById('stream-selector-stream').value;
const pictureSourceId = document.getElementById('stream-selector-stream').value;
const borderWidth = parseInt(document.getElementById('stream-selector-border-width').value) || 10;
const interpolation = document.getElementById('stream-selector-interpolation').value;
const smoothing = parseFloat(document.getElementById('stream-selector-smoothing').value);
const errorEl = document.getElementById('stream-selector-error');
try {
// Save picture stream assignment
// Save picture source assignment
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ picture_stream_id: pictureStreamId })
body: JSON.stringify({ picture_source_id: pictureSourceId })
});
if (response.status === 401) {