Files
jukebox_maker/web/templates/dashboard.html
Michael Chus 9fd02fb5bf Add per-disk profiles with video transcoding support
Each disk stores .jukebox/profile.json with copy parameters (dest
folder, overwrite mode, file select, reserve space, auto-copy) and
optional transcoding limits for the target player (codec, resolution,
bitrate, FPS, audio channels, output format).

On copy, video files are probed with ffprobe; if they exceed the
profile limits they are transcoded via ffmpeg (-threads 0 for full
CPU usage), otherwise copied as-is. Scale filter never upscales.

New: internal/disk/profile.go, internal/transcoder/{detect,transcoder}.go
API: GET/PUT /api/disks/profile?mount_path=
UI: disk profile panel in dashboard for known disks
Dockerfile: adds ffmpeg to the runtime image

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:52:46 +03:00

442 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "content"}}
<section class="panel">
<h2>Mounted Disk</h2>
<div class="panel-body">
<div class="path-input-row">
<input class="form-input" type="text" id="mountPath" placeholder="/Volumes/JUKEBOX or E:\\">
<button type="button" class="button-primary" onclick="pickMountPath()">+</button>
<button type="button" class="button-secondary" onclick="refreshSelectedDisk()">Refresh</button>
</div>
<div class="form-hint">Choose the directory where the removable disk is mounted. The app works with one selected disk at a time in standalone mode.</div>
</div>
</section>
<div id="diskState"></div>
<script>
const selectedDisk = { info: null };
const taskState = new Map();
const taskPollers = new Map();
function escapeHTML(value) {
return String(value || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function badgeClass(state) {
return ({ absent: 'badge-unknown', foreign: 'badge-warn', known: 'badge-ok' })[state] || 'badge-unknown';
}
function badgeLabel(state) {
return ({ absent: 'Directory unavailable', foreign: 'Uninitialized disk', known: 'Ready' })[state] || '—';
}
function fmtSpeed(bps) {
if (!bps) return '';
if (bps >= 1e9) return (bps / 1e9).toFixed(1) + ' GB/s';
if (bps >= 1e6) return (bps / 1e6).toFixed(1) + ' MB/s';
if (bps >= 1e3) return (bps / 1e3).toFixed(0) + ' KB/s';
return bps + ' B/s';
}
function fmtETA(sec) {
if (!sec || sec <= 0) return '';
if (sec >= 3600) return Math.floor(sec / 3600) + ' h ' + Math.floor((sec % 3600) / 60) + ' min';
if (sec >= 60) return Math.floor(sec / 60) + ' min';
return sec + ' s';
}
function fmtDateTime(value) {
if (!value) return 'Never';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function taskMeta(task) {
if (!task) return '';
return [fmtSpeed(task.speed_bps), task.eta_sec ? 'ETA: ' + fmtETA(task.eta_sec) : ''].filter(Boolean).join(' · ');
}
function renderDisk() {
const root = document.getElementById('diskState');
const disk = selectedDisk.info;
if (!disk) {
root.innerHTML = `
<section class="panel">
<div class="panel-body text-muted">Choose a mounted disk directory to inspect it.</div>
</section>
`;
return;
}
const activeTask = disk.active_task_id ? taskState.get(disk.active_task_id) : null;
const progress = activeTask ? activeTask.progress : 0;
const message = activeTask ? (activeTask.message || 'Preparing...') : '';
const meta = activeTask ? taskMeta(activeTask) : '';
const isKnown = disk.state === 'known';
const isForeign = disk.state === 'foreign';
const hasCapacity = disk.state !== 'absent';
root.innerHTML = `
<section class="panel disk-card">
<h2>${escapeHTML(disk.mount_path)}</h2>
<table class="kv-table">
<tbody>
<tr>
<th>Status</th>
<td><span class="badge ${badgeClass(disk.state)}">${badgeLabel(disk.state)}</span></td>
</tr>
<tr>
<th>Disk ID</th>
<td>${disk.disk_id ? `<span class="mono">${escapeHTML(disk.disk_id)}</span>` : '<span class="text-muted">not initialized yet</span>'}</td>
</tr>
<tr>
<th>Total capacity</th>
<td>${hasCapacity ? fmtBytes(disk.total_bytes) : '—'}</td>
</tr>
<tr>
<th>Free space</th>
<td>${hasCapacity ? fmtBytes(disk.free_bytes) : '—'}</td>
</tr>
<tr>
<th>Last copied</th>
<td>${fmtDateTime(disk.last_copied_at)}</td>
</tr>
</tbody>
</table>
${activeTask ? `
<div class="panel-body progress-wrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" style="width:${progress}%"></div>
</div>
<div class="progress-label">${escapeHTML(message)}</div>
<div class="progress-label">${escapeHTML(meta)}</div>
</div>
` : ''}
<div class="btn-row">
${isKnown ? `
<button class="button-danger" data-action="start-copy" data-mode="replace" ${activeTask ? 'disabled' : ''}>Replace media</button>
<button class="button-primary" data-action="start-copy" data-mode="add" ${activeTask ? 'disabled' : ''}>Add media</button>
<button class="button-danger ${activeTask ? '' : 'hidden'}" data-action="cancel-copy">Cancel</button>
` : ''}
${isForeign ? `
<button class="button-secondary" data-action="init-disk">Initialize disk</button>
` : ''}
</div>
</section>
${isKnown ? renderProfile(disk) : ''}
`;
}
function renderProfile(disk) {
const p = disk.profile || {};
const t = p.transcode || null;
const transcodeEnabled = !!t;
const sel = (name, value, options) => {
const opts = options.map(([v, label]) =>
`<option value="${v}" ${v === value ? 'selected' : ''}>${escapeHTML(label)}</option>`
).join('');
return `<select class="form-input" id="prof_${name}">${opts}</select>`;
};
const transcodeSection = `
<div id="transcodeFields" style="${transcodeEnabled ? '' : 'display:none'}">
<div class="form-group">
<label>Видеокодек</label>
${sel('video_codec', t?.video_codec || 'h264', [['h264','H.264 (AVC)'],['h265','H.265 (HEVC)'],['mpeg4','MPEG-4']])}
</div>
<div class="form-group">
<label>Макс. разрешение</label>
${sel('max_resolution', t?.max_resolution || '720p', [['480p','480p'],['720p','720p (HD)'],['1080p','1080p (Full HD)']])}
</div>
<div class="form-group">
<label>Макс. битрейт видео</label>
${sel('max_video_bitrate', t?.max_video_bitrate || '2000k', [['','Без лимита'],['1000k','1000 кбит/с'],['2000k','2000 кбит/с'],['4000k','4000 кбит/с'],['8000k','8000 кбит/с']])}
</div>
<div class="form-group">
<label>Макс. FPS</label>
${sel('max_fps', String(t?.max_fps ?? 0), [['0','Без лимита'],['24','24'],['25','25'],['30','30']])}
</div>
<hr>
<div class="form-group">
<label>Аудиокодек</label>
${sel('audio_codec', t?.audio_codec || 'aac', [['aac','AAC'],['mp3','MP3']])}
</div>
<div class="form-group">
<label>Макс. битрейт аудио</label>
${sel('max_audio_bitrate', t?.max_audio_bitrate || '192k', [['','Без лимита'],['128k','128 кбит/с'],['192k','192 кбит/с'],['320k','320 кбит/с']])}
</div>
<div class="form-group">
<label>Каналы</label>
${sel('max_audio_channels', String(t?.max_audio_channels ?? 0), [['0','Копировать'],['2','Стерео (2.0)'],['6','5.1']])}
</div>
<hr>
<div class="form-group">
<label>Формат контейнера</label>
${sel('output_format', t?.output_format || 'mp4', [['mp4','MP4'],['mkv','MKV'],['avi','AVI']])}
</div>
</div>
`;
return `
<section class="panel" id="profilePanel">
<h2>Профиль диска</h2>
<div class="panel-body">
<h3>Параметры копирования</h3>
<div class="form-group">
<label>Папка назначения</label>
<input class="form-input" type="text" id="prof_dest_folder" value="${escapeHTML(p.dest_folder || 'media')}">
</div>
<div class="form-group">
<label>Режим перезаписи</label>
${sel('overwrite_mode', p.overwrite_mode || 'skip', [['skip','Пропускать существующие'],['delete','Заменять всё']])}
</div>
<div class="form-group">
<label>Выбор файлов</label>
${sel('file_select_mode', p.file_select_mode || 'new', [['new','Только новые'],['all','Все подходящие']])}
</div>
<div class="form-group">
<label>Резерв свободного места (ГБ)</label>
<input class="form-input" type="number" id="prof_reserve_free_gb" value="${p.reserve_free_gb ?? 2}" min="0" step="0.5">
</div>
<div class="form-group">
<label><input type="checkbox" id="prof_auto_copy" ${p.auto_copy ? 'checked' : ''}> Автокопирование при подключении</label>
</div>
<h3 style="margin-top:1.5em">Транскодирование видео</h3>
<div class="form-group">
<label>
<input type="checkbox" id="prof_transcode_enabled" ${transcodeEnabled ? 'checked' : ''}
onchange="document.getElementById('transcodeFields').style.display=this.checked?'':'none'">
Ограничить видео под устройство
</label>
</div>
${transcodeSection}
<div class="btn-row" style="margin-top:1em">
<button class="button-primary" onclick="saveProfile('${escapeHTML(disk.mount_path)}')">Сохранить профиль</button>
</div>
</div>
</section>
`;
}
async function saveProfile(mountPath) {
const g = id => document.getElementById(id);
const transcodeEnabled = g('prof_transcode_enabled')?.checked;
const profile = {
dest_folder: g('prof_dest_folder')?.value.trim() || 'media',
overwrite_mode: g('prof_overwrite_mode')?.value || 'skip',
file_select_mode: g('prof_file_select_mode')?.value || 'new',
reserve_free_gb: parseFloat(g('prof_reserve_free_gb')?.value || '2') || 0,
auto_copy: g('prof_auto_copy')?.checked || false,
};
if (transcodeEnabled) {
profile.transcode = {
video_codec: g('prof_video_codec')?.value || 'h264',
max_resolution: g('prof_max_resolution')?.value || '720p',
max_video_bitrate: g('prof_max_video_bitrate')?.value || '',
max_fps: parseInt(g('prof_max_fps')?.value || '0', 10),
audio_codec: g('prof_audio_codec')?.value || 'aac',
max_audio_bitrate: g('prof_max_audio_bitrate')?.value || '',
max_audio_channels: parseInt(g('prof_max_audio_channels')?.value || '0', 10),
output_format: g('prof_output_format')?.value || 'mp4',
};
}
try {
const response = await fetch('/api/disks/profile?mount_path=' + encodeURIComponent(mountPath), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profile)
});
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Ошибка сохранения профиля', 'error');
return;
}
toast('Профиль сохранён', 'ok');
refreshSelectedDisk();
} catch (error) {
toast('Ошибка сети', 'error');
}
}
function stopTaskPoll(taskID) {
if (!taskPollers.has(taskID)) return;
clearInterval(taskPollers.get(taskID));
taskPollers.delete(taskID);
}
function startTaskPoll(taskID) {
if (!taskID || taskPollers.has(taskID)) return;
taskPollers.set(taskID, setInterval(() => pollTask(taskID), 1500));
pollTask(taskID);
}
async function pickFolder() {
const response = await fetch('/api/system/pick-folder', { method: 'POST' });
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Failed to choose folder');
}
return payload.path || '';
}
async function pickMountPath() {
try {
const path = await pickFolder();
if (!path) return;
document.getElementById('mountPath').value = path;
localStorage.setItem('jukebox.selectedMountPath', path);
await refreshSelectedDisk();
} catch (error) {
toast(error.message || 'Failed to choose folder', 'error');
}
}
async function refreshSelectedDisk() {
const mountPath = document.getElementById('mountPath').value.trim();
if (!mountPath) {
selectedDisk.info = null;
renderDisk();
return;
}
localStorage.setItem('jukebox.selectedMountPath', mountPath);
try {
const response = await fetch('/api/disks/probe?mount_path=' + encodeURIComponent(mountPath));
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Failed to inspect directory', 'error');
return;
}
selectedDisk.info = payload;
renderDisk();
if (payload.active_task_id) {
for (const taskID of Array.from(taskPollers.keys())) {
if (taskID !== payload.active_task_id) {
stopTaskPoll(taskID);
taskState.delete(taskID);
}
}
startTaskPoll(payload.active_task_id);
} else {
for (const taskID of Array.from(taskPollers.keys())) {
stopTaskPoll(taskID);
taskState.delete(taskID);
}
}
} catch (error) {
toast('Network error', 'error');
}
}
async function pollTask(taskID) {
try {
const response = await fetch('/api/tasks/' + taskID);
if (!response.ok) return;
const task = await response.json();
taskState.set(taskID, task);
renderDisk();
if (['success', 'failed', 'canceled'].includes(task.status)) {
stopTaskPoll(taskID);
taskState.delete(taskID);
if (task.status === 'success') toast(task.message || 'Done', 'ok');
if (task.status === 'failed') toast('Error: ' + task.error, 'error');
if (task.status === 'canceled') toast('Copy canceled', 'error');
refreshSelectedDisk();
}
} catch (error) {}
}
async function startCopy(mode) {
const mountPath = document.getElementById('mountPath').value.trim();
if (!mountPath) return;
try {
const response = await fetch('/api/disks/copy/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mount_path: mountPath, mode })
});
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Failed to start copy', 'error');
return;
}
startTaskPoll(payload.task_id);
refreshSelectedDisk();
} catch (error) {
toast('Network error', 'error');
}
}
async function cancelCopy() {
if (!selectedDisk.info || !selectedDisk.info.disk_id) return;
try {
await fetch('/api/disks/' + encodeURIComponent(selectedDisk.info.disk_id) + '/copy/cancel', { method: 'POST' });
toast('Canceling...', 'ok');
} catch (error) {
toast('Network error', 'error');
}
}
async function initDisk() {
const mountPath = document.getElementById('mountPath').value.trim();
if (!mountPath) return;
try {
const response = await fetch('/api/disks/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mount_path: mountPath })
});
const payload = await response.json();
if (!response.ok) {
toast(payload.error || 'Failed to initialize disk', 'error');
return;
}
toast('Disk initialized', 'ok');
refreshSelectedDisk();
} catch (error) {
toast('Network error', 'error');
}
}
document.getElementById('diskState').addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
if (!button) return;
const action = button.dataset.action;
if (action === 'start-copy') startCopy(button.dataset.mode || 'add');
if (action === 'cancel-copy') cancelCopy();
if (action === 'init-disk') initDisk();
});
const savedMountPath = localStorage.getItem('jukebox.selectedMountPath');
if (savedMountPath) {
document.getElementById('mountPath').value = savedMountPath;
refreshSelectedDisk();
} else {
renderDisk();
}
</script>
{{end}}