Switch admin/API flows to article IDs with legacy job fallback

This commit is contained in:
hbrain 2026-05-31 18:04:21 +02:00
parent 3cc2828bc0
commit efcf87d00c
5 changed files with 205 additions and 67 deletions

66
new.php
View file

@ -84,7 +84,7 @@ function load_articles_index($base, $dirs = null){
foreach ($dirs as $dir) {
$name = basename((string)$dir);
$id = read_article_id($dir);
if (($id === '' || !empty($byId[$id])) && !is_dir($dir . '/.movmaker-lock')) {
if ($id === '' || !empty($byId[$id])) {
$id = ensure_article_id($dir, $byId);
}
if ($id !== '' && empty($byId[$id])) $byId[$id] = $name;
@ -561,13 +561,14 @@ function active_worker_jobs($config){
foreach (input_dirs($config['uploads_dir']) as $dir) {
$name = basename($dir);
$state = input_dir_state($dir);
$articleId = (string)($state['article_id'] ?? '') ?: ensure_article_id($dir);
$status = (string)($state['status'] ?? '');
$locked = is_dir($dir . '/.movmaker-lock');
if ($locked || $status === 'processing') {
$started = '';
if (is_file($dir . '/.movmaker-lock/started_at')) $started = trim((string)file_get_contents($dir . '/.movmaker-lock/started_at'));
if ($started === '' && !empty($state)) $started = (string)($state['started_at'] ?? $state['updated_at'] ?? '');
$jobs[] = ['name'=>$name,'status'=>$status ?: ($locked ? 'processing' : 'unknown'),'started_at'=>$started];
$jobs[] = ['name'=>$name,'id'=>$articleId,'status'=>$status ?: ($locked ? 'processing' : 'unknown'),'started_at'=>$started];
}
}
return $jobs;
@ -763,7 +764,7 @@ try {
if ($visibleNow && $canShow) {
if (is_file(__DIR__ . '/lib/send_push.php')) {
include_once __DIR__ . '/lib/send_push.php';
try_send_show_notification(basename($dir), $dir, __DIR__, '/var/log/mvlog_notify.log');
try_send_show_notification($articleId ?: basename($dir), $dir, __DIR__, '/var/log/mvlog_notify.log');
} else {
error_log("[mvlog] send_push helper missing, cannot send notification for " . basename($dir) . "\n", 3, '/var/log/mvlog_notify.log');
}
@ -942,7 +943,7 @@ $runningJobs = active_worker_jobs($config);
<?php if($err): ?><div class="err"><?=h($err)?></div><?php endif; ?>
<?php if($tab==='videos'): ?><section><h2>Videos without input dir</h2><?php if(!$orphanVideos): ?><p>No orphan videos.</p><?php endif; ?><div class="admin-list"><?php foreach($orphanVideosPage as $v): $vn=basename($v); ?><form method="post" class="admin-item" onsubmit="return confirm('Delete this generated video?') js-ajax-form"><div><strong><?=h($vn)?></strong><br><span><?=h(round(filesize($v)/1048576,1))?> MB</span></div><input type="hidden" name="action" value="delete_video"><input type="hidden" name="video" value="<?=h($vn)?>"><button type="submit">Delete video</button></form><?php endforeach; ?></div><?=page_links('videos',$videoPage,$videoPages)?></section><?php endif; ?>
<?php if($tab==='edit' && !$editDir): ?><section><form method="get" class="admin-search-form"><input type="hidden" name="tab" value="edit"><div class="admin-search-wrap"><input name="q" value="<?=h($rawKeyword)?>" placeholder="Search title/description or use date=... / location=..." aria-label="Search input dirs"><a class="admin-search-clear <?=$searchEmpty ? 'is-empty' : ''?>" href="new.php?tab=edit" aria-label="Clear filter" title="Clear filter">×</a></div><div class="admin-search-filters"><label class="admin-filter-item" title="Preview"><input type="checkbox" name="f_preview" value="1" <?=$filterPreview?'checked':''?>><span>P</span></label><label class="admin-filter-item" title="Render"><input type="checkbox" name="f_render" value="1" <?=$filterRender?'checked':''?>><span>R</span></label><label class="admin-filter-item" title="Show"><input type="checkbox" name="f_shown" value="1" <?=$filterShown?'checked':''?>><span>S</span></label></div><button type="submit" class="admin-search-submit" aria-label="Filter"><span class="icon" aria-hidden="true">🔍</span><span class="sr-only">Filter</span></button></form><?php if(!$dirs): ?><p>No input directories yet.</p><?php endif; ?><div class="admin-list video-list"><?php foreach($dirsPage as $d): $runStatus=input_dir_status($d); $running=input_dir_running($d); $enabled=input_dir_enabled($d); $preview=input_dir_preview($d); $state=input_dir_state($d); $fullOutput=basename((string)($state['output'] ?? '')); $previewOutput=basename((string)($state['preview_output'] ?? '')); $hasFullVideo=$fullOutput !== '' && is_file($config['videos_dir'].'/'.$fullOutput); $hasPreviewVideo=$previewOutput !== '' && is_file($config['videos_dir'].'/'.$previewOutput); $displayOutput=($preview && $hasPreviewVideo) ? $previewOutput : ($hasFullVideo ? $fullOutput : ($hasPreviewVideo ? $previewOutput : '')); $hasVideo=$displayOutput !== ''; $displayVideoPath=$hasVideo ? $config['videos_dir'].'/'.$displayOutput : ''; $previewVideo=$hasVideo && $displayOutput === $previewOutput; $canShow=$hasFullVideo && !$preview; $visible=$canShow && input_dir_visible($d); $info=$dirInfo[basename($d)] ?? input_dir_info($d); $data=read_data($d); $meta=cached_video_metadata($displayOutput, $data); $currentSignature=$info['signature'] ?? ''; $fullFingerprint=is_array($state)?(string)($state['fingerprint'] ?? ''):''; $previewFingerprint=is_array($state)?(string)($state['preview_fingerprint'] ?? ''):''; $editedSinceRender=$hasFullVideo && $currentSignature !== '' && $fullFingerprint !== '' && $currentSignature !== $fullFingerprint; $editedSincePreview=!$hasFullVideo && $previewVideo && $currentSignature !== '' && $previewFingerprint !== '' && $currentSignature !== $previewFingerprint; $staleLabel=$editedSinceRender ? 'Edited since render' : ($editedSincePreview ? 'Edited since preview' : ''); ?><article class="video-row admin-video-row<?= $visible ? ' shown' : '' ?>" data-job="<?=h(basename($d))?>"><div><?php if($hasVideo): ?><div class="video-wrapper"><video controls preload="metadata" src="<?=h($config['public_videos'].'/'.rawurlencode($displayOutput))?>"></video><?php $map_n = ($displayOutput !== '' ? pathinfo($displayOutput, PATHINFO_FILENAME) : basename($d)) . '.png'; if(is_file($config['videos_dir'].'/'.$map_n)): ?><a class="map-image-link" href="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>"><img class="map-image" src="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>" alt="Map"></a><?php endif; ?></div><?php else: ?><div class="video-placeholder"><img src="assets/img/moto_travel.png" alt=""><span>No video yet</span></div><?php endif; ?></div><div class="video-info"><div class="admin-row-top"><div class="admin-switches">
<?php if($tab==='edit' && !$editDir): ?><section><form method="get" class="admin-search-form"><input type="hidden" name="tab" value="edit"><div class="admin-search-wrap"><input name="q" value="<?=h($rawKeyword)?>" placeholder="Search title/description or use date=... / location=..." aria-label="Search input dirs"><a class="admin-search-clear <?=$searchEmpty ? 'is-empty' : ''?>" href="new.php?tab=edit" aria-label="Clear filter" title="Clear filter">×</a></div><div class="admin-search-filters"><label class="admin-filter-item" title="Preview"><input type="checkbox" name="f_preview" value="1" <?=$filterPreview?'checked':''?>><span>P</span></label><label class="admin-filter-item" title="Render"><input type="checkbox" name="f_render" value="1" <?=$filterRender?'checked':''?>><span>R</span></label><label class="admin-filter-item" title="Show"><input type="checkbox" name="f_shown" value="1" <?=$filterShown?'checked':''?>><span>S</span></label></div><button type="submit" class="admin-search-submit" aria-label="Filter"><span class="icon" aria-hidden="true">🔍</span><span class="sr-only">Filter</span></button></form><?php if(!$dirs): ?><p>No input directories yet.</p><?php endif; ?><div class="admin-list video-list"><?php foreach($dirsPage as $d): $runStatus=input_dir_status($d); $running=input_dir_running($d); $enabled=input_dir_enabled($d); $preview=input_dir_preview($d); $state=input_dir_state($d); $fullOutput=basename((string)($state['output'] ?? '')); $previewOutput=basename((string)($state['preview_output'] ?? '')); $hasFullVideo=$fullOutput !== '' && is_file($config['videos_dir'].'/'.$fullOutput); $hasPreviewVideo=$previewOutput !== '' && is_file($config['videos_dir'].'/'.$previewOutput); $displayOutput=($preview && $hasPreviewVideo) ? $previewOutput : ($hasFullVideo ? $fullOutput : ($hasPreviewVideo ? $previewOutput : '')); $hasVideo=$displayOutput !== ''; $displayVideoPath=$hasVideo ? $config['videos_dir'].'/'.$displayOutput : ''; $previewVideo=$hasVideo && $displayOutput === $previewOutput; $canShow=$hasFullVideo && !$preview; $visible=$canShow && input_dir_visible($d); $info=$dirInfo[basename($d)] ?? input_dir_info($d); $data=read_data($d); $meta=cached_video_metadata($displayOutput, $data); $currentSignature=$info['signature'] ?? ''; $fullFingerprint=is_array($state)?(string)($state['fingerprint'] ?? ''):''; $previewFingerprint=is_array($state)?(string)($state['preview_fingerprint'] ?? ''):''; $editedSinceRender=$hasFullVideo && $currentSignature !== '' && $fullFingerprint !== '' && $currentSignature !== $fullFingerprint; $editedSincePreview=!$hasFullVideo && $previewVideo && $currentSignature !== '' && $previewFingerprint !== '' && $currentSignature !== $previewFingerprint; $staleLabel=$editedSinceRender ? 'Edited since render' : ($editedSincePreview ? 'Edited since preview' : ''); ?><article class="video-row admin-video-row<?= $visible ? ' shown' : '' ?>" data-job="<?=h(basename($d))?>" data-id="<?=h(read_article_id($d))?>"><div><?php if($hasVideo): ?><div class="video-wrapper"><video controls preload="metadata" src="<?=h($config['public_videos'].'/'.rawurlencode($displayOutput))?>"></video><?php $map_n = ($displayOutput !== '' ? pathinfo($displayOutput, PATHINFO_FILENAME) : basename($d)) . '.png'; if(is_file($config['videos_dir'].'/'.$map_n)): ?><a class="map-image-link" href="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>"><img class="map-image" src="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>" alt="Map"></a><?php endif; ?></div><?php else: ?><div class="video-placeholder"><img src="assets/img/moto_travel.png" alt=""><span>No video yet</span></div><?php endif; ?></div><div class="video-info"><div class="admin-row-top"><div class="admin-switches">
<form method="post" class="inline-form switch-form" data-switch="preview">
<input type="hidden" name="action" value="set_preview">
<input type="hidden" name="dir" value="<?=h(basename($d))?>">
@ -1319,8 +1320,10 @@ async function handleSwitchToggle(form, checkbox){
}
function applySwitchPayload(payload){
if(!payload || !payload.dir) return;
const row = document.querySelector('.admin-video-row[data-job="'+payload.dir+'"]');
if(!payload) return;
let row = null;
if(payload.id) row = document.querySelector('.admin-video-row[data-id="'+payload.id+'"]');
if(!row && payload.dir) row = document.querySelector('.admin-video-row[data-job="'+payload.dir+'"]');
if(!row) return;
function setSwitch(type, value){
const target = row.querySelector('form[data-switch="'+type+'"] input[type="checkbox"]');
@ -1366,12 +1369,13 @@ function applySwitchPayload(payload){
if (m > 0) return m + 'm ' + s + 's';
return s + 's';
}
function syncEditButtons(runningJobs){
function syncEditButtons(runningIds, runningJobs){
document.querySelectorAll('.admin-video-row[data-job]').forEach(function(row){
const job=row.dataset.job || '';
const articleId=row.dataset.id || '';
const edit=row.querySelector('[data-edit-action="1"]');
if(!edit) return;
const running=runningJobs.has(job);
const running=(articleId && runningIds.has(articleId)) || runningJobs.has(job);
if(edit.tagName === 'A'){
if(running){
if(!edit.dataset.editHref) edit.dataset.editHref = edit.getAttribute('href') || '';
@ -1397,9 +1401,10 @@ function applySwitchPayload(payload){
const r=await fetch('job_status.php',{cache:'no-store'});
if(!r.ok) throw new Error('status failed');
const data=await r.json();
const jobs=(data.jobs||[]).filter(function(job){ return job && job.name; });
const runningJobs=new Set(jobs.map(function(job){ return String(job.name); }));
syncEditButtons(runningJobs);
const jobs=(data.jobs||[]).filter(function(job){ return job && (job.name || job.id); });
const runningIds=new Set(jobs.map(function(job){ return String(job.id || ''); }).filter(function(v){ return v !== ''; }));
const runningJobs=new Set(jobs.map(function(job){ return String(job.name || ''); }).filter(function(v){ return v !== ''; }));
syncEditButtons(runningIds, runningJobs);
if(!jobs.length){
box.hidden=true;
box.innerHTML='';
@ -1418,7 +1423,6 @@ function applySwitchPayload(payload){
<footer>Input dirs are saved under <code>in-dir/</code>.</footer>
<footer class="site-footer admin-footer"><div class="admin-left"><nav class="tabs"><a class="<?= $tab==='new'?'active':'' ?>" href="new.php?tab=new">New</a><a class="<?= $tab==='edit'?'active':'' ?>" href="new.php?tab=edit">Edit</a><a class="<?= $tab==='videos'?'active':'' ?>" href="new.php?tab=videos">Videos</a></nav></div><div id="job-status" class="job-status"<?= empty($runningJobs) ? ' hidden' : '' ?>><?php foreach($runningJobs as $job): ?><span class="job-dot"></span><span><?=h(str_replace('_', ' ', $job['name']))?></span><?php if(!empty($job['started_at'])): ?><span class="job-age">(<?=h(format_job_age($job['started_at']))?>)</span><?php endif; ?><?php endforeach; ?></div><div class="admin-right"><a class="logout-link" href="logout.php">Log off</a></div></footer>
<script>document.querySelectorAll('textarea[data-counter]').forEach(function(t){var c=document.getElementById(t.dataset.counter);function u(){c.textContent=t.value.length+' / '+t.maxLength+' characters';}t.addEventListener('input',u);u();});</script>
<script>(function(){const box=document.getElementById('job-status');if(!box)return;function fmt(job){const name=(job.name||'job').replace(/_/g,' ');const started=job.started_at ? new Date(job.started_at) : null;const ageText=started && !Number.isNaN(started.getTime()) ? ' <span class="job-age">(' + formatAge(started) + ')</span>' : '';return '<span class="job-dot"></span><span>'+name+'</span>'+ageText;}function formatAge(started){const seconds=Math.max(0,Math.floor((Date.now()-started.getTime())/1000));const h=Math.floor(seconds/3600);const m=Math.floor((seconds%3600)/60);const s=seconds%60;if(h>0) return h+'h '+m+'m';if(m>0) return m+'m '+s+'s';return s+'s';}function syncEditButtons(runningJobs){document.querySelectorAll('.admin-video-row[data-job]').forEach(function(row){const job=row.dataset.job || '';const edit=row.querySelector('[data-edit-action="1"]');if(!edit) return;const running=runningJobs.has(job);if(edit.tagName==='A'){if(running){if(!edit.dataset.editHref) edit.dataset.editHref = edit.getAttribute('href') || '';edit.removeAttribute('href');edit.classList.add('disabled');edit.setAttribute('aria-disabled','true');edit.title='Rendering now';}else{const href=edit.dataset.editHref || ('?edit=' + encodeURIComponent(job));edit.setAttribute('href', href);edit.classList.remove('disabled');edit.removeAttribute('aria-disabled');edit.title='Edit';}}else{edit.classList.toggle('disabled', running);edit.title=running ? 'Rendering now' : '';}});}async function updateJobs(){try{const r=await fetch('job_status.php',{cache:'no-store'});if(!r.ok) throw new Error('status failed');const data=await r.json();const jobs=(data.jobs||[]).filter(function(job){ return job && job.name; });const runningJobs=new Set(jobs.map(function(job){ return String(job.name); }));syncEditButtons(runningJobs);if(!jobs.length){box.hidden=true;box.innerHTML='';return;}box.hidden=false;box.innerHTML=jobs.map(fmt).join('');}catch(e){if(box.innerHTML.trim()!=='') box.hidden=false;}}updateJobs();setInterval(updateJobs,30000);})();</script>
<script>
(function(){
function attachDescribeButtons(){
@ -1426,12 +1430,14 @@ function applySwitchPayload(payload){
if (row.querySelector('.describe-button')) return;
var actions = row.querySelector('.admin-actions');
if (!actions) return;
var job = row.dataset.job;
if (!job) return;
var job = row.dataset.job || '';
var articleId = row.dataset.id || '';
if (!job && !articleId) return;
var describe = document.createElement('a');
describe.className = 'button describe-button';
describe.href = '#';
describe.dataset.job = job;
describe.dataset.id = articleId;
describe.textContent = 'Describe';
var first = actions.querySelector('.button');
if (first) actions.insertBefore(describe, first);
@ -1439,7 +1445,7 @@ function applySwitchPayload(payload){
});
}
function showModal(job, description, rawJson){
function showModal(job, articleId, description, rawJson){
var existing = document.getElementById('mvlog-gemini-modal');
if (existing) existing.remove();
var overlay = document.createElement('div');
@ -1473,8 +1479,11 @@ function applySwitchPayload(payload){
useBtn.addEventListener('click', function(){
var editForm = document.getElementById('edit-form');
if (editForm){
var idInput = editForm.querySelector('input[name="id"]');
var dirInput = editForm.querySelector('input[name="dir"]');
if (dirInput && dirInput.value === job){
var sameById = !!(articleId && idInput && idInput.value === articleId);
var sameByDir = !!(job && dirInput && dirInput.value === job);
if (sameById || sameByDir){
var textarea = editForm.querySelector('textarea[name="description"]');
if (textarea){
textarea.value = description || '';
@ -1484,8 +1493,12 @@ function applySwitchPayload(payload){
}
}
}
try { localStorage.setItem('mvlog_gemini_description_' + job, description || ''); } catch(e){}
window.location.href = 'new.php?tab=edit&edit=' + encodeURIComponent(job);
var storageKey = 'mvlog_gemini_description_' + (articleId || job);
try { localStorage.setItem(storageKey, description || ''); } catch(e){}
var href = 'new.php?tab=edit';
if (articleId) href += '&id=' + encodeURIComponent(articleId);
if (job) href += '&edit=' + encodeURIComponent(job);
window.location.href = href;
});
}
@ -1493,8 +1506,9 @@ function applySwitchPayload(payload){
var el = (e.target && e.target.closest && e.target.closest('.describe-button')) || (e.target && e.target.classList && e.target.classList.contains && e.target.classList.contains('describe-button') ? e.target : null);
if (!el) return;
e.preventDefault();
var job = el.dataset.job;
if (!job) return;
var job = el.dataset.job || '';
var articleId = el.dataset.id || '';
if (!job && !articleId) return;
el.classList.add('disabled');
var oldText = el.textContent;
el.textContent = 'Generating...';
@ -1502,12 +1516,12 @@ function applySwitchPayload(payload){
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type':'application/x-www-form-urlencoded'},
body: 'job=' + encodeURIComponent(job) + '&max_frames=16'
body: 'id=' + encodeURIComponent(articleId) + '&job=' + encodeURIComponent(job) + '&max_frames=16'
}).then(function(r){ return r.json().catch(()=>({})); })
.then(function(data){
if (data && data.status === 'ok'){
var desc = data.description || (data.raw_json && JSON.stringify(data.raw_json)) || '';
showModal(job, desc, data.raw_json || null);
showModal(job, articleId, desc, data.raw_json || null);
} else {
var msg = (data && data.message) ? data.message : 'No response';
if (data && data.log) {
@ -1539,10 +1553,12 @@ function applySwitchPayload(payload){
var editForm = document.getElementById('edit-form');
if (!editForm) return;
var dirInput = editForm.querySelector('input[name="dir"]');
if (!dirInput) return;
var job = dirInput.value;
var idInput = editForm.querySelector('input[name="id"]');
if (!dirInput && !idInput) return;
var job = dirInput ? dirInput.value : '';
var articleId = idInput ? idInput.value : '';
try {
var key = 'mvlog_gemini_description_' + job;
var key = 'mvlog_gemini_description_' + (articleId || job);
var desc = localStorage.getItem(key);
if (desc) {
var textarea = editForm.querySelector('textarea[name="description"]');