movmaker-webui/new.php
2026-06-01 07:59:13 +02:00

1739 lines
No EOL
94 KiB
PHP
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.

<?php
require __DIR__ . '/auth.php';
mvlog_require_login();
$config = require __DIR__ . "/config.php";
foreach (["videos_dir", "thumbs_dir", "uploads_dir"] as $d) if (!is_dir($config[$d])) mkdir($config[$d], 0775, true);
if (!is_dir(__DIR__ . "/cache")) mkdir(__DIR__ . "/cache", 0775, true);
const ARTICLE_ID_FILE = '.mvlog-id';
const MAP_HIDDEN_FILE = '.mvlog-hide-map';
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, "UTF-8"); }
function ajax_json($data){ header('Content-Type: application/json; charset=UTF-8'); echo json_encode($data, JSON_UNESCAPED_UNICODE); exit; }
function lower_text($s){ return function_exists('mb_strtolower') ? mb_strtolower((string)$s, 'UTF-8') : strtolower((string)$s); }
function fold_text($s){
$s = strtr((string)$s, [
'æ'=>'ae','Æ'=>'ae','ø'=>'o','Ø'=>'o','å'=>'aa','Å'=>'aa',
'ä'=>'ae','Ä'=>'ae','ö'=>'oe','Ö'=>'oe','ü'=>'ue','Ü'=>'ue','ß'=>'ss','ẞ'=>'ss',
'č'=>'c','Č'=>'c','ć'=>'c','Ć'=>'c','ž'=>'z','Ž'=>'z','š'=>'s','Š'=>'s','đ'=>'d','Đ'=>'d',
]);
if (function_exists('iconv')) {
$x = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if ($x !== false) $s = $x;
}
$s = lower_text($s);
return preg_replace('/[^a-z0-9]+/', ' ', $s) ?? '';
}
function ci_contains($haystack, $needle){
$haystack = (string)$haystack;
$needle = (string)$needle;
if ($needle === '') return true;
if (function_exists('mb_stripos') && mb_stripos($haystack, $needle, 0, 'UTF-8') !== false) return true;
if (strpos(lower_text($haystack), lower_text($needle)) !== false) return true;
$foldHay = fold_text($haystack);
$foldNeedle = trim(fold_text($needle));
if ($foldNeedle === '') return false;
return strpos($foldHay, $foldNeedle) !== false;
}
function ascii_safe($s){
$s = strtr((string)$s, [
'æ'=>'ae','Æ'=>'Ae','ø'=>'o','Ø'=>'O','å'=>'aa','Å'=>'Aa',
'ä'=>'ae','Ä'=>'Ae','ö'=>'oe','Ö'=>'Oe','ü'=>'ue','Ü'=>'Ue','ß'=>'ss','ẞ'=>'SS',
'č'=>'c','Č'=>'C','ć'=>'c','Ć'=>'C','ž'=>'z','Ž'=>'Z','š'=>'s','Š'=>'S','đ'=>'d','Đ'=>'D',
]);
if (function_exists('iconv')) {
$x = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if ($x !== false) $s = $x;
}
return preg_replace('/[^\x20-\x7E]/', '', $s) ?? '';
}
function slugify($s){ $s = ascii_safe($s); $s = strtolower(trim($s)); $s = preg_replace('/[^a-z0-9]+/', '-', $s); return trim($s, '-') ?: 'movie'; }
function rrmdir($dir){ if(!is_dir($dir)) return; foreach(scandir($dir) as $f){ if($f==='.'||$f==='..') continue; $p="$dir/$f"; is_dir($p)?rrmdir($p):unlink($p);} rmdir($dir); }
function input_dirs($base){ return glob($base.'/*', GLOB_ONLYDIR) ?: []; }
function safe_input_dir($base, $name){ $name = basename((string)$name); $path = realpath($base . '/' . $name); $root = realpath($base); if (!$path || !$root || !str_starts_with($path, $root . DIRECTORY_SEPARATOR) || !is_dir($path)) throw new RuntimeException('Invalid input directory.'); return $path; }
function article_id_is_valid($id){ return is_string($id) && preg_match('/^[0-9]{14}[a-f0-9]{16}$/', $id); }
function generate_article_id(){ return gmdate('YmdHis') . bin2hex(random_bytes(8)); }
function read_article_id($dir){
$idFile = rtrim((string)$dir, '/').'/'.ARTICLE_ID_FILE;
if (!is_file($idFile)) return '';
$id = trim((string)file_get_contents($idFile));
return article_id_is_valid($id) ? $id : '';
}
function write_article_id($dir, $id){
if (!article_id_is_valid($id)) throw new RuntimeException('Invalid article id.');
$idFile = rtrim((string)$dir, '/').'/'.ARTICLE_ID_FILE;
$tmp = $idFile . '.tmp.' . bin2hex(random_bytes(4));
if (file_put_contents($tmp, $id . "\n", LOCK_EX) === false) throw new RuntimeException('Cannot write article id.');
chmod($tmp, 0664);
if (!rename($tmp, $idFile)) { @unlink($tmp); throw new RuntimeException('Cannot save article id.'); }
}
function ensure_article_id($dir, &$seenIds = null){
$existing = read_article_id($dir);
if ($existing !== '' && (!is_array($seenIds) || empty($seenIds[$existing]))) {
if (is_array($seenIds)) $seenIds[$existing] = basename((string)$dir);
return $existing;
}
do { $id = generate_article_id(); } while (is_array($seenIds) && !empty($seenIds[$id]));
write_article_id($dir, $id);
if (is_array($seenIds)) $seenIds[$id] = basename((string)$dir);
return $id;
}
function article_index_signature($dirs){
$names = array_map('basename', $dirs);
sort($names, SORT_STRING);
$parts = [];
foreach ($names as $name) $parts[] = $name;
return hash('sha256', implode("\n", $parts));
}
function load_articles_index($base, $dirs = null){
if ($dirs === null) $dirs = input_dirs($base);
$cacheFile = __DIR__ . '/cache/articles-index.json';
$signature = article_index_signature($dirs);
$cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : [];
if (is_array($cache)
&& ($cache['version'] ?? 0) === 1
&& ($cache['signature'] ?? '') === $signature
&& is_array($cache['by_id'] ?? null)
&& is_array($cache['by_dir'] ?? null)
&& !in_array('', $cache['by_dir'], true)) {
return $cache;
}
$byId = [];
$byDir = [];
foreach ($dirs as $dir) {
$name = basename((string)$dir);
$id = read_article_id($dir);
if ($id === '' || !empty($byId[$id])) {
$id = ensure_article_id($dir, $byId);
}
if ($id !== '' && empty($byId[$id])) $byId[$id] = $name;
$byDir[$name] = $id;
}
ksort($byId, SORT_STRING);
ksort($byDir, SORT_STRING);
$out = [
'version' => 1,
'generated_at' => date('c'),
'signature' => $signature,
'by_id' => $byId,
'by_dir' => $byDir,
];
file_put_contents($cacheFile, json_encode($out, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
return $out;
}
function resolve_input_dir($base, $idOrDir, $articleIndex = null){
$token = trim((string)$idOrDir);
if ($token === '') throw new RuntimeException('Invalid input directory.');
if ($articleIndex === null) $articleIndex = load_articles_index($base);
if (article_id_is_valid($token)) {
$name = (string)($articleIndex['by_id'][$token] ?? '');
if ($name !== '') return safe_input_dir($base, $name);
}
return safe_input_dir($base, $token);
}
function resolve_input_dir_from_request($base, $id, $dir, $articleIndex = null){
$id = trim((string)$id);
$dir = trim((string)$dir);
if ($id !== '') {
try { return resolve_input_dir($base, $id, $articleIndex); }
catch (Throwable $e) { if ($dir !== '') return resolve_input_dir($base, $dir, $articleIndex); throw $e; }
}
return resolve_input_dir($base, $dir, $articleIndex);
}
function read_data($dir){
$data = ['title'=>'','teaser'=>'','quote_da'=>'','date'=>'','place'=>'','description'=>'','captions'=>[],'video_audio'=>[]]; $file = $dir . '/data.txt';
if (!is_file($file)) return $data;
$lines = file($file, FILE_IGNORE_NEW_LINES);
for ($i = 0; $i < count($lines); $i++) {
$line = trim($lines[$i]);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, ':')) continue;
[$key, $value] = array_map('trim', explode(':', $line, 2));
$low = strtolower($key);
if ($value === '|' && in_array($low, ['description','desc','synopsis'], true)) {
$block = [];
while ($i + 1 < count($lines)) {
$next = $lines[$i + 1];
if (trim($next) !== '' && $next[0] !== ' ' && $next[0] !== "\t") break;
$i++;
if (str_starts_with($next, ' ')) $next = substr($next, 2);
elseif (str_starts_with($next, ' ') || str_starts_with($next, "\t")) $next = substr($next, 1);
$block[] = rtrim($next);
}
$data['description'] = trim(implode("\n", $block));
} elseif (in_array($low, ['title','name'], true)) $data['title'] = $value;
elseif (in_array($low, ['teaser','tagline','subtitle'], true)) $data['teaser'] = $value;
elseif ($low === 'quote_da') $data['quote_da'] = trim($value, "\"“”");
elseif (in_array($low, ['date','dates','when'], true)) $data['date'] = $value;
elseif (in_array($low, ['place','location','where'], true)) $data['place'] = $value;
elseif (in_array($low, ['description','desc','synopsis'], true)) $data['description'] = $value;
elseif (str_ends_with($low, ' audio')) { $fileKey = trim(substr($key, 0, -6)); if ($fileKey !== '' && in_array(strtolower($value), ['1','yes','true','on'], true)) $data['video_audio'][$fileKey] = true; }
elseif ($key !== '') $data['captions'][$key] = $value;
}
return $data;
}
function write_data($dir, $post){
$title = trim($post['title'] ?? '');
$teaser = trim($post['teaser'] ?? '');
$quote_da = trim($post['quote_da'] ?? '');
$place = trim($post['place'] ?? '');
$date = trim($post['date'] ?? '');
$description = trim($post['description'] ?? '');
if (function_exists('mb_substr')) $description = mb_substr($description, 0, 5000, 'UTF-8'); else $description = substr($description, 0, 5000);
$lines = [];
if ($title !== '') $lines[] = 'Title: ' . $title;
if ($teaser !== '') $lines[] = 'Teaser: ' . $teaser;
if ($quote_da !== '') $lines[] = 'Quote_da: ' . trim($quote_da, "\"“”");
if ($place !== '') $lines[] = 'Location: ' . $place;
if ($date !== '') $lines[] = 'Date: ' . $date;
if ($description !== '') {
$lines[] = 'Description: |';
$description = str_replace(["\r\n", "\r"], "\n", $description);
foreach (explode("\n", $description) as $descLine) $lines[] = ' ' . rtrim($descLine);
}
$useAudioFiles = $post['use_audio_files'] ?? [];
$useAudio = is_array($useAudioFiles) ? array_flip(array_map('basename', array_map('strval', $useAudioFiles))) : [];
foreach (array_keys($useAudio) as $fileName) if ($fileName !== '') $lines[] = $fileName . ' audio: yes';
$captionFiles = $post['caption_files'] ?? [];
$captions = $post['captions'] ?? [];
if (is_array($captionFiles) && is_array($captions)) {
foreach ($captionFiles as $i => $fileName) {
$fileName = basename((string)$fileName);
$caption = trim((string)($captions[$i] ?? ''));
if ($fileName !== '' && $caption !== '') $lines[] = $fileName . ': ' . $caption;
}
}
file_put_contents($dir . '/data.txt', implode("\n", $lines) . "\n");
}
function editable_file($f){ $ext = strtolower(pathinfo((string)$f, PATHINFO_EXTENSION)); return $f !== '' && $f[0] !== '.' && $f !== 'data.txt' && in_array($ext, ['jpg','jpeg','png','webp','gif','mp4','mov','m4v','avi','mkv','webm','mp3','wav','m4a','aac','ogg','flac'], true); }
function natural_file_compare($a, $b){ return strnatcasecmp((string)$a, (string)$b); }
function is_visual_media_file($name){
$ext = strtolower(pathinfo((string)$name, PATHINFO_EXTENSION));
return in_array($ext, ['jpg','jpeg','png','webp','gif','mp4','mov','m4v','avi','mkv','webm'], true);
}
function parse_date_from_filename($path) {
$filename = basename($path);
if (!preg_match('/(?:^|[^0-9])(\\d{8})[_-]?(\\d{6})(?:[^0-9]|$)/', $filename, $m)) {
return null;
}
$y = (int)substr($m[1], 0, 4);
$mo = (int)substr($m[1], 4, 2);
$d = (int)substr($m[1], 6, 2);
$hh = (int)substr($m[2], 0, 2);
$mi = (int)substr($m[2], 2, 2);
$ss = (int)substr($m[2], 4, 2);
if (!checkdate($mo, $d, $y) || $hh > 23 || $mi > 59 || $ss > 59) {
return null;
}
return $m[1] . $m[2];
}
function mvlog_local_timezone(){
static $tz = null;
if ($tz instanceof DateTimeZone) return $tz;
$name = trim((string)@file_get_contents('/etc/timezone'));
if ($name === '') $name = (string)date_default_timezone_get();
if ($name === '') $name = 'UTC';
try { $tz = new DateTimeZone($name); }
catch (Throwable $e) { $tz = new DateTimeZone('UTC'); }
return $tz;
}
function parse_embedded_datetime_to_local($value){
$value = trim((string)$value);
if ($value === '') return null;
try {
$dt = new DateTimeImmutable($value);
return $dt->setTimezone(mvlog_local_timezone())->format('YmdHis');
} catch (Throwable $e) {
return null;
}
}
function media_file_date($path){
$ext = strtolower(pathinfo((string)$path, PATHINFO_EXTENSION));
// Priority 2: Exif/embedded metadata
if (in_array($ext, ['jpg','jpeg','tif','tiff'], true) && function_exists('exif_read_data')) {
$exif = @exif_read_data($path);
foreach (['DateTimeOriginal','DateTimeDigitized','DateTime'] as $k) {
$value = (string)($exif[$k] ?? '');
if ($value !== '' && preg_match('/^(\\d{4}):(\\d{2}):(\\d{2})(?:\\s+(\\d{2}):(\\d{2}):(\\d{2}))?/', $value, $m)) {
$date = $m[1] . $m[2] . $m[3];
$time = isset($m[4]) ? ($m[4] . $m[5] . $m[6]) : '000000';
return $date . $time;
}
}
}
if (in_array($ext, ['mp4','mov','m4v','avi','mkv','webm'], true)) {
$ffprobe = trim((string)shell_exec('command -v ffprobe 2>/dev/null'));
if ($ffprobe !== '') {
$json = shell_exec(escapeshellarg($ffprobe).' -v quiet -print_format json -show_entries format_tags=creation_time:stream_tags=creation_time '.escapeshellarg($path).' 2>/dev/null');
$data = json_decode((string)$json, true);
$candidates = [];
if (is_array($data['format']['tags'] ?? null)) $candidates[] = (string)($data['format']['tags']['creation_time'] ?? '');
foreach (($data['streams'] ?? []) as $stream) if (is_array($stream['tags'] ?? null)) $candidates[] = (string)($stream['tags']['creation_time'] ?? '');
foreach ($candidates as $value) {
$normalized = parse_embedded_datetime_to_local($value);
if ($normalized !== null) {
return $normalized;
}
if (preg_match('/^(\\d{4})-(\\d{2})-(\\d{2})/', $value, $m)) {
return $m[1].$m[2].$m[3].'000000';
}
}
}
}
// Priority 3: filename parsing
$from_filename = parse_date_from_filename($path);
if ($from_filename) {
return $from_filename;
}
return null;
}
function media_sort_datetime($path){
return media_file_date($path) ?? date('YmdHis', filemtime($path));
}
function list_files($dir){
$files = array_values(array_filter(scandir($dir), fn($f)=>editable_file($f) && is_file($dir.'/'.$f)));
$orderFile = $dir . '/order.json';
$media = [];
$other = [];
foreach ($files as $name) {
if (is_visual_media_file($name)) $media[] = $name;
else $other[] = $name;
}
// Priority 1: explicit order file, but regenerate if file set changed.
if (is_file($orderFile)) {
$order = json_decode((string)file_get_contents($orderFile), true);
if (is_array($order)) {
$mediaSet = array_fill_keys($media, true);
$orderSet = [];
$sortedMedia = [];
foreach ($order as $name) {
if (!is_string($name) || !is_visual_media_file($name)) continue;
if (isset($mediaSet[$name]) && !isset($orderSet[$name])) {
$orderSet[$name] = true;
$sortedMedia[] = $name;
}
}
$sameSet = (count($orderSet) === count($mediaSet));
if ($sameSet) {
foreach ($mediaSet as $name => $_) {
if (!isset($orderSet[$name])) {
$sameSet = false;
break;
}
}
}
if ($sameSet) {
return array_merge($sortedMedia, $other);
}
}
}
// Missing/invalid/stale order.json: sort by datetime and recreate order.json
usort($media, function($a, $b) use ($dir) {
$da = media_sort_datetime($dir . '/' . $a);
$db = media_sort_datetime($dir . '/' . $b);
if ($da === $db) return 0; // never fallback to filename ordering
return strcmp($da, $db);
});
file_put_contents($orderFile, json_encode(array_values($media), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
@chmod($orderFile, 0664);
return array_merge($media, $other);
}
function media_sort_files($dir, $files){
return list_files($dir);
}
function visual_order_files($dir){
$all = list_files($dir);
return array_values(array_filter($all, fn($name)=>is_visual_media_file($name)));
}
function write_visual_order($dir, $order){
$clean = [];
$seen = [];
foreach ((array)$order as $name) {
if (!is_string($name)) continue;
$name = basename($name);
if (!is_visual_media_file($name) || isset($seen[$name])) continue;
if (!is_file($dir . '/' . $name)) continue;
$seen[$name] = true;
$clean[] = $name;
}
file_put_contents($dir . '/order.json', json_encode(array_values($clean), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
@chmod($dir . '/order.json', 0664);
return $clean;
}
function move_visual_file_order($dir, $file, $direction){
$file = basename((string)$file);
if (!is_visual_media_file($file) || !is_file($dir . '/' . $file)) throw new RuntimeException('Invalid media file for reordering.');
$direction = strtolower(trim((string)$direction));
if (!in_array($direction, ['up','down'], true)) throw new RuntimeException('Invalid move direction.');
$order = visual_order_files($dir);
$idx = array_search($file, $order, true);
if ($idx === false) throw new RuntimeException('File not found in visual order.');
$newIdx = $idx;
if ($direction === 'up' && $idx > 0) {
[$order[$idx - 1], $order[$idx]] = [$order[$idx], $order[$idx - 1]];
$newIdx = $idx - 1;
} elseif ($direction === 'down' && $idx < count($order) - 1) {
[$order[$idx], $order[$idx + 1]] = [$order[$idx + 1], $order[$idx]];
$newIdx = $idx + 1;
}
write_visual_order($dir, $order);
return [
'changed' => $newIdx !== $idx,
'index' => $newIdx,
'count' => count($order),
];
}
function input_dir_date_from_media($dir){
$dates = [];
foreach (list_files($dir) as $file) {
$date = media_file_date($dir . '/' . $file);
if ($date !== null) $dates[] = $date;
}
if ($dates) {
sort($dates, SORT_STRING);
return $dates[0];
}
return date('Ymd');
}
function input_dir_date_from_text($text){
$text = trim((string)$text);
if ($text === '') return null;
if (preg_match('/^(\d{4})[.-]?(\d{2})[.-]?(\d{2})$/', $text, $m) && checkdate((int)$m[2], (int)$m[3], (int)$m[1])) return "$m[1]$m[2]$m[3]";
if (preg_match('/^(\d{1,2})[\/. -](\d{1,2})[\/. -](\d{4})$/', $text, $m) && checkdate((int)$m[2], (int)$m[1], (int)$m[3])) return sprintf('%04d%02d%02d', $m[3], $m[2], $m[1]);
$ts = strtotime($text);
if ($ts !== false) return date('Ymd', $ts);
if (preg_match('/^(jan|january|feb|february|mar|march|apr|april|may|jun|june|jul|july|aug|august|sep|sept|september|oct|october|nov|november|dec|december)\s+(\d{4})$/i', $text, $m)) {
$months = ['jan'=>1,'january'=>1,'feb'=>2,'february'=>2,'mar'=>3,'march'=>3,'apr'=>4,'april'=>4,'may'=>5,'jun'=>6,'june'=>6,'jul'=>7,'july'=>7,'aug'=>8,'august'=>8,'sep'=>9,'sept'=>9,'september'=>9,'oct'=>10,'october'=>10,'nov'=>11,'november'=>11,'dec'=>12,'december'=>12];
return sprintf('%04d%02d01', (int)$m[2], $months[strtolower($m[1])]);
}
return null;
}
function unique_input_dir($base, $slug){
$dir = $base . '/' . $slug;
if (!file_exists($dir)) return $dir;
for ($i = 2; ; $i++) {
$candidate = $base . '/' . $slug . '-' . $i;
if (!file_exists($candidate)) return $candidate;
}
}
function input_dir_signature($dir){
$script = <<<'PY'
import hashlib, json, os, sys
root = sys.argv[1]
entries = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d != '.movmaker-lock']
for name in filenames:
if name in {'.movmaker-state.json', '.movmaker-enabled', '.movmaker-preview', '.mvlog-hidden', '.mvlog-id', '.mvlog-hide-map'}:
continue
path = os.path.join(dirpath, name)
rel = os.path.relpath(path, root)
st = os.stat(path)
mtime_ns = getattr(st, 'st_mtime_ns', int(st.st_mtime * 1_000_000_000))
entries.append([rel, st.st_size, mtime_ns])
entries.sort()
print(hashlib.sha256(json.dumps(entries, ensure_ascii=False, separators=(',', ':')).encode('utf-8')).hexdigest())
PY;
$cmd = 'python3 -c ' . escapeshellarg($script) . ' ' . escapeshellarg($dir);
$output = trim(shell_exec($cmd) ?? '');
return $output !== '' ? $output : '';
}
function input_dir_info($dir){
$data = read_data($dir);
$files = list_files($dir);
return [
'name' => basename($dir),
'article_id' => ensure_article_id($dir),
'title' => $data['title'] ?: basename($dir),
'sort_date' => input_dir_date_from_text($data['date'] ?? '') ?? input_dir_date_from_media($dir),
'file_count' => count($files),
'signature' => input_dir_signature($dir),
];
}
function load_input_dir_cache($dirs){
$cacheFile = __DIR__ . '/cache/input-dirs.json';
$cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : [];
if (!is_array($cache) || ($cache['version'] ?? 0) !== 1) $cache = ['version'=>1,'items'=>[]];
$oldItems = is_array($cache['items'] ?? null) ? $cache['items'] : [];
$items = []; $changed = false;
foreach ($dirs as $dir) {
$name = basename($dir);
$sig = input_dir_signature($dir);
$cached = $oldItems[$name] ?? null;
if (is_array($cached) && ($cached['signature'] ?? '') === $sig && !empty($cached['article_id'])) {
$items[$name] = $cached;
} else {
$items[$name] = input_dir_info($dir);
$changed = true;
}
}
if (array_diff(array_keys($oldItems), array_keys($items))) $changed = true;
if ($changed) file_put_contents($cacheFile, json_encode(['version'=>1,'generated_at'=>date('c'),'items'=>$items], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
return $items;
}
function sort_input_dirs_by_metadata_date(&$dirs, $dirInfo){
usort($dirs, function($a, $b) use ($dirInfo) {
$ad = $dirInfo[basename($a)]['sort_date'] ?? '00000000';
$bd = $dirInfo[basename($b)]['sort_date'] ?? '00000000';
$cmp = strcmp($bd, $ad);
return $cmp !== 0 ? $cmp : strnatcasecmp(basename($b), basename($a));
});
}
function orphan_videos($config){
$referenced = [];
foreach (input_dirs($config['uploads_dir']) as $dir) {
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
if (is_array($state) && !empty($state['output'])) $referenced[basename((string)$state['output'])] = true;
}
$videos = glob($config['videos_dir'].'/*.{mp4,webm,mov,m4v}', GLOB_BRACE) ?: [];
return array_values(array_filter($videos, fn($v)=>empty($referenced[basename($v)])));
}
function input_dir_status($dir){
$stateFile = $dir . '/.movmaker-state.json';
$lockDir = $dir . '/.movmaker-lock';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
$status = is_array($state) ? (string)($state['status'] ?? '') : '';
if (is_dir($lockDir)) return $status !== '' ? $status : 'processing';
return in_array($status, ['processing', 'stale', 'error'], true) ? $status : '';
}
function input_dir_running($dir){
$stateFile = $dir . '/.movmaker-state.json';
$lockDir = $dir . '/.movmaker-lock';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
$status = is_array($state) ? (string)($state['status'] ?? '') : '';
return is_dir($lockDir) || $status === 'processing';
}
function input_dir_enabled($dir){ return is_file($dir . '/.movmaker-enabled'); }
function set_input_dir_enabled($dir, $enabled){
$path = $dir . '/.movmaker-enabled';
if ($enabled) { file_put_contents($path, "enabled\n"); chmod($path, 0664); }
elseif (is_file($path)) unlink($path);
}
function input_dir_preview($dir){
if (is_file($dir . '/.movmaker-preview')) return true;
$stateFile = $dir . '/.movmaker-state.json';
if (!is_file($stateFile)) return false;
$state = json_decode((string)file_get_contents($stateFile), true);
return is_array($state) && !empty($state['preview']);
}
function set_input_dir_preview($dir, $preview){
$path = $dir . '/.movmaker-preview';
if ($preview) { file_put_contents($path, "preview\n"); chmod($path, 0664); }
elseif (is_file($path)) unlink($path);
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
if (!is_array($state)) $state = [];
$state['article_id'] = ensure_article_id($dir);
$state['preview'] = (bool)$preview;
$state['preview_updated_at'] = gmdate('c');
file_put_contents($stateFile, json_encode($state, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n");
chmod($stateFile, 0664);
}
function input_dir_visible($dir){ return !is_file($dir . '/.mvlog-hidden'); }
function set_input_dir_visible($dir, $visible){
$path = $dir . '/.mvlog-hidden';
if ($visible) { if (is_file($path)) unlink($path); }
else { file_put_contents($path, "hidden\n"); chmod($path, 0664); }
}
function input_dir_map_hidden($dir){ return is_file($dir . '/' . MAP_HIDDEN_FILE); }
function set_input_dir_map_hidden($dir, $hidden){
$path = $dir . '/' . MAP_HIDDEN_FILE;
if ($hidden) { file_put_contents($path, "hide-map\n"); chmod($path, 0664); }
elseif (is_file($path)) unlink($path);
}
function is_image_media_file($name){
$ext = strtolower(pathinfo((string)$name, PATHINFO_EXTENSION));
return in_array($ext, ['jpg','jpeg','png','webp','gif'], true);
}
function video_thumb_public_url($config, $videoName){
$videoName = basename((string)$videoName);
if ($videoName === '') return '';
$base = pathinfo($videoName, PATHINFO_FILENAME);
foreach (['jpg','jpeg','png','webp','gif'] as $ext) {
$file = $config['thumbs_dir'] . '/' . $base . '.' . $ext;
if (is_file($file)) return $config['public_thumbs'] . '/' . rawurlencode($base . '.' . $ext) . '?v=' . filemtime($file);
}
return '';
}
function ensure_state_article_id($dir){
$articleId = ensure_article_id($dir);
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
if (!is_array($state)) $state = [];
if (($state['article_id'] ?? '') !== $articleId) {
$state['article_id'] = $articleId;
file_put_contents($stateFile, json_encode($state, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n");
chmod($stateFile, 0664);
}
return $articleId;
}
function input_dir_state($dir){
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
if (!is_array($state)) $state = [];
$state['article_id'] = ($state['article_id'] ?? '') ?: read_article_id($dir);
return $state;
}
function active_worker_jobs($config){
$jobs = [];
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,'id'=>$articleId,'status'=>$status ?: ($locked ? 'processing' : 'unknown'),'started_at'=>$started];
}
}
return $jobs;
}
function format_job_age($startedAt){
$ts = strtotime((string)$startedAt);
if (!$ts) return '';
$seconds = max(0, time() - $ts);
$h = intdiv($seconds, 3600);
$m = intdiv($seconds % 3600, 60);
$s = $seconds % 60;
if ($h > 0) return $h . 'h ' . $m . 'm';
if ($m > 0) return $m . 'm ' . $s . 's';
return $s . 's';
}
function input_dir_output($dir){
$state = input_dir_state($dir);
return basename((string)($state['output'] ?? ''));
}
function regenerate_input_dir_maps($dir, $config){
$python = trim((string)shell_exec('command -v python3 2>/dev/null'));
$script = __DIR__ . '/bin/gpsmap.py';
if ($python === '' || !is_file($script)) return;
$state = input_dir_state($dir);
$targets = [];
foreach (['output', 'preview_output'] as $key) {
$name = basename((string)($state[$key] ?? ''));
if ($name === '') continue;
$targets[] = $config['videos_dir'] . '/' . pathinfo($name, PATHINFO_FILENAME) . '.png';
}
// Fallback target if no output filename is known yet.
if (!$targets) {
$targets[] = $config['videos_dir'] . '/' . basename((string)$dir) . '.png';
}
$targets = array_values(array_unique($targets));
foreach ($targets as $outPng) {
$cmd = escapeshellarg($python) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($dir) . ' ' . escapeshellarg($outPng) . ' 2>&1';
$output = (string)shell_exec($cmd);
if (is_file($outPng)) {
@chmod($outPng, 0664);
} else {
error_log('[mvlog] map regenerate failed for '.basename((string)$dir).' target='.basename((string)$outPng).' output='.trim($output)."\n", 3, '/var/log/mvlog_map.log');
}
}
}
function cached_video_metadata($output, $fallback){
$meta = ['title'=>$fallback['title'] ?? '', 'date'=>$fallback['date'] ?? '', 'location'=>$fallback['place'] ?? '', 'quote_da'=>$fallback['quote_da'] ?? '', 'description'=>$fallback['description'] ?? ''];
$cacheFile = __DIR__ . '/cache/videos.json';
$cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : [];
$cached = $cache['items'][$output]['metadata'] ?? null;
if (is_array($cached)) {
foreach (['title','date','location','quote_da','description'] as $k) if (!empty($cached[$k])) $meta[$k] = $cached[$k];
}
return $meta;
}
function paginate($items, $page, $perPage = 8){
$total = count($items);
$pages = max(1, (int)ceil($total / $perPage));
$page = max(1, min((int)$page, $pages));
return [$page, $pages, array_slice($items, ($page - 1) * $perPage, $perPage), $total];
}
function page_links($tab, $page, $pages){
if ($pages <= 1) return '';
$extra = '';
if ($tab === 'edit') {
$q = trim((string)($_GET['q'] ?? ''));
if ($q !== '') $extra .= '&q=' . rawurlencode($q);
if (!empty($_GET['f_preview'])) $extra .= '&f_preview=1';
if (!empty($_GET['f_render'])) $extra .= '&f_render=1';
if (!empty($_GET['f_shown'])) $extra .= '&f_shown=1';
}
$html = '<div class="pages">';
for ($i = 1; $i <= $pages; $i++) {
$class = $i === $page ? 'active' : '';
$html .= '<a class="'.$class.'" href="new.php?tab='.rawurlencode($tab).'&page='.$i.$extra.'">'.$i.'</a>';
}
return $html . '</div>';
}
function save_uploads($field, $dest, $allowed){
if (empty($_FILES[$field])) return [];
$files = $_FILES[$field]; $saved = [];
$count = is_array($files['name']) ? count($files['name']) : 0;
for ($i=0; $i<$count; $i++) {
if ($files['error'][$i] === UPLOAD_ERR_NO_FILE) continue;
if ($files['error'][$i] !== UPLOAD_ERR_OK) throw new RuntimeException("Upload failed: ".$files['name'][$i]);
$ext = strtolower(pathinfo($files['name'][$i], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed, true)) throw new RuntimeException("File type not allowed: ".$files['name'][$i]);
$base = preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($files['name'][$i]));
$target = $dest . '/' . sprintf('%03d_', count(glob($dest.'/*') ?: [])+count($saved)+1) . $base;
if (!move_uploaded_file($files['tmp_name'][$i], $target)) throw new RuntimeException("Cannot save upload");
$saved[] = basename($target);
}
return $saved;
}
$msg = $_GET['msg'] ?? null; $err = null;
$allowedMedia = ['jpg','jpeg','png','webp','gif','mp4','mov','m4v','avi','mkv','webm'];
$allowedAudio = ['mp3','wav','m4a','aac','ogg','flac'];
$articleIndex = load_articles_index($config['uploads_dir']);
try {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'create';
if ($action === 'create') {
$title = trim($_POST['title'] ?? '');
if ($title === '') throw new RuntimeException('Title is required.');
$tmpSlug = '.upload-' . date('YmdHis') . '-' . bin2hex(random_bytes(4));
$tmpDir = $config['uploads_dir'] . '/' . $tmpSlug;
if (!mkdir($tmpDir, 0775, true)) throw new RuntimeException('Cannot create temporary input directory.');
chmod($tmpDir, 02775);
$media = save_uploads('media', $tmpDir, $allowedMedia);
if (!$media) { rrmdir($tmpDir); throw new RuntimeException('Upload at least one image or video.'); }
save_uploads('audio', $tmpDir, $allowedAudio);
$mediaDate = input_dir_date_from_text($_POST['date'] ?? '') ?? input_dir_date_from_media($tmpDir);
$slug = $mediaDate . '_' . slugify($title);
$dir = unique_input_dir($config['uploads_dir'], $slug);
$slug = basename($dir);
if (!rename($tmpDir, $dir)) { rrmdir($tmpDir); throw new RuntimeException('Cannot create input directory.'); }
chmod($dir, 02775);
$articleId = ensure_article_id($dir);
write_data($dir, $_POST);
set_input_dir_enabled($dir, false);
set_input_dir_preview($dir, false);
set_input_dir_visible($dir, false);
if (!empty($_POST['ajax'])) {
ajax_json(['ok'=>true, 'message'=>"Created movmaker input directory: in-dir/$slug", 'dir'=>$slug, 'id'=>$articleId, 'tab'=>'edit']);
}
header('Location: new.php?tab=edit');
exit;
} elseif ($action === 'set_enabled') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
$enabledNow = !empty($_POST['enabled']);
set_input_dir_enabled($dir, $enabledNow);
if ($enabledNow) {
set_input_dir_preview($dir, false);
}
$state = input_dir_state($dir);
$output = basename((string)($state['output'] ?? ''));
$canShow = $output !== '' && is_file($config['videos_dir'].'/'.$output) && !input_dir_preview($dir);
if (!$canShow) set_input_dir_visible($dir, false);
$message = ($enabledNow ? 'Enabled rendering for: in-dir/' : 'Disabled rendering for: in-dir/') . basename($dir);
if (!empty($_POST['ajax'])) {
ajax_json([
'ok' => true,
'enabled' => $enabledNow,
'preview' => input_dir_preview($dir),
'visible' => input_dir_visible($dir) && $canShow,
'can_show' => $canShow,
'dir' => basename($dir),
'id' => $articleId,
'message' => $message,
]);
}
header('Location: new.php?tab=edit&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'set_preview') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
$previewNow = !empty($_POST['preview']);
set_input_dir_preview($dir, $previewNow);
if ($previewNow) { set_input_dir_enabled($dir, false); set_input_dir_visible($dir, false); }
$state = input_dir_state($dir);
$output = basename((string)($state['output'] ?? ''));
$canShow = $output !== '' && is_file($config['videos_dir'].'/'.$output) && !input_dir_preview($dir);
$message = ($previewNow ? 'Enabled preview for: in-dir/' : 'Disabled preview for: in-dir/') . basename($dir);
if (!empty($_POST['ajax'])) {
ajax_json([
'ok' => true,
'preview' => $previewNow,
'enabled' => input_dir_enabled($dir),
'visible' => input_dir_visible($dir) && $canShow,
'can_show' => $canShow,
'dir' => basename($dir),
'id' => $articleId,
'message' => $message,
]);
}
header('Location: new.php?tab=edit&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'set_visible') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
$visibleNow = !empty($_POST['visible']);
$state = input_dir_state($dir);
$output = basename((string)($state['output'] ?? ''));
$canShow = $output !== '' && is_file($config['videos_dir'].'/'.$output) && !input_dir_preview($dir);
if ($visibleNow && !$canShow) throw new RuntimeException('Only finished full-quality videos can be shown.');
set_input_dir_visible($dir, $visibleNow && $canShow);
// Attempt to send "Show enabled" notification (server-side) on enable. Non-fatal.
if ($visibleNow && $canShow) {
if (is_file(__DIR__ . '/lib/send_push.php')) {
include_once __DIR__ . '/lib/send_push.php';
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');
}
}
$message = ($visibleNow ? 'Shown: in-dir/' : 'Hidden: in-dir/') . basename($dir);
$currentVisible = input_dir_visible($dir) && $canShow;
if (!empty($_POST['ajax'])) {
ajax_json([
'ok' => true,
'visible' => $currentVisible,
'can_show' => $canShow,
'dir' => basename($dir),
'id' => $articleId,
'message' => $message,
]);
}
header('Location: new.php?tab=edit&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'set_map_hidden') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
$mapHiddenNow = !empty($_POST['map_hidden']);
set_input_dir_map_hidden($dir, $mapHiddenNow);
$message = ($mapHiddenNow ? 'Map hidden on index for: in-dir/' : 'Map visible on index for: in-dir/') . basename($dir);
if (!empty($_POST['ajax'])) {
ajax_json([
'ok' => true,
'map_hidden' => $mapHiddenNow,
'dir' => basename($dir),
'id' => $articleId,
'message' => $message,
]);
}
header('Location: new.php?tab=edit&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'set_video_thumb') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
$file = basename((string)($_POST['file'] ?? ''));
if ($file === '' || !is_image_media_file($file) || !is_file($dir . '/' . $file)) throw new RuntimeException('Invalid image file.');
$state = input_dir_state($dir);
$outputs = [];
foreach (['output', 'preview_output'] as $key) {
$name = basename((string)($state[$key] ?? ''));
if ($name !== '' && is_file($config['videos_dir'] . '/' . $name)) $outputs[] = $name;
}
$outputs = array_values(array_unique($outputs));
if (!$outputs) throw new RuntimeException('No rendered video found for this input directory yet.');
$src = $dir . '/' . $file;
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
$written = [];
foreach ($outputs as $out) {
$base = pathinfo($out, PATHINFO_FILENAME);
foreach (['jpg','jpeg','png','webp','gif'] as $oldExt) {
$old = $config['thumbs_dir'] . '/' . $base . '.' . $oldExt;
if (is_file($old)) @unlink($old);
}
$target = $config['thumbs_dir'] . '/' . $base . '.' . $ext;
if (!@copy($src, $target)) throw new RuntimeException('Failed to write thumbnail.');
@chmod($target, 0664);
$written[] = basename($target);
}
$message = 'Thumbnail set from ' . $file . ' for ' . count($written) . ' video(s).';
if (!empty($_POST['ajax'])) {
ajax_json([
'ok' => true,
'message' => $message,
'dir' => basename($dir),
'id' => $articleId,
'written' => $written,
]);
}
header('Location: new.php?tab=edit&id=' . rawurlencode((string)$articleId) . '&edit=' . rawurlencode(basename($dir)) . '&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'update') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
write_data($dir, $_POST);
save_uploads('media', $dir, $allowedMedia);
save_uploads('audio', $dir, $allowedAudio);
// Always regenerate map image(s) on Save changes.
regenerate_input_dir_maps($dir, $config);
if (!empty($_POST['ajax'])) ajax_json(['ok'=>true, 'message'=>'Updated input directory: in-dir/' . basename($dir), 'dir'=>basename($dir), 'id'=>$articleId]);
header('Location: new.php?tab=edit');
exit;
} elseif ($action === 'delete_file') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
$file = basename((string)($_POST['file'] ?? ''));
if (!editable_file($file) || !is_file($dir.'/'.$file)) throw new RuntimeException('Invalid file.');
unlink($dir.'/'.$file);
if (!empty($_POST['ajax'])) {
ajax_json(['ok'=>true, 'message'=>'Removed file: ' . $file, 'tab'=>'edit']);
}
header('Location: new.php?tab=edit');
exit;
} elseif ($action === 'reorder_file') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
$file = basename((string)($_POST['file'] ?? ''));
$direction = strtolower(trim((string)($_POST['direction'] ?? '')));
$result = move_visual_file_order($dir, $file, $direction);
$message = $result['changed'] ? ('Moved file ' . ($direction === 'up' ? 'up' : 'down') . ': ' . $file) : 'No move possible for file: ' . $file;
if (!empty($_POST['ajax'])) {
ajax_json([
'ok' => true,
'message' => $message,
'file' => $file,
'direction' => $direction,
'changed' => !empty($result['changed']),
'index' => (int)($result['index'] ?? -1),
'count' => (int)($result['count'] ?? 0),
'dir' => basename($dir),
'id' => $articleId,
]);
}
header('Location: new.php?tab=edit&id=' . rawurlencode((string)$articleId) . '&edit=' . rawurlencode(basename($dir)) . '&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'delete_video') {
$file = basename((string)($_POST['video'] ?? ''));
$path = $config['videos_dir'] . '/' . $file;
if ($file === '' || !is_file($path)) throw new RuntimeException('Invalid video.');
unlink($path);
$cache = __DIR__ . '/cache/videos.json';
if (is_file($cache)) unlink($cache);
if (!empty($_POST['ajax'])) {
ajax_json(['ok'=>true, 'message'=>'Deleted video: ' . $file, 'tab'=>'videos']);
}
header('Location: new.php?tab=videos');
exit;
} elseif ($action === 'delete_dir') {
$dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex);
$articleId = ensure_state_article_id($dir);
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
$name = basename($dir);
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
$output = is_array($state) ? basename((string)($state['output'] ?? '')) : '';
if (!empty($_POST['delete_output']) && $output !== '') {
$outPath = $config['videos_dir'] . '/' . $output;
if (is_file($outPath)) unlink($outPath);
$cache = __DIR__ . '/cache/videos.json';
if (is_file($cache)) unlink($cache);
}
rrmdir($dir);
if (!empty($_POST['ajax'])) {
ajax_json(['ok'=>true, 'message'=>'Deleted input directory: in-dir/' . $name, 'tab'=>'edit']);
}
header('Location: new.php?tab=edit');
exit;
}
}
} catch (Throwable $e) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['ajax'])) {
http_response_code(400);
ajax_json(['ok'=>false, 'error'=>$e->getMessage()]);
}
$err = $e->getMessage();
}
$dirs = input_dirs($config['uploads_dir']);
$articleIndex = load_articles_index($config['uploads_dir'], $dirs);
$dirInfo = load_input_dir_cache($dirs);
sort_input_dirs_by_metadata_date($dirs, $dirInfo);
$orphanVideos = orphan_videos($config);
$tab = $_GET['tab'] ?? 'edit';
$rawKeyword = trim((string)($_GET['q'] ?? ''));
$keywordType = 'general';
$keywordValue = $rawKeyword;
$filterPreview = !empty($_GET['f_preview']);
$filterRender = !empty($_GET['f_render']);
$filterShown = !empty($_GET['f_shown']);
$statusFilterActive = $filterPreview || $filterRender || $filterShown;
$searchEmpty = ($rawKeyword === '' && !$statusFilterActive);
if ($rawKeyword !== '' && preg_match('/^\s*(date|location|place)\s*=\s*(.+)\s*$/i', $rawKeyword, $m)) {
$keywordType = strtolower((string)$m[1]);
if ($keywordType === 'place') $keywordType = 'location';
$keywordValue = trim((string)$m[2]);
}
if ($keywordValue !== '' || $statusFilterActive) {
$dirs = array_values(array_filter($dirs, function($dir) use ($keywordType, $keywordValue, $config, $statusFilterActive, $filterPreview, $filterRender, $filterShown) {
$data = read_data($dir);
$state = input_dir_state($dir);
$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);
$preview = input_dir_preview($dir);
$enabled = input_dir_enabled($dir);
$canShow = $hasFullVideo && !$preview;
$visible = $canShow && input_dir_visible($dir);
if ($statusFilterActive) {
$statusMatch = false;
if ($filterPreview && $preview) $statusMatch = true;
if ($filterRender && $enabled) $statusMatch = true;
if ($filterShown && $visible) $statusMatch = true;
if (!$statusMatch) return false;
}
if ($keywordValue === '') return true;
$title = (string)(($data['title'] ?? '') !== '' ? $data['title'] : basename($dir));
$teaser = (string)($data['teaser'] ?? '');
$description = (string)($data['description'] ?? '');
$quoteDa = (string)($data['quote_da'] ?? '');
$date = (string)($data['date'] ?? '');
$location = (string)($data['place'] ?? '');
if ($keywordType === 'date') return ci_contains($date, $keywordValue);
if ($keywordType === 'location') return ci_contains($location, $keywordValue);
return ci_contains($title, $keywordValue) || ci_contains($teaser, $keywordValue) || ci_contains($quoteDa, $keywordValue) || ci_contains($description, $keywordValue);
}));
}
$page = max(1, (int)($_GET['page'] ?? 1));
[$editPage, $editPages, $dirsPage, $dirsTotal] = paginate($dirs, $tab === 'edit' ? $page : 1);
[$videoPage, $videoPages, $orphanVideosPage, $orphanVideosTotal] = paginate($orphanVideos, $tab === 'videos' ? $page : 1);
$editName = $_GET['edit'] ?? '';
$editId = $_GET['id'] ?? '';
$editDir = null; $editStatus = ''; $editRunning = false; $editData = ['title'=>'','teaser'=>'','quote_da'=>'','date'=>'','place'=>'','description'=>'','captions'=>[],'video_audio'=>[]]; $editFiles = [];
if ($editName !== '' || $editId !== '') { try { $editDir = resolve_input_dir_from_request($config['uploads_dir'], $editId, $editName, $articleIndex); $editStatus = input_dir_status($editDir); $editRunning = input_dir_running($editDir); if (!$editRunning) { $editData = read_data($editDir); $editFiles = media_sort_files($editDir, list_files($editDir)); } } catch (Throwable $e) { $err = $e->getMessage(); } }
$runningJobs = active_worker_jobs($config);
?>
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>MVL Admin</title><link rel="icon" type="image/png" href="assets/img/moto_travel.png"><link rel="stylesheet" href="style.css"><style>.admin-search-form{display:flex;flex-wrap:nowrap;align-items:center;gap:.55rem;margin:0 0 .6rem;overflow-x:auto;padding-bottom:2px}.admin-search-wrap{position:relative;min-width:180px;flex:1 1 auto}.admin-search-wrap input{margin:0;padding-right:2.15rem}.admin-search-clear{position:absolute;right:.45rem;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;border-radius:50%;background:#C46A3A;color:#111315;text-decoration:none;font-size:1rem;line-height:1;font-weight:700;box-shadow:0 1px 4px #0007;transition:opacity .15s ease,transform .15s ease}.admin-search-clear:hover{background:#d97b48;color:#111315;transform:translateY(-50%) scale(1.05)}.admin-search-clear.is-empty{opacity:.38;pointer-events:none}.admin-search-submit{display:inline-grid;place-items:center;width:2.35rem;height:2.35rem;padding:0;border-radius:.5rem;background:#C46A3A;color:#111315;flex:0 0 auto}.admin-search-submit .icon{font-size:1.02rem;line-height:1;transform:translateY(.01em)}.admin-search-filters{display:flex;flex-wrap:nowrap;gap:.7rem;align-items:center;flex:0 0 auto;white-space:nowrap}.admin-filter-item{display:inline-flex;align-items:center;gap:.35rem;margin:0;white-space:nowrap;font-size:.92rem}.admin-filter-item input[type=checkbox]{width:.9rem;height:.9rem;margin:0}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.description-teaser{margin:.2rem 0 .45rem;color:#eadfca}.description-quote{margin:.2rem 0 .45rem;font-style:italic;font-size:.82rem;color:#d4d7dd}</style></head><body>
<header class="site-header admin-header"><div class="brand-wrap"><a class="header-logo" href="index.php" aria-label="MVLog home"><img src="assets/img/moto_travel.png" alt=""></a><a class="brand" href="index.php"><h1 style="font-size:1.15rem">MVLog <span class="admin-word">Admin</span></h1><p>journal of an<br>unreliable narrator.</p></a></div></header>
<main>
<?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/teaser/quote_da/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 : ''; $posterUrl=$hasVideo ? video_thumb_public_url($config, $displayOutput) : ''; $previewVideo=$hasVideo && $displayOutput === $previewOutput; $canShow=$hasFullVideo && !$preview; $visible=$canShow && input_dir_visible($d); $mapHidden=input_dir_map_hidden($d); $info=$dirInfo[basename($d)] ?? input_dir_info($d); $data=read_data($d); $meta=['title'=>(string)($data['title'] ?? ''),'teaser'=>(string)($data['teaser'] ?? ''),'date'=>(string)($data['date'] ?? ''),'location'=>(string)($data['place'] ?? ''),'quote_da'=>(string)($data['quote_da'] ?? ''),'description'=>(string)($data['description'] ?? '')]; $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))?>"<?= $posterUrl !== '' ? ' poster="'.h($posterUrl).'"' : '' ?>></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))?>">
<input type="hidden" name="id" value="<?=h(read_article_id($d))?>">
<label class="switch-label">
<input type="checkbox" name="preview" value="1" <?=$preview?'checked':''?>>
<span class="switch-ui" aria-hidden="true"></span><span class="switch-text">Preview</span>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<form method="post" class="inline-form switch-form" data-switch="render">
<input type="hidden" name="action" value="set_enabled">
<input type="hidden" name="dir" value="<?=h(basename($d))?>">
<input type="hidden" name="id" value="<?=h(read_article_id($d))?>">
<label class="switch-label">
<input type="checkbox" name="enabled" value="1" <?=$enabled?'checked':''?>>
<span class="switch-ui" aria-hidden="true"></span><span class="switch-text">Render</span>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<form method="post" class="inline-form switch-form" data-switch="show">
<input type="hidden" name="action" value="set_visible">
<input type="hidden" name="dir" value="<?=h(basename($d))?>">
<input type="hidden" name="id" value="<?=h(read_article_id($d))?>">
<label class="switch-label">
<input type="checkbox" name="visible" value="1" <?=$visible?'checked':''?> <?=!$canShow?'disabled':''?>>
<span class="switch-ui" aria-hidden="true"></span><span class="switch-text">Show</span>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<form method="post" class="inline-form switch-form" data-switch="hide-map">
<input type="hidden" name="action" value="set_map_hidden">
<input type="hidden" name="dir" value="<?=h(basename($d))?>">
<input type="hidden" name="id" value="<?=h(read_article_id($d))?>">
<label class="switch-label">
<input type="checkbox" name="map_hidden" value="1" <?=$mapHidden?'checked':''?>>
<span class="switch-ui" aria-hidden="true"></span><span class="switch-text">Hide map</span>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<?php if($hasVideo): ?><time class="admin-time<?= $staleLabel ? ' admin-time-stale' : '' ?>" title="<?=h($staleLabel ?: 'Video created at')?>"><?=h(date('d.m.Y H:i', filemtime($displayVideoPath)))?></time><?php endif; ?>
</div><div class="admin-actions"><?php if($running): ?><span class="button disabled" title="Rendering now" data-edit-action="1">Rendering</span><?php else: ?><a class="button" href="?id=<?=rawurlencode(read_article_id($d))?>&edit=<?=rawurlencode(basename($d))?>" data-edit-action="1" data-edit-href="?id=<?=rawurlencode(read_article_id($d))?>&edit=<?=rawurlencode(basename($d))?>">Edit</a><?php endif; ?></div></div><h2><?=h($meta['title'] ?: ($info['title'] ?? basename($d)))?></h2><p class="meta"><?php if($meta['date']): ?><span><?=h($meta['date'])?></span><?php endif; ?><?php if($meta['location']): ?><span><?=h($meta['location'])?></span><?php endif; ?><?php if(!$hasVideo): ?><span>No video</span><?php endif; ?><?php if($previewVideo): ?><span>Preview video</span><?php endif; ?></p><?php if(!empty($meta['teaser'])): ?><p class="description-teaser"><?=h((string)$meta['teaser'])?></p><?php endif; ?><?php if($meta['description']): ?><p class="description"><?=nl2br(h($meta['description']), false)?></p><?php endif; ?><?php if(!empty($meta['quote_da'])): ?><p class="description-quote">“<?=h(trim((string)$meta['quote_da'], "\"“”"))?>”</p><?php endif; ?><p class="details"><?=h(basename($d))?> · <?=h($info['file_count'] ?? count(list_files($d)))?> files</p></div></article><?php endforeach; ?></div><?=page_links('edit',$editPage,$editPages)?></section><?php endif; ?>
<?php if($tab==='edit' && $editDir && $editRunning): ?><section><h2>Edit input dir</h2><p><code>in-dir/<?=h(basename($editDir))?></code></p><div class="err">This input directory is <?=h($editStatus)?>. Editing is disabled until the job completes.</div></section><?php endif; ?>
<?php if($tab==='edit' && $editDir && !$editRunning): ?><section><h2>Edit input dir</h2><p><code>in-dir/<?=h(basename($editDir))?></code></p><form method="post" enctype="multipart/form-data" id="edit-form"><input type="hidden" name="action" value="update"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><input type="hidden" name="id" value="<?=h(ensure_article_id($editDir))?>"><label>Title<input name="title" value="<?=h($editData['title'])?>"></label><label>Teaser<input name="teaser" value="<?=h($editData['teaser'])?>" maxlength="240"></label><label>Danish quote<input name="quote_da" value="<?=h($editData['quote_da'])?>" maxlength="280"></label><label>Date<input name="date" value="<?=h($editData['date'])?>"></label><label>Place<input name="place" value="<?=h($editData['place'])?>"></label><label>Description<textarea name="description" maxlength="5000" data-counter="description-counter-edit"><?=h($editData['description'])?></textarea><small id="description-counter-edit" class="counter"></small></label><label>Add images / videos<input type="file" name="media[]" multiple accept="image/*,video/*"></label><label>Add audio<input type="file" name="audio[]" multiple accept="audio/*"></label><h3>Files and captions</h3><div class="file-list"><?php foreach($editFiles as $f): $ext=strtolower(pathinfo($f, PATHINFO_EXTENSION)); $fileUrl='in-dir/'.rawurlencode(basename($editDir)).'/'.rawurlencode($f); ?><div class="caption-row"><div class="preview"><?php if(in_array($ext,['jpg','jpeg','png','webp','gif'])): ?><img src="<?=h($fileUrl)?>" alt=""><?php elseif(in_array($ext,['mp4','mov','m4v','webm'])): ?><video src="<?=h($fileUrl)?>" controls preload="metadata"></video><a class="download-link" href="<?=h($fileUrl)?>" download>Download</a><?php else: ?><span><?=h(strtoupper($ext ?: 'FILE'))?></span><?php endif; ?></div><div class="caption-fields"><strong><?=h($f)?></strong><?php if(in_array($ext,['jpg','jpeg','png','webp','gif','mp4','mov','m4v','webm'])): ?><input type="hidden" name="caption_files[]" value="<?=h($f)?>"><textarea name="captions[]" placeholder="Optional caption for this file"><?=h($editData['captions'][$f] ?? '')?></textarea><?php if(in_array($ext,['mp4','mov','m4v','webm'])): ?><label class="checkbox-label"><input type="checkbox" name="use_audio_files[]" value="<?=h($f)?>" <?=!empty($editData['video_audio'][$f])?'checked':''?>> <span>Use video file audio</span></label><?php endif; ?><?php else: ?><small>No caption for audio files</small><?php endif; ?></div><div class="file-actions" style="display:flex;gap:.45rem;flex-wrap:wrap;align-self:start"><?php if(in_array($ext,['jpg','jpeg','png','webp','gif'])): ?><button type="button" onclick="setThumb(<?=h(json_encode($f))?>)">Set as thumb</button><?php endif; ?><button type="button" onclick="deleteFile(<?=h(json_encode($f))?>)">Remove</button></div></div><?php endforeach; ?></div><button type="submit">Save changes</button></form><form method="post" id="delete-file-form" style="display:none"><input type="hidden" name="action" value="delete_file"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><input type="hidden" name="id" value="<?=h(ensure_article_id($editDir))?>"><input type="hidden" name="file" id="delete-file-name" value=""></form><form method="post" onsubmit="return confirm(\'Delete this input directory\?\')" id="delete-dir-form""><input type="hidden" name="action" value="delete_dir"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><input type="hidden" name="id" value="<?=h(ensure_article_id($editDir))?>"><label class="checkbox-label"><input type="checkbox" name="delete_output" value="1"> <span>Also delete generated video, if any</span></label><button class="danger" type="submit">Delete input dir</button></form></section><?php endif; ?>
<?php if($tab==='new'): ?><section><h2>Add new movie input</h2><form method="post" enctype="multipart/form-data" id="new-form"><input type="hidden" name="action" value="create"><label>Title*<input name="title" required></label><label>Teaser<input name="teaser" maxlength="240"></label><label>Danish quote<input name="quote_da" maxlength="280"></label><label>Date<input name="date" placeholder="2026-05-25"></label><label>Place<input name="place"></label><label>Description<textarea name="description" maxlength="5000" data-counter="description-counter-new"></textarea><small id="description-counter-new" class="counter"></small></label><label>Images / videos*<input type="file" name="media[]" multiple required accept="image/*,video/*"></label><label>Audio (optional)<input type="file" name="audio[]" multiple accept="audio/*"></label><button type="submit">Create input-dir</button></form></section><?php endif; ?>
<script>
function deleteFile(name){
if(!confirm('Remove this file?')) return;
var form = document.getElementById('delete-file-form');
if(!form){ showToast('Internal error: delete form not found', false); return; }
var dirInput = form.querySelector('input[name=dir]');
var dir = dirInput ? dirInput.value : '';
var idInput = form.querySelector('input[name=id]');
var articleId = idInput ? idInput.value : '';
// find the caption-row for this file name
var rows = document.querySelectorAll('.caption-row');
var target = null;
for(var i=0;i<rows.length;i++){
var strong = rows[i].querySelector('.caption-fields strong') || rows[i].querySelector('strong');
if(!strong) continue;
if(strong.textContent.trim() === name){ target = rows[i]; break; }
}
var body = 'action=delete_file&dir=' + encodeURIComponent(dir) + '&id=' + encodeURIComponent(articleId) + '&file=' + encodeURIComponent(name) + '&ajax=1';
fetch('new.php', {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: body
}).then(function(r){ return r.json().catch(function(){ return { ok: false, message: 'Invalid server response' }; }); })
.then(function(json){
if(!json || !json.ok) throw new Error(json && (json.error || json.message) ? (json.error || json.message) : 'Delete failed');
if(target) target.remove();
refreshFileOrderButtons();
showToast(json.message || ('Removed ' + name), true);
}).catch(function(err){
showToast(err.message || String(err), false);
});
}
function setThumb(name){
var ctx = getEditDirContext();
if(!ctx.dir){ showToast('Internal error: missing input dir', false); return; }
var body = 'action=set_video_thumb&dir=' + encodeURIComponent(ctx.dir) + '&id=' + encodeURIComponent(ctx.id) + '&file=' + encodeURIComponent(name) + '&ajax=1';
fetch('new.php', {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: body
}).then(function(r){ return r.json().catch(function(){ return { ok:false, message:'Invalid server response' }; }); })
.then(function(json){
if(!json || !json.ok) throw new Error((json && (json.error || json.message)) ? (json.error || json.message) : 'Set thumb failed');
showToast(json.message || ('Thumbnail set from ' + name), true);
}).catch(function(err){
showToast(err.message || String(err), false);
});
}
function getEditDirContext(){
var form = document.getElementById('delete-file-form');
if(!form) return {dir:'', id:''};
var dirInput = form.querySelector('input[name=dir]');
var idInput = form.querySelector('input[name=id]');
return {
dir: dirInput ? dirInput.value : '',
id: idInput ? idInput.value : ''
};
}
function captionRowFileName(row){
if(!row) return '';
var strong = row.querySelector('.caption-fields strong') || row.querySelector('strong');
return strong ? strong.textContent.trim() : '';
}
function isVisualMediaName(name){
var lower = String(name || '').toLowerCase();
return /\.(jpg|jpeg|png|webp|gif|mp4|mov|m4v|avi|mkv|webm)$/.test(lower);
}
function getVisualCaptionRows(){
return Array.prototype.slice.call(document.querySelectorAll('.caption-row')).filter(function(row){
return isVisualMediaName(captionRowFileName(row));
});
}
function moveFileOrder(name, direction, row, triggerBtn){
var ctx = getEditDirContext();
if(!ctx.dir){ showToast('Internal error: missing input dir', false); return; }
if(triggerBtn) triggerBtn.disabled = true;
var body = 'action=reorder_file'
+ '&dir=' + encodeURIComponent(ctx.dir)
+ '&id=' + encodeURIComponent(ctx.id)
+ '&file=' + encodeURIComponent(name)
+ '&direction=' + encodeURIComponent(direction)
+ '&ajax=1';
fetch('new.php', {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: body
}).then(function(r){ return r.json().catch(function(){ return { ok:false, error:'Invalid server response' }; }); })
.then(function(json){
if(!json || !json.ok) throw new Error((json && (json.error || json.message)) ? (json.error || json.message) : 'Reorder failed');
if(row && json.changed){
var rows = getVisualCaptionRows();
var idx = rows.indexOf(row);
if(direction === 'up' && idx > 0){
row.parentNode.insertBefore(row, rows[idx - 1]);
} else if(direction === 'down' && idx >= 0 && idx < rows.length - 1){
row.parentNode.insertBefore(rows[idx + 1], row);
}
}
refreshFileOrderButtons();
showToast(json.message || 'Order updated', true);
}).catch(function(err){
showToast(err.message || String(err), false);
}).finally(function(){
if(triggerBtn) triggerBtn.disabled = false;
});
}
function ensureFileOrderButtons(){
var rows = document.querySelectorAll('.caption-row');
for(var i=0;i<rows.length;i++){
var row = rows[i];
if(row.dataset.orderButtons === '1') continue;
var fileName = captionRowFileName(row);
if(!isVisualMediaName(fileName)) { row.dataset.orderButtons = '1'; continue; }
var removeBtn = row.querySelector('button[onclick*="deleteFile"]');
if(!removeBtn) { row.dataset.orderButtons = '1'; continue; }
var wrap = document.createElement('span');
wrap.className = 'file-order-controls';
wrap.style.display = 'inline-flex';
wrap.style.gap = '6px';
wrap.style.marginRight = '8px';
var upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.className = 'button order-btn-up';
upBtn.textContent = '↑';
upBtn.title = 'Move up';
upBtn.style.padding = '0.2rem 0.45rem';
var downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.className = 'button order-btn-down';
downBtn.textContent = '↓';
downBtn.title = 'Move down';
downBtn.style.padding = '0.2rem 0.45rem';
(function(r, file, up, down){
up.addEventListener('click', function(){ moveFileOrder(file, 'up', r, up); });
down.addEventListener('click', function(){ moveFileOrder(file, 'down', r, down); });
})(row, fileName, upBtn, downBtn);
wrap.appendChild(upBtn);
wrap.appendChild(downBtn);
row.insertBefore(wrap, removeBtn);
row.dataset.orderButtons = '1';
}
}
function refreshFileOrderButtons(){
ensureFileOrderButtons();
var rows = getVisualCaptionRows();
for(var i=0;i<rows.length;i++){
var row = rows[i];
var up = row.querySelector('.order-btn-up');
var down = row.querySelector('.order-btn-down');
if(up) up.disabled = (i === 0);
if(down) down.disabled = (i === rows.length - 1);
}
}
document.addEventListener('DOMContentLoaded', refreshFileOrderButtons);
</script>
<script>
function showToast(message, ok, details){
let wrap=document.getElementById('toast-wrap');
if(!wrap){
wrap=document.createElement('div');
wrap.id='toast-wrap';
wrap.className='toast-wrap';
document.body.appendChild(wrap);
}
const toast=document.createElement('div');
toast.className='toast '+(ok? 'ok-toast' : 'err-toast');
toast.style.pointerEvents = 'auto';
const text=document.createElement('div');
text.style.whiteSpace='normal';
text.textContent=message;
toast.appendChild(text);
if(details){
const actions=document.createElement('div');
actions.style.marginTop='8px';
actions.style.textAlign='right';
const dbtn=document.createElement('button');
dbtn.className='button';
dbtn.textContent='Details';
dbtn.style.padding='0.35rem 0.6rem';
dbtn.style.fontSize='0.85rem';
dbtn.addEventListener('click', function(e){ e.stopPropagation(); e.preventDefault(); showLogModal('Generator log', details); });
actions.appendChild(dbtn);
toast.appendChild(actions);
}
wrap.appendChild(toast);
requestAnimationFrame(function(){toast.classList.add('show');});
const timeout = details ? 15000 : 4000;
setTimeout(function(){
toast.classList.remove('show');
setTimeout(function(){toast.remove();},250);
}, timeout);
}
function showLogModal(title, text){
var existing=document.getElementById('mvlog-log-modal');
if(existing) existing.remove();
var overlay=document.createElement('div');
overlay.id='mvlog-log-modal';
overlay.style.cssText='position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:10001;';
var box=document.createElement('div');
box.style.cssText='background:white;color:#111;padding:16px;max-width:90%;max-height:80vh;overflow:auto;border-radius:6px;font-family:inherit;';
var h=document.createElement('h3');
h.textContent=title||'Details';
var pre=document.createElement('pre');
pre.style.cssText='white-space:pre-wrap;font-family:monospace;margin-top:10px;border:1px solid #eee;padding:12px;background:#f8f8f8;border-radius:4px; max-height:60vh; overflow:auto;';
pre.textContent=text||'';
var btnBar=document.createElement('div');
btnBar.style.cssText='margin-top:12px;text-align:right;';
var copyBtn=document.createElement('button');
copyBtn.className='button'; copyBtn.textContent='Copy';
var closeBtn=document.createElement('button'); closeBtn.className='button'; closeBtn.textContent='Close'; closeBtn.style.marginLeft='8px';
btnBar.appendChild(copyBtn); btnBar.appendChild(closeBtn);
box.appendChild(h); box.appendChild(pre); box.appendChild(btnBar); overlay.appendChild(box); document.body.appendChild(overlay);
closeBtn.addEventListener('click', function(){ overlay.remove(); });
copyBtn.addEventListener('click', function(){ try{ navigator.clipboard.writeText(text).then(function(){ showToast('Copied to clipboard', true); }, function(){ showToast('Copy failed', false); }); }catch(e){ showToast('Copy failed', false); } });
}
const editForm=document.getElementById('edit-form');
if(editForm) handleAjaxFormSubmit(editForm, 'edit');
const newForm=document.getElementById('new-form');
if(newForm) handleAjaxFormSubmit(newForm, 'edit');
const deleteFileForm=document.getElementById('delete-file-form');
if(deleteFileForm) handleAjaxFormSubmit(deleteFileForm, 'edit');
const deleteDirForm=document.getElementById('delete-dir-form');
if(deleteDirForm) handleAjaxFormSubmit(deleteDirForm, 'edit');
// For old delete video forms
// Redirect tab defaults to videos
for(const form of document.querySelectorAll('.js-ajax-form')) {
handleAjaxFormSubmit(form, form.dataset.redirectTab || 'videos');
}
function handleAjaxFormSubmit(form, redirectTab) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const submit = form.querySelector('button[type="submit"]');
const data = new FormData(form);
data.set('ajax', '1');
const action = String(data.get('action') || '');
const verb = action === 'update' ? 'Saving'
: action === 'delete_video' ? 'Deleting'
: action === 'delete_dir' ? 'Deleting'
: action === 'delete_file' ? 'Deleting'
: action === 'create' ? 'Creating'
: 'Working';
let timer = 0;
let success = false;
if (submit) {
submit.dataset.label = submit.dataset.label || submit.textContent.trim() || 'Submit';
submit.disabled = true;
submit.style.opacity = '0.78';
submit.style.cursor = 'wait';
let dots = 0;
submit.textContent = verb;
timer = window.setInterval(function(){
dots = (dots + 1) % 4;
submit.textContent = verb + '.'.repeat(dots);
}, 320);
}
try{
const res = await fetch('new.php', { method: 'POST', body: data, credentials: 'same-origin', headers: { 'Accept': 'application/json' }});
const json = await res.json().catch(() => ({}));
if(!res.ok || !json.ok) throw new Error(json.error || 'Operation failed');
success = true;
showToast(json.message || 'Operation successful', true);
setTimeout(() => { window.location.href = 'new.php?tab=' + (json.tab || redirectTab); }, 450);
} catch(e) {
showToast(e.message || 'Operation failed', false);
} finally {
if (timer) window.clearInterval(timer);
if(submit && !success) {
submit.disabled = false;
submit.style.opacity = '';
submit.style.cursor = '';
submit.textContent = submit.dataset.label || 'Submit';
}
}
});
}
const initialMessage = <?= json_encode($msg ?? '') ?>;
if (initialMessage) showToast(initialMessage, true);
initSwitchForms();
function initSwitchForms(){
document.querySelectorAll('.switch-form').forEach(function(form){
const checkbox=form.querySelector('input[type="checkbox"]');
if(!checkbox) return;
checkbox.dataset.state = checkbox.checked ? '1' : '0';
form.addEventListener('submit', function(event){ event.preventDefault(); });
checkbox.addEventListener('change', function(event){
event.preventDefault();
handleSwitchToggle(form, checkbox);
});
});
}
async function handleSwitchToggle(form, checkbox){
if(checkbox.disabled) return;
const previousState = checkbox.dataset.state === '1';
const data = new FormData(form);
data.set('ajax', '1');
if(checkbox.checked){
data.set(checkbox.name, '1');
}else{
data.delete(checkbox.name);
}
checkbox.disabled = true;
try{
const res = await fetch('new.php', { method: 'POST', body: data, credentials: 'same-origin', headers: { 'Accept': 'application/json' }});
const json = await res.json().catch(() => ({}));
if(!res.ok || !json.ok) throw new Error(json.error || 'Switch update failed');
applySwitchPayload(json);
showToast(json.message || 'Updated input directory', true);
}catch(e){
checkbox.checked = previousState;
checkbox.dataset.state = previousState ? '1' : '0';
showToast(e.message || 'Switch update failed', false);
}finally{
checkbox.disabled = false;
}
}
function applySwitchPayload(payload){
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"]');
if(!target) return;
target.checked = !!value;
target.dataset.state = target.checked ? '1' : '0';
}
if('preview' in payload) setSwitch('preview', payload.preview);
if('enabled' in payload) setSwitch('render', payload.enabled);
if('map_hidden' in payload) setSwitch('hide-map', payload.map_hidden);
if('visible' in payload){
setSwitch('show', payload.visible);
row.classList.toggle('shown', !!payload.visible);
}
if('can_show' in payload){
const showInput = row.querySelector('form[data-switch="show"] input[type="checkbox"]');
if(showInput){
showInput.disabled = !payload.can_show;
if(!payload.can_show){
showInput.checked = false;
showInput.dataset.state = '0';
row.classList.remove('shown');
}
}
}
}
</script>
<script>
document.addEventListener('DOMContentLoaded', 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(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=(articleId && runningIds.has(articleId)) || 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 || 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='';
return;
}
box.hidden=false;
box.innerHTML=jobs.map(fmt).join('');
}catch(e){
if(box.innerHTML.trim() !== '') box.hidden=false;
}
}
updateJobs();
setInterval(updateJobs,32000);
});
</script></main>
<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(){
function attachDescribeButtons(){
document.querySelectorAll('.admin-video-row').forEach(function(row){
if (row.querySelector('.describe-button')) return;
var actions = row.querySelector('.admin-actions');
if (!actions) 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);
else actions.appendChild(describe);
});
}
function showModal(job, articleId, description, teaser, quoteDa, rawJson){
var existing = document.getElementById('mvlog-gemini-modal');
if (existing) existing.remove();
var overlay = document.createElement('div');
overlay.id = 'mvlog-gemini-modal';
overlay.style.cssText = 'position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
var box = document.createElement('div');
box.style.cssText = 'background:white;color:#111;padding:20px;max-width:900px;max-height:80vh;overflow:auto;border-radius:6px;font-family:inherit;';
var h = document.createElement('h3');
h.textContent = 'Generated description';
var quoteBox = null;
if (quoteDa) {
quoteBox = document.createElement('div');
quoteBox.style.cssText = 'white-space:pre-wrap;font-style:italic;font-family:inherit;margin-top:10px;border:1px solid #eee;padding:10px;background:#f4f0ff;border-radius:4px;';
quoteBox.textContent = '“' + quoteDa + '”';
}
var pre = document.createElement('div');
pre.style.cssText = 'white-space:pre-wrap;font-family:inherit;margin-top:10px;border:1px solid #eee;padding:12px;background:#f8f8f8;border-radius:4px;';
pre.textContent = description || '';
var teaserBox = null;
if (teaser) {
teaserBox = document.createElement('div');
teaserBox.style.cssText = 'white-space:pre-wrap;font-family:inherit;margin-top:10px;border:1px solid #eee;padding:10px;background:#fff7e8;border-radius:4px;';
teaserBox.textContent = teaser;
}
var btnBar = document.createElement('div');
btnBar.style.cssText = 'margin-top:12px;text-align:right;';
var useBtn = document.createElement('button');
useBtn.className = 'button';
useBtn.textContent = 'Use in form';
var cancelBtn = document.createElement('button');
cancelBtn.className = 'button';
cancelBtn.textContent = 'Close';
cancelBtn.style.marginLeft = '8px';
btnBar.appendChild(useBtn);
btnBar.appendChild(cancelBtn);
box.appendChild(h);
if (teaserBox) box.appendChild(teaserBox);
if (quoteBox) box.appendChild(quoteBox);
box.appendChild(pre);
box.appendChild(btnBar);
overlay.appendChild(box);
document.body.appendChild(overlay);
cancelBtn.addEventListener('click', function(){ overlay.remove(); });
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"]');
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 || '';
var teaserInput = editForm.querySelector('input[name="teaser"]');
if (teaserInput && teaser) teaserInput.value = teaser;
var quoteInput = editForm.querySelector('input[name="quote_da"]');
if (quoteInput && quoteDa) quoteInput.value = quoteDa;
showToast('Inserted generated description' + (teaser || quoteDa ? ' and extras' : '') + ' into form. Click Save changes to apply.', true);
overlay.remove();
return;
}
}
}
var storageKey = 'mvlog_gemini_description_' + (articleId || job);
try { localStorage.setItem(storageKey, JSON.stringify({description: description || '', teaser: teaser || '', quote_da: quoteDa || ''})); } catch(e){}
var href = 'new.php?tab=edit';
if (articleId) href += '&id=' + encodeURIComponent(articleId);
if (job) href += '&edit=' + encodeURIComponent(job);
window.location.href = href;
});
}
document.addEventListener('click', function(e){
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 || '';
var articleId = el.dataset.id || '';
if (!job && !articleId) return;
el.classList.add('disabled');
var oldText = el.textContent;
el.textContent = 'Generating...';
fetch('lib/generate_data_sync.php', {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type':'application/x-www-form-urlencoded'},
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)) || '';
var teaser = data.teaser || '';
var rawFirst = (Array.isArray(data.raw_json) && data.raw_json.length) ? data.raw_json[0] : data.raw_json;
var quoteDa = data.quote_da || (rawFirst && rawFirst.quote_da) || '';
showModal(job, articleId, desc, teaser, quoteDa, data.raw_json || null);
} else {
var msg = (data && data.message) ? data.message : 'No response';
if (data && data.log) {
try {
var log = String(data.log);
var lines = log.split('\n').map(function(l){ return l.trim(); }).filter(function(l){ return l.length>0; });
var keywords = ['quota','rate limit','rate-limit','429','503','overloaded','high demand','busy','unavailable','error','failed','retry'];
var found = lines.find(function(l){ var ll=l.toLowerCase(); return keywords.some(function(k){ return ll.indexOf(k) !== -1; }); });
if (found) msg += ' — ' + found;
else if (lines.length) msg += ' — ' + lines[0].slice(0,200);
} catch(e){}
}
showToast('Error: ' + msg, false, (data && data.log) ? (typeof data.log === 'string' ? data.log : JSON.stringify(data.log)) : null);
}
}).catch(function(err){
showToast('Request failed: ' + err, false);
}).finally(function(){
el.classList.remove('disabled');
el.textContent = oldText;
});
});
attachDescribeButtons();
var list = document.querySelector('.admin-list.video-list');
if (list) new MutationObserver(function(){ attachDescribeButtons(); }).observe(list, {childList:true, subtree:true});
// On edit page load: if a generated description is in localStorage, insert it into the form.
(function(){
var editForm = document.getElementById('edit-form');
if (!editForm) return;
var dirInput = editForm.querySelector('input[name="dir"]');
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_' + (articleId || job);
var raw = localStorage.getItem(key);
if (raw) {
var payload = null;
try { payload = JSON.parse(raw); } catch(e) { payload = {description: raw, teaser: '', quote_da: ''}; }
var desc = (payload && typeof payload === 'object') ? String(payload.description || '') : String(raw || '');
var teaser = (payload && typeof payload === 'object') ? String(payload.teaser || '') : '';
var quoteDa = (payload && typeof payload === 'object') ? String(payload.quote_da || '') : '';
var textarea = editForm.querySelector('textarea[name="description"]');
if (textarea) {
textarea.value = desc;
var teaserInput = editForm.querySelector('input[name="teaser"]');
if (teaserInput && teaser) teaserInput.value = teaser;
var quoteInput = editForm.querySelector('input[name="quote_da"]');
if (quoteInput && quoteDa) quoteInput.value = quoteDa;
showToast('Inserted generated description' + (teaser || quoteDa ? ' and extras' : '') + ' into form. Click Save changes to apply.', true);
}
localStorage.removeItem(key);
}
} catch(e){}
})();
})();
</script>
<script src="js/map_lightbox.js?v=20260531b" defer></script>
</body></html>