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

View file

@ -4,28 +4,86 @@ require __DIR__ . '/../auth.php';
mvlog_require_login(); mvlog_require_login();
// Web-server side handler to toggle "Show" for an MVLog job and send a web-push immediately if appropriate. // Web-server side handler to toggle "Show" for an MVLog job and send a web-push immediately if appropriate.
// Usage (POST): job=<job> show=1|0 // Preferred usage (POST): id=<article_id> show=1|0
// Legacy fallback (POST): job=<job_dir_name> show=1|0
$MVLOG_ROOT = dirname(__DIR__); $MVLOG_ROOT = dirname(__DIR__);
$job = $_POST['job'] ?? ''; $id = trim((string)($_POST['id'] ?? ''));
$job = trim((string)($_POST['job'] ?? ''));
$show = isset($_POST['show']) ? filter_var($_POST['show'], FILTER_VALIDATE_BOOLEAN) : true; $show = isset($_POST['show']) ? filter_var($_POST['show'], FILTER_VALIDATE_BOOLEAN) : true;
// Basic validation function mvlog_article_id_is_valid(string $id): bool {
if (!preg_match('/^[0-9]{8}[-_][A-Za-z0-9._-]+$/', $job)) { return (bool)preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $id);
http_response_code(400);
echo 'invalid job';
exit;
} }
$jobdir = "$MVLOG_ROOT/in-dir/$job"; function mvlog_legacy_job_is_valid(string $job): bool {
if (!is_dir($jobdir)) { return (bool)preg_match('/^[0-9]{8}[-_][A-Za-z0-9._-]+$/', $job);
}
function mvlog_read_article_id(string $jobdir): string {
$path = $jobdir . '/.mvlog-id';
if (!is_file($path)) return '';
$id = strtolower(trim((string)@file_get_contents($path)));
return mvlog_article_id_is_valid($id) ? $id : '';
}
function mvlog_find_jobdir_by_id(string $mvlogRoot, string $articleId): ?string {
if (!mvlog_article_id_is_valid($articleId)) return null;
$cacheFile = $mvlogRoot . '/cache/articles-index.json';
if (is_file($cacheFile)) {
$cache = json_decode((string)@file_get_contents($cacheFile), true);
if (is_array($cache) && is_array($cache['by_id'] ?? null)) {
$name = (string)($cache['by_id'][$articleId] ?? '');
if ($name !== '') {
$candidate = $mvlogRoot . '/in-dir/' . basename($name);
if (is_dir($candidate)) return $candidate;
}
}
}
foreach (glob($mvlogRoot . '/in-dir/*', GLOB_ONLYDIR) ?: [] as $dir) {
if (mvlog_read_article_id($dir) === $articleId) return $dir;
}
return null;
}
function mvlog_resolve_jobdir(string $mvlogRoot, string $id, string $job, ?string &$resolvedJob, ?string &$resolvedId): ?string {
$resolvedJob = null;
$resolvedId = null;
if ($id !== '' && mvlog_article_id_is_valid($id)) {
$dir = mvlog_find_jobdir_by_id($mvlogRoot, strtolower($id));
if ($dir !== null) {
$resolvedJob = basename($dir);
$resolvedId = mvlog_read_article_id($dir);
return $dir;
}
}
if ($job !== '' && mvlog_legacy_job_is_valid($job)) {
$dir = $mvlogRoot . '/in-dir/' . basename($job);
if (is_dir($dir)) {
$resolvedJob = basename($dir);
$resolvedId = mvlog_read_article_id($dir);
return $dir;
}
}
return null;
}
$resolvedJob = null;
$resolvedId = null;
$jobdir = mvlog_resolve_jobdir($MVLOG_ROOT, strtolower($id), $job, $resolvedJob, $resolvedId);
if ($jobdir === null) {
http_response_code(404); http_response_code(404);
echo 'job not found'; echo 'job not found';
exit; exit;
} }
$enabled_file = "$jobdir/.movmaker-enabled"; $enabled_file = "$jobdir/.movmaker-enabled";
$cache_file = "$MVLOG_ROOT/cache/push_notifications.json";
if ($show) { if ($show) {
file_put_contents($enabled_file, date(DATE_ATOM) . PHP_EOL, LOCK_EX); file_put_contents($enabled_file, date(DATE_ATOM) . PHP_EOL, LOCK_EX);
@ -35,20 +93,16 @@ if ($show) {
if (is_file(__DIR__ . '/../lib/send_push.php')) { if (is_file(__DIR__ . '/../lib/send_push.php')) {
include_once __DIR__ . '/../lib/send_push.php'; include_once __DIR__ . '/../lib/send_push.php';
try { try {
$sent = try_send_show_notification($job, $jobdir, $MVLOG_ROOT, '/var/log/mvlog_notify.log'); $sent = try_send_show_notification($resolvedId ?: $resolvedJob, $jobdir, $MVLOG_ROOT, '/var/log/mvlog_notify.log');
} catch (Throwable $e) { } catch (Throwable $e) {
error_log("[mvlog] notify exception for $job: " . $e->getMessage() . "\n", 3, '/var/log/mvlog_notify.log'); error_log("[mvlog] notify exception for " . ($resolvedId ?: $resolvedJob) . ": " . $e->getMessage() . "\n", 3, '/var/log/mvlog_notify.log');
$sent = false; $sent = false;
} }
} else { } else {
error_log("[mvlog] send_push helper missing, cannot send notification for $job\n", 3, '/var/log/mvlog_notify.log'); error_log("[mvlog] send_push helper missing, cannot send notification for " . ($resolvedId ?: $resolvedJob) . "\n", 3, '/var/log/mvlog_notify.log');
} }
if ($sent) { echo $sent ? 'enabled' : 'enabled (no notification)';
echo 'enabled';
} else {
echo 'enabled (no notification)';
}
} else { } else {
@unlink($enabled_file); @unlink($enabled_file);
echo 'disabled'; echo 'disabled';

View file

@ -10,8 +10,12 @@ foreach ($dirs as $dir) {
$name = basename($dir); $name = basename($dir);
$stateFile = $dir . '/.movmaker-state.json'; $stateFile = $dir . '/.movmaker-state.json';
$lockDir = $dir . '/.movmaker-lock'; $lockDir = $dir . '/.movmaker-lock';
$idFile = $dir . '/.mvlog-id';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : []; $state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
$status = is_array($state) ? (string)($state['status'] ?? '') : ''; $status = is_array($state) ? (string)($state['status'] ?? '') : '';
$articleId = is_array($state) ? strtolower((string)($state['article_id'] ?? '')) : '';
if ($articleId === '' && is_file($idFile)) $articleId = strtolower(trim((string)file_get_contents($idFile)));
if (!preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $articleId)) $articleId = '';
$locked = is_dir($lockDir); $locked = is_dir($lockDir);
if ($locked || $status === 'processing') { if ($locked || $status === 'processing') {
$started = ''; $started = '';
@ -19,6 +23,7 @@ foreach ($dirs as $dir) {
if ($started === '' && is_array($state)) $started = (string)($state['started_at'] ?? $state['updated_at'] ?? ''); if ($started === '' && is_array($state)) $started = (string)($state['started_at'] ?? $state['updated_at'] ?? '');
$jobs[] = [ $jobs[] = [
'name' => $name, 'name' => $name,
'id' => $articleId,
'status' => $status ?: ($locked ? 'processing' : 'unknown'), 'status' => $status ?: ($locked ? 'processing' : 'unknown'),
'started_at' => $started, 'started_at' => $started,
]; ];

View file

@ -7,17 +7,57 @@ mvlog_require_login();
$MVLOG_ROOT = dirname(__DIR__); $MVLOG_ROOT = dirname(__DIR__);
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
$job = $_POST['job'] ?? ''; $id = strtolower(trim((string)($_POST['id'] ?? '')));
$job = trim((string)($_POST['job'] ?? ''));
$max_frames = isset($_POST['max_frames']) ? intval($_POST['max_frames']) : 16; $max_frames = isset($_POST['max_frames']) ? intval($_POST['max_frames']) : 16;
if (!preg_match('/^[0-9]{8}[-_][A-Za-z0-9._-]+$/', $job)) { function mvlog_article_id_is_valid(string $id): bool {
http_response_code(400); return (bool)preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $id);
echo json_encode(['status'=>'error','message'=>'invalid job']);
exit;
} }
$jobdir = $MVLOG_ROOT . '/in-dir/' . $job; function mvlog_legacy_job_is_valid(string $job): bool {
if (!is_dir($jobdir)) { return (bool)preg_match('/^[0-9]{8}[-_][A-Za-z0-9._-]+$/', $job);
}
function mvlog_read_article_id(string $jobdir): string {
$path = $jobdir . '/.mvlog-id';
if (!is_file($path)) return '';
$value = strtolower(trim((string)@file_get_contents($path)));
return mvlog_article_id_is_valid($value) ? $value : '';
}
function mvlog_find_jobdir_by_id(string $mvlogRoot, string $articleId): ?string {
if (!mvlog_article_id_is_valid($articleId)) return null;
$cacheFile = $mvlogRoot . '/cache/articles-index.json';
if (is_file($cacheFile)) {
$cache = json_decode((string)@file_get_contents($cacheFile), true);
if (is_array($cache) && is_array($cache['by_id'] ?? null)) {
$name = (string)($cache['by_id'][$articleId] ?? '');
if ($name !== '') {
$candidate = $mvlogRoot . '/in-dir/' . basename($name);
if (is_dir($candidate)) return $candidate;
}
}
}
foreach (glob($mvlogRoot . '/in-dir/*', GLOB_ONLYDIR) ?: [] as $dir) {
if (mvlog_read_article_id($dir) === $articleId) return $dir;
}
return null;
}
$jobdir = null;
if ($id !== '' && mvlog_article_id_is_valid($id)) {
$jobdir = mvlog_find_jobdir_by_id($MVLOG_ROOT, $id);
}
if ($jobdir === null && $job !== '' && mvlog_legacy_job_is_valid($job)) {
$candidate = $MVLOG_ROOT . '/in-dir/' . basename($job);
if (is_dir($candidate)) $jobdir = $candidate;
}
if ($jobdir === null || !is_dir($jobdir)) {
http_response_code(404); http_response_code(404);
echo json_encode(['status'=>'error','message'=>'job not found']); echo json_encode(['status'=>'error','message'=>'job not found']);
exit; exit;

View file

@ -3,7 +3,7 @@
* send_push.php * send_push.php
* Helper function to send "Show enabled" web-push notifications from the webserver. * Helper function to send "Show enabled" web-push notifications from the webserver.
* *
* Function: try_send_show_notification(string $job, string $jobdir, string $mvlog_root = null, string $logfile = null): bool * Function: try_send_show_notification(string $articleRefOrJob, string $jobdir, string $mvlog_root = null, string $logfile = null): bool
* *
* Returns true on successful send and cache update, false otherwise. * Returns true on successful send and cache update, false otherwise.
*/ */
@ -73,7 +73,20 @@ function mvlog_nice_title(string $s): string
return trim(ucwords(str_replace(['_', '-'], ' ', $s))); return trim(ucwords(str_replace(['_', '-'], ' ', $s)));
} }
function try_send_show_notification($job, $jobdir, $mvlog_root = null, $logfile = null) function mvlog_article_id_is_valid(string $id): bool
{
return (bool)preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $id);
}
function mvlog_read_article_id(string $jobdir): string
{
$path = rtrim($jobdir, '/') . '/.mvlog-id';
if (!is_file($path)) return '';
$value = strtolower(trim((string)@file_get_contents($path)));
return mvlog_article_id_is_valid($value) ? $value : '';
}
function try_send_show_notification($articleRefOrJob, $jobdir, $mvlog_root = null, $logfile = null)
{ {
if ($mvlog_root === null) $mvlog_root = dirname(__DIR__); if ($mvlog_root === null) $mvlog_root = dirname(__DIR__);
if ($logfile === null) $logfile = '/var/log/mvlog_notify.log'; if ($logfile === null) $logfile = '/var/log/mvlog_notify.log';
@ -81,6 +94,11 @@ function try_send_show_notification($job, $jobdir, $mvlog_root = null, $logfile
$subs_file = $mvlog_root . '/cache/push_subscriptions.json'; $subs_file = $mvlog_root . '/cache/push_subscriptions.json';
$cache_file = $mvlog_root . '/cache/push_notifications.json'; $cache_file = $mvlog_root . '/cache/push_notifications.json';
$job = basename((string)$jobdir);
$articleRef = strtolower(trim((string)$articleRefOrJob));
$articleId = mvlog_article_id_is_valid($articleRef) ? $articleRef : mvlog_read_article_id((string)$jobdir);
$identity = $articleId !== '' ? $articleId : $job;
// Don't notify for hidden jobs // Don't notify for hidden jobs
if (is_file($jobdir . '/.mvlog-hidden')) { if (is_file($jobdir . '/.mvlog-hidden')) {
error_log("[mvlog] job $job hidden, skipping push\n", 3, $logfile); error_log("[mvlog] job $job hidden, skipping push\n", 3, $logfile);
@ -89,17 +107,17 @@ function try_send_show_notification($job, $jobdir, $mvlog_root = null, $logfile
// Gather subscriptions // Gather subscriptions
if (!is_file($subs_file)) { if (!is_file($subs_file)) {
error_log("[mvlog] subscriptions file missing, skipping push for $job\n", 3, $logfile); error_log("[mvlog] subscriptions file missing, skipping push for $identity\n", 3, $logfile);
return false; return false;
} }
$subs_raw = @file_get_contents($subs_file); $subs_raw = @file_get_contents($subs_file);
if (!is_string($subs_raw) || trim($subs_raw) === '') { if (!is_string($subs_raw) || trim($subs_raw) === '') {
error_log("[mvlog] subscriptions empty, skipping push for $job\n", 3, $logfile); error_log("[mvlog] subscriptions empty, skipping push for $identity\n", 3, $logfile);
return false; return false;
} }
$subs = json_decode($subs_raw, true); $subs = json_decode($subs_raw, true);
if (!is_array($subs) || count($subs) === 0) { if (!is_array($subs) || count($subs) === 0) {
error_log("[mvlog] subscriptions invalid/empty, skipping push for $job\n", 3, $logfile); error_log("[mvlog] subscriptions invalid/empty, skipping push for $identity\n", 3, $logfile);
return false; return false;
} }
@ -109,21 +127,22 @@ function try_send_show_notification($job, $jobdir, $mvlog_root = null, $logfile
$cache = json_decode((string)@file_get_contents($cache_file), true) ?: []; $cache = json_decode((string)@file_get_contents($cache_file), true) ?: [];
} }
$shown = isset($cache['shown']) && is_array($cache['shown']) ? $cache['shown'] : []; $shown = isset($cache['shown']) && is_array($cache['shown']) ? $cache['shown'] : [];
if (isset($shown[$job])) { // Transition support: honor both new (ID) and legacy (job name) keys.
error_log("[mvlog] push already sent for $job, skipping\n", 3, $logfile); if (isset($shown[$identity]) || ($identity !== $job && isset($shown[$job]))) {
error_log("[mvlog] push already sent for $identity (job=$job), skipping\n", 3, $logfile);
return false; return false;
} }
// Resolve push config consistently (same source model as push_config.php) // Resolve push config consistently (same source model as push_config.php)
$cfg = mvlog_load_push_config($mvlog_root); $cfg = mvlog_load_push_config($mvlog_root);
if (!$cfg) { if (!$cfg) {
error_log("[mvlog] push config not found/invalid, skipping push for $job\n", 3, $logfile); error_log("[mvlog] push config not found/invalid, skipping push for $identity\n", 3, $logfile);
return false; return false;
} }
$tmp_push_config = mvlog_write_temp_push_json($cfg); $tmp_push_config = mvlog_write_temp_push_json($cfg);
if (!$tmp_push_config) { if (!$tmp_push_config) {
error_log("[mvlog] failed to create temp push config for $job\n", 3, $logfile); error_log("[mvlog] failed to create temp push config for $identity\n", 3, $logfile);
return false; return false;
} }
@ -135,7 +154,7 @@ function try_send_show_notification($job, $jobdir, $mvlog_root = null, $logfile
$state_file = $jobdir . '/.movmaker-state.json'; $state_file = $jobdir . '/.movmaker-state.json';
if (is_file($state_file)) { if (is_file($state_file)) {
$state = json_decode(@file_get_contents($state_file), true) ?: []; $state = json_decode((string)@file_get_contents($state_file), true) ?: [];
if (!empty($state['output']) && is_string($state['output'])) { if (!empty($state['output']) && is_string($state['output'])) {
$out = pathinfo($state['output'], PATHINFO_FILENAME); $out = pathinfo($state['output'], PATHINFO_FILENAME);
if ($out) { if ($out) {
@ -158,7 +177,7 @@ function try_send_show_notification($job, $jobdir, $mvlog_root = null, $logfile
'title' => $articleTitle, 'title' => $articleTitle,
'body' => 'New MVLog article is now visible', 'body' => 'New MVLog article is now visible',
'url' => $filterUrl, 'url' => $filterUrl,
'tag' => "mvlog-show-{$job}", 'tag' => "mvlog-show-{$identity}",
], JSON_UNESCAPED_UNICODE); ], JSON_UNESCAPED_UNICODE);
// Call node send_push.js with subscriptions on stdin // Call node send_push.js with subscriptions on stdin
@ -176,7 +195,7 @@ function try_send_show_notification($job, $jobdir, $mvlog_root = null, $logfile
$proc = proc_open($cmd, $descriptorspec, $pipes); $proc = proc_open($cmd, $descriptorspec, $pipes);
if (!is_resource($proc)) { if (!is_resource($proc)) {
@unlink($tmp_push_config); @unlink($tmp_push_config);
error_log("[mvlog] failed to start node send_push process for $job\n", 3, $logfile); error_log("[mvlog] failed to start node send_push process for $identity\n", 3, $logfile);
return false; return false;
} }
@ -191,29 +210,33 @@ function try_send_show_notification($job, $jobdir, $mvlog_root = null, $logfile
@unlink($tmp_push_config); @unlink($tmp_push_config);
if ($rc !== 0) { if ($rc !== 0) {
error_log("[mvlog] send_push failed for $job rc=$rc stdout=" . trim((string)$stdout) . " stderr=" . trim((string)$stderr) . "\n", 3, $logfile); error_log("[mvlog] send_push failed for $identity rc=$rc stdout=" . trim((string)$stdout) . " stderr=" . trim((string)$stderr) . "\n", 3, $logfile);
return false; return false;
} }
// On success, record in cache['shown'] // On success, record in cache['shown']
if (!is_array($cache)) $cache = []; if (!is_array($cache)) $cache = [];
if (!isset($cache['shown']) || !is_array($cache['shown'])) $cache['shown'] = []; if (!isset($cache['shown']) || !is_array($cache['shown'])) $cache['shown'] = [];
$cache['shown'][$job] = ['sent_at' => date(DATE_ATOM)]; $cache['shown'][$identity] = [
'sent_at' => date(DATE_ATOM),
'job' => $job,
'article_id' => $articleId,
];
// Atomic write: write to tmp then rename // Atomic write: write to tmp then rename
$tmp = $cache_file . '.tmp.' . bin2hex(random_bytes(6)); $tmp = $cache_file . '.tmp.' . bin2hex(random_bytes(6));
$json = json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"; $json = json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
if (@file_put_contents($tmp, $json, LOCK_EX) === false) { if (@file_put_contents($tmp, $json, LOCK_EX) === false) {
error_log("[mvlog] failed to write cache temp file for $job\n", 3, $logfile); error_log("[mvlog] failed to write cache temp file for $identity\n", 3, $logfile);
return false; return false;
} }
@chmod($tmp, 0664); @chmod($tmp, 0664);
if (!@rename($tmp, $cache_file)) { if (!@rename($tmp, $cache_file)) {
@unlink($tmp); @unlink($tmp);
error_log("[mvlog] failed to atomically write cache file for $job\n", 3, $logfile); error_log("[mvlog] failed to atomically write cache file for $identity\n", 3, $logfile);
return false; return false;
} }
error_log("[mvlog] show-notification sent: $job (cfg=" . ($cfg['_source'] ?? 'unknown') . ")\n", 3, $logfile); error_log("[mvlog] show-notification sent: identity=$identity job=$job (cfg=" . ($cfg['_source'] ?? 'unknown') . ")\n", 3, $logfile);
return true; return true;
} }

66
new.php
View file

@ -84,7 +84,7 @@ function load_articles_index($base, $dirs = null){
foreach ($dirs as $dir) { foreach ($dirs as $dir) {
$name = basename((string)$dir); $name = basename((string)$dir);
$id = read_article_id($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); $id = ensure_article_id($dir, $byId);
} }
if ($id !== '' && empty($byId[$id])) $byId[$id] = $name; 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) { foreach (input_dirs($config['uploads_dir']) as $dir) {
$name = basename($dir); $name = basename($dir);
$state = input_dir_state($dir); $state = input_dir_state($dir);
$articleId = (string)($state['article_id'] ?? '') ?: ensure_article_id($dir);
$status = (string)($state['status'] ?? ''); $status = (string)($state['status'] ?? '');
$locked = is_dir($dir . '/.movmaker-lock'); $locked = is_dir($dir . '/.movmaker-lock');
if ($locked || $status === 'processing') { if ($locked || $status === 'processing') {
$started = ''; $started = '';
if (is_file($dir . '/.movmaker-lock/started_at')) $started = trim((string)file_get_contents($dir . '/.movmaker-lock/started_at')); 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'] ?? ''); 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; return $jobs;
@ -763,7 +764,7 @@ try {
if ($visibleNow && $canShow) { if ($visibleNow && $canShow) {
if (is_file(__DIR__ . '/lib/send_push.php')) { if (is_file(__DIR__ . '/lib/send_push.php')) {
include_once __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 { } else {
error_log("[mvlog] send_push helper missing, cannot send notification for " . basename($dir) . "\n", 3, '/var/log/mvlog_notify.log'); 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($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==='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"> <form method="post" class="inline-form switch-form" data-switch="preview">
<input type="hidden" name="action" value="set_preview"> <input type="hidden" name="action" value="set_preview">
<input type="hidden" name="dir" value="<?=h(basename($d))?>"> <input type="hidden" name="dir" value="<?=h(basename($d))?>">
@ -1319,8 +1320,10 @@ async function handleSwitchToggle(form, checkbox){
} }
function applySwitchPayload(payload){ function applySwitchPayload(payload){
if(!payload || !payload.dir) return; if(!payload) return;
const row = document.querySelector('.admin-video-row[data-job="'+payload.dir+'"]'); 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; if(!row) return;
function setSwitch(type, value){ function setSwitch(type, value){
const target = row.querySelector('form[data-switch="'+type+'"] input[type="checkbox"]'); 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'; if (m > 0) return m + 'm ' + s + 's';
return s + 's'; return s + 's';
} }
function syncEditButtons(runningJobs){ function syncEditButtons(runningIds, runningJobs){
document.querySelectorAll('.admin-video-row[data-job]').forEach(function(row){ document.querySelectorAll('.admin-video-row[data-job]').forEach(function(row){
const job=row.dataset.job || ''; const job=row.dataset.job || '';
const articleId=row.dataset.id || '';
const edit=row.querySelector('[data-edit-action="1"]'); const edit=row.querySelector('[data-edit-action="1"]');
if(!edit) return; if(!edit) return;
const running=runningJobs.has(job); const running=(articleId && runningIds.has(articleId)) || runningJobs.has(job);
if(edit.tagName === 'A'){ if(edit.tagName === 'A'){
if(running){ if(running){
if(!edit.dataset.editHref) edit.dataset.editHref = edit.getAttribute('href') || ''; 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'}); const r=await fetch('job_status.php',{cache:'no-store'});
if(!r.ok) throw new Error('status failed'); if(!r.ok) throw new Error('status failed');
const data=await r.json(); const data=await r.json();
const jobs=(data.jobs||[]).filter(function(job){ return job && job.name; }); const jobs=(data.jobs||[]).filter(function(job){ return job && (job.name || job.id); });
const runningJobs=new Set(jobs.map(function(job){ return String(job.name); })); const runningIds=new Set(jobs.map(function(job){ return String(job.id || ''); }).filter(function(v){ return v !== ''; }));
syncEditButtons(runningJobs); const runningJobs=new Set(jobs.map(function(job){ return String(job.name || ''); }).filter(function(v){ return v !== ''; }));
syncEditButtons(runningIds, runningJobs);
if(!jobs.length){ if(!jobs.length){
box.hidden=true; box.hidden=true;
box.innerHTML=''; box.innerHTML='';
@ -1418,7 +1423,6 @@ function applySwitchPayload(payload){
<footer>Input dirs are saved under <code>in-dir/</code>.</footer> <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> <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>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> <script>
(function(){ (function(){
function attachDescribeButtons(){ function attachDescribeButtons(){
@ -1426,12 +1430,14 @@ function applySwitchPayload(payload){
if (row.querySelector('.describe-button')) return; if (row.querySelector('.describe-button')) return;
var actions = row.querySelector('.admin-actions'); var actions = row.querySelector('.admin-actions');
if (!actions) return; if (!actions) return;
var job = row.dataset.job; var job = row.dataset.job || '';
if (!job) return; var articleId = row.dataset.id || '';
if (!job && !articleId) return;
var describe = document.createElement('a'); var describe = document.createElement('a');
describe.className = 'button describe-button'; describe.className = 'button describe-button';
describe.href = '#'; describe.href = '#';
describe.dataset.job = job; describe.dataset.job = job;
describe.dataset.id = articleId;
describe.textContent = 'Describe'; describe.textContent = 'Describe';
var first = actions.querySelector('.button'); var first = actions.querySelector('.button');
if (first) actions.insertBefore(describe, first); 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'); var existing = document.getElementById('mvlog-gemini-modal');
if (existing) existing.remove(); if (existing) existing.remove();
var overlay = document.createElement('div'); var overlay = document.createElement('div');
@ -1473,8 +1479,11 @@ function applySwitchPayload(payload){
useBtn.addEventListener('click', function(){ useBtn.addEventListener('click', function(){
var editForm = document.getElementById('edit-form'); var editForm = document.getElementById('edit-form');
if (editForm){ if (editForm){
var idInput = editForm.querySelector('input[name="id"]');
var dirInput = editForm.querySelector('input[name="dir"]'); 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"]'); var textarea = editForm.querySelector('textarea[name="description"]');
if (textarea){ if (textarea){
textarea.value = description || ''; textarea.value = description || '';
@ -1484,8 +1493,12 @@ function applySwitchPayload(payload){
} }
} }
} }
try { localStorage.setItem('mvlog_gemini_description_' + job, description || ''); } catch(e){} var storageKey = 'mvlog_gemini_description_' + (articleId || job);
window.location.href = 'new.php?tab=edit&edit=' + encodeURIComponent(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); 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; if (!el) return;
e.preventDefault(); e.preventDefault();
var job = el.dataset.job; var job = el.dataset.job || '';
if (!job) return; var articleId = el.dataset.id || '';
if (!job && !articleId) return;
el.classList.add('disabled'); el.classList.add('disabled');
var oldText = el.textContent; var oldText = el.textContent;
el.textContent = 'Generating...'; el.textContent = 'Generating...';
@ -1502,12 +1516,12 @@ function applySwitchPayload(payload){
method: 'POST', method: 'POST',
credentials: 'same-origin', credentials: 'same-origin',
headers: {'Content-Type':'application/x-www-form-urlencoded'}, 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(r){ return r.json().catch(()=>({})); })
.then(function(data){ .then(function(data){
if (data && data.status === 'ok'){ if (data && data.status === 'ok'){
var desc = data.description || (data.raw_json && JSON.stringify(data.raw_json)) || ''; 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 { } else {
var msg = (data && data.message) ? data.message : 'No response'; var msg = (data && data.message) ? data.message : 'No response';
if (data && data.log) { if (data && data.log) {
@ -1539,10 +1553,12 @@ function applySwitchPayload(payload){
var editForm = document.getElementById('edit-form'); var editForm = document.getElementById('edit-form');
if (!editForm) return; if (!editForm) return;
var dirInput = editForm.querySelector('input[name="dir"]'); var dirInput = editForm.querySelector('input[name="dir"]');
if (!dirInput) return; var idInput = editForm.querySelector('input[name="id"]');
var job = dirInput.value; if (!dirInput && !idInput) return;
var job = dirInput ? dirInput.value : '';
var articleId = idInput ? idInput.value : '';
try { try {
var key = 'mvlog_gemini_description_' + job; var key = 'mvlog_gemini_description_' + (articleId || job);
var desc = localStorage.getItem(key); var desc = localStorage.getItem(key);
if (desc) { if (desc) {
var textarea = editForm.querySelector('textarea[name="description"]'); var textarea = editForm.querySelector('textarea[name="description"]');