1557 lines
84 KiB
PHP
1557 lines
84 KiB
PHP
<?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?v=20260531k"><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><div class="header-actions"><a class="contact-link" href="contact.php" aria-label="Contact" title="Contact"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z"/></svg></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>
|
||
<?php include __DIR__ . '/_footer_admin.php'; ?>
|
||
</body></html>
|