'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 apply_thumbnail_overlay($imagePath, $title, $teaser) {
$logFile = __DIR__ . '/logs/image_magick.log';
file_put_contents($logFile, "--- New Thumbnail Job ---\n", FILE_APPEND);
$fontPath = __DIR__ . '/assets/fonts/IBMPlexSans-SemiBold.ttf';
$titleFontPath = is_file(__DIR__ . '/assets/fonts/BebasNeue-Regular.ttf')
? __DIR__ . '/assets/fonts/BebasNeue-Regular.ttf'
: $fontPath;
$teaserFontPath = is_file(__DIR__ . '/assets/fonts/NotoSans-Bold.ttf')
? __DIR__ . '/assets/fonts/NotoSans-Bold.ttf'
: $fontPath;
if (!is_file($fontPath)) {
throw new RuntimeException('Thumbnail font file is missing.');
}
$imgW = 1200;
$imgH = 630;
$normalize = static function ($text): string {
$text = trim((string)$text);
if ($text === '') return '';
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
return trim($text);
};
$wrapText = static function ($text, int $width) use ($normalize): string {
$text = $normalize($text);
return $text === '' ? '' : wordwrap($text, $width, "\n", false);
};
$clampWrappedText = static function ($text, int $width, int $maxLines, bool $cutLongWords = false) use ($normalize): string {
$text = $normalize($text);
if ($text === '') return '';
$words = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [];
if (!$words) return '';
$candidate = implode(' ', $words);
while (true) {
$wrapped = wordwrap($candidate, $width, "\n", $cutLongWords);
$lineCount = substr_count($wrapped, "\n") + 1;
if ($lineCount <= $maxLines || count($words) <= 1) return $wrapped;
array_pop($words);
$candidate = implode(' ', $words) . '…';
}
};
$fitTitleText = static function ($text) use ($wrapText): array {
$bestText = '';
$bestSize = 95;
$bestWidth = 28;
$bestLines = 1;
for ($size = 84; $size >= 54; $size -= 4) {
$width = max(16, min(46, (int)round(23 + (84 - $size) / 2.7)));
$candidate = $wrapText($text, $width);
$lines = substr_count($candidate, "\n") + 1;
$bestText = $candidate;
$bestSize = $size;
$bestWidth = $width;
$bestLines = $lines;
if ($lines <= 2) break;
}
return [$bestText, $bestSize, $bestWidth, $bestLines];
};
[$titleText, $titleFontSize, $titleWrapWidth, $titleLines] = $fitTitleText($title);
$teaserText = $clampWrappedText($teaser, 34, 6, true);
$teaserLines = max(1, substr_count($teaserText, "\n") + 1);
// Move the whole block a bit left and higher while keeping left-aligned text.
$left = max(14, (int)round($imgW * 0.046));
$titleY = max(20, (int)round($imgH * 0.062));
$titleLineStep = max(68, (int)round($imgH * 0.043));
$teaserY = max((int)round($imgH * 0.235), $titleY + ($titleLines * $titleLineStep) + max(54, (int)round($imgH * 0.03)));
$overlayBase = tempnam(sys_get_temp_dir(), 'thumb_overlay_');
$outputPath = dirname($imagePath) . '/' . basename($imagePath) . '.tmp.' . uniqid('', true) . '.jpg';
if ($overlayBase === false) {
throw new RuntimeException('Failed to create temporary thumbnail overlay file.');
}
$overlayPath = $overlayBase . '.png';
@unlink($overlayBase);
$cmd1 = sprintf(
'/usr/bin/convert -size %dx%d xc:none -font %s -pointsize %d -fill "#F3F4F6" -stroke black -strokewidth 2 -interline-spacing 2 -gravity northwest -annotate +%d+%d %s -font %s -pointsize 58 -fill "#EADFC9" -stroke black -strokewidth 2 -interline-spacing -18 -gravity northwest -annotate +%d+%d %s %s 2>&1',
$imgW,
$imgH,
escapeshellarg($titleFontPath),
$titleFontSize,
$left,
$titleY,
escapeshellarg($titleText),
escapeshellarg($teaserFontPath),
$left,
$teaserY,
escapeshellarg($teaserText),
escapeshellarg($overlayPath)
);
file_put_contents($logFile, "Overlay CMD: " . $cmd1 . "\n", FILE_APPEND);
$out1 = shell_exec($cmd1);
file_put_contents($logFile, "Overlay Output: " . $out1 . "\n", FILE_APPEND);
if (!is_file($overlayPath) || filesize($overlayPath) < 100) {
@unlink($overlayPath);
@unlink($outputPath);
throw new RuntimeException('Failed to render thumbnail overlay: ' . trim((string)$out1));
}
$maxBytes = 600 * 1024;
$qualities = [88, 84, 80, 76, 72, 68, 64, 60, 56, 52, 48];
$finalPath = '';
$finalSize = 0;
$finalOut = '';
foreach ($qualities as $quality) {
$attemptPath = dirname($imagePath) . '/' . basename($imagePath) . '.tmp.' . uniqid('', true) . '.jpg';
$cmd2 = sprintf(
'/usr/bin/convert -filter Lanczos -define filter:blur=0.85 %s -resize %dx%d^ -gravity center -extent %dx%d -modulate 70,82 %s -composite -strip -interlace Plane -sampling-factor 4:2:0 -quality %d %s 2>&1',
escapeshellarg($imagePath),
$imgW,
$imgH,
$imgW,
$imgH,
escapeshellarg($overlayPath),
$quality,
escapeshellarg($attemptPath)
);
file_put_contents($logFile, "Composite CMD (q=$quality): " . $cmd2 . "\n", FILE_APPEND);
$out2 = shell_exec($cmd2);
file_put_contents($logFile, "Composite Output (q=$quality): " . $out2 . "\n", FILE_APPEND);
if (!is_file($attemptPath) || filesize($attemptPath) < 100) {
@unlink($attemptPath);
$finalOut = trim((string)$out2);
continue;
}
$finalPath = $attemptPath;
$finalSize = filesize($attemptPath);
$finalOut = trim((string)$out2);
if ($finalSize <= $maxBytes) break;
}
if ($finalPath === '') {
@unlink($overlayPath);
@unlink($outputPath);
throw new RuntimeException('Failed to compose thumbnail overlay: ' . $finalOut);
}
if ($finalSize > $maxBytes) {
file_put_contents($logFile, "Thumbnail still above size limit: " . $finalSize . " bytes\n", FILE_APPEND);
}
if (!@rename($finalPath, $imagePath)) {
@unlink($overlayPath);
@unlink($finalPath);
throw new RuntimeException('Failed to replace thumbnail image.');
}
@unlink($overlayPath);
@unlink($outputPath);
}
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 public_url_path($path){
$path = str_replace('\\', '/', trim((string)$path));
if ($path === '') return '';
$parts = array_values(array_filter(explode('/', $path), static fn($p) => $p !== ''));
return implode('/', array_map('rawurlencode', $parts));
}
function video_thumb_public_url($config, $videoName){
$videoName = basename((string)$videoName);
if ($videoName === '') return '';
$info = video_thumb_source_info($config, $videoName);
$sourcePath = (string)($info['source_path'] ?? '');
if ($sourcePath !== '') {
$abs = str_starts_with($sourcePath, '/') ? $sourcePath : __DIR__ . '/' . ltrim($sourcePath, '/');
if (is_file($abs)) return public_url_path($sourcePath) . '?v=' . filemtime($abs);
}
$sourceFile = basename((string)($info['source_file'] ?? ''));
$inputDir = basename((string)($info['input_dir'] ?? ''));
if ($sourceFile !== '' && $inputDir !== '') {
$rel = 'in-dir/' . $inputDir . '/' . $sourceFile;
$abs = __DIR__ . '/' . $rel;
if (is_file($abs)) return public_url_path($rel) . '?v=' . filemtime($abs);
}
return '';
}
function video_thumb_source_info($config, $videoName){
$videoName = basename((string)$videoName);
if ($videoName === '') return [];
$base = pathinfo($videoName, PATHINFO_FILENAME);
$file = $config['videos_dir'] . '/' . $base . '_thumb.json';
if (!is_file($file)) return [];
$data = json_decode((string)file_get_contents($file), true);
return is_array($data) ? $data : [];
}
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){
$logFile = '/var/log/mvlog_map.log';
$python = trim((string)shell_exec('command -v python3 2>/dev/null'));
$script = __DIR__ . '/bin/gpsmap.py';
if ($python === '' || !is_file($script)) {
file_put_contents($logFile, '[mvlog] map regenerate skipped for ' . basename((string)$dir) . ' (python=' . ($python !== '' ? 'yes' : 'no') . ', script=' . (is_file($script) ? 'yes' : 'no') . ")\n", FILE_APPEND);
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) . '_map.png';
}
// Fallback target if no output filename is known yet.
if (!$targets) {
$targets[] = $config['videos_dir'] . '/' . basename((string)$dir) . '_map.png';
}
$targets = array_values(array_unique($targets));
file_put_contents($logFile, '[mvlog] map regenerate requested for ' . basename((string)$dir) . ' targets=' . implode(', ', array_map('basename', $targets)) . "\n", FILE_APPEND);
foreach ($targets as $outPng) {
file_put_contents($logFile, '[mvlog] map regenerate start for ' . basename((string)$dir) . ' target=' . basename((string)$outPng) . "\n", FILE_APPEND);
$cmd = escapeshellarg($python) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($dir) . ' ' . escapeshellarg($outPng) . ' 2>&1';
$output = (string)shell_exec($cmd);
if (is_file($outPng)) {
@chmod($outPng, 0664);
clearstatcache(true, $outPng);
$size = @filesize($outPng);
file_put_contents($logFile, '[mvlog] map regenerate done for ' . basename((string)$dir) . ' target=' . basename((string)$outPng) . ' bytes=' . ($size !== false ? $size : 'unknown') . "\n", FILE_APPEND);
} else {
file_put_contents($logFile, '[mvlog] map regenerate failed for ' . basename((string)$dir) . ' target=' . basename((string)$outPng) . ' output=' . trim($output) . "\n", FILE_APPEND);
}
}
}
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 = '
';
for ($i = 1; $i <= $pages; $i++) {
$class = $i === $page ? 'active' : '';
$html .= '
'.$i.'';
}
return $html . '
';
}
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') {
$logFile = __DIR__ . '/logs/image_magick.log';
file_put_contents($logFile, "Entering set_video_thumb action...\n", FILE_APPEND); // LOG 1
$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);
$meta = read_data($dir); // Need this for title and teaser
$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;
$thumbName = $articleId . '_og.jpg';
$target = $config['videos_dir'] . '/' . $thumbName;
if (is_file($target)) @unlink($target);
if (!@copy($src, $target)) {
file_put_contents($logFile, "Failed to copy thumbnail.\n", FILE_APPEND); // LOG 2
throw new RuntimeException('Failed to write thumbnail.');
}
@chmod($target, 0664);
file_put_contents($logFile, "Thumbnail copied, now applying overlay...\n", FILE_APPEND); // LOG 3
// Apply text overlay after smart resizing/cropping to 1200x630
apply_thumbnail_overlay($target, (string)($meta['title'] ?? ''), (string)($meta['teaser'] ?? ''));
$written = [];
foreach ($outputs as $out) {
$base = pathinfo($out, PATHINFO_FILENAME);
// Remove any old thumbnail files in thumbs_dir
foreach (['jpg','jpeg','png','webp','gif'] as $oldExt) {
$old = $config['thumbs_dir'] . '/' . $base . '.' . $oldExt;
if (is_file($old)) @unlink($old);
}
$thumbMeta = [
'article_id' => $articleId,
'input_dir' => basename($dir),
'source_file' => $file,
'source_path' => 'in-dir/' . basename($dir) . '/' . $file,
'thumb_file' => $thumbName,
'thumb_path' => 'out-dir/' . $thumbName,
'created_at' => gmdate('c'),
];
$thumbMetaPath = $config['videos_dir'] . '/' . $base . '_thumb.json';
if (file_put_contents($thumbMetaPath, json_encode($thumbMeta, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", LOCK_EX) === false) {
throw new RuntimeException('Failed to write thumbnail metadata.');
}
@chmod($thumbMetaPath, 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.');
file_put_contents('/var/log/mvlog_map.log', '[mvlog] save changes pressed for ' . basename($dir) . ' id=' . $articleId . "\n", FILE_APPEND);
try {
write_data($dir, $_POST);
save_uploads('media', $dir, $allowedMedia);
save_uploads('audio', $dir, $allowedAudio);
} finally {
// Always regenerate map image(s) whenever Save changes is pressed.
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);
?>
MVL Admin
=h($err)?>
Videos without input dir
No orphan videos.
=page_links('videos',$videoPage,$videoPages)?>
No input directories yet.
(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' : ''); ?>
)?>)
No video yetThumb source: =h((string)$thumbMeta['source_file'])?>
=h($meta['title'] ?: ($info['title'] ?? basename($d)))?>
=h($meta['date'])?>=h($meta['location'])?>No videoPreview video
=h((string)$meta['teaser'])?>
=nl2br(h($meta['description']), false)?>
“=h(trim((string)$meta['quote_da'], "\"“”"))?>”
=h(basename($d))?> · =h($info['file_count'] ?? count(list_files($d)))?> files
=page_links('edit',$editPage,$editPages)?>
Edit input dir
in-dir/=h(basename($editDir))?>
This input directory is =h($editStatus)?>. Editing is disabled until the job completes.