'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 --- ", 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.'); } $scale = 3; $baseW = 1200; $baseH = 630; $imgW = $baseW * $scale; $imgH = $baseH * $scale; $targetW = $baseW; $targetH = $baseH; $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, " ", 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, " ", $cutLongWords); $lineCount = substr_count($wrapped, " ") + 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, " ") + 1; $bestText = $candidate; $bestSize = $size; $bestWidth = $width; $bestLines = $lines; if ($lines <= 2) break; } return [$bestText, $bestSize, $bestWidth, $bestLines]; }; [$titleText, $titleFontSizeBase, $titleWrapWidth, $titleLines] = $fitTitleText($title); $teaserText = $clampWrappedText($teaser, 34, 6, true); $teaserLines = max(1, substr_count($teaserText, " ") + 1); $baseLeft = max(14, (int)round($baseW * 0.046)); $baseTitleY = max(20, (int)round($baseH * 0.062)); $baseTitleLineStep = max(68, (int)round($baseH * 0.043)); $baseTeaserY = max((int)round($baseH * 0.235), $baseTitleY + ($titleLines * $baseTitleLineStep) + max(54, (int)round($baseH * 0.03))); $left = $baseLeft * $scale; $titleY = $baseTitleY * $scale; $titleLineStep = $baseTitleLineStep * $scale; $teaserY = $baseTeaserY * $scale; $shadowOffset = 3 * $scale; $titleLineSpacing = 2 * $scale; $teaserLineSpacing = -18 * $scale; $titlePointSize = $titleFontSizeBase * $scale; $teaserPointSize = 58 * $scale; $shadowFill = 'rgba(0,0,0,0.35)'; $titleFill = '#F3F4F6'; $teaserFill = '#EADFC9'; $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 %s -interline-spacing %d -gravity northwest -annotate +%d+%d %s -fill %s -annotate +%d+%d %s -font %s -pointsize %d -fill %s -interline-spacing %d -gravity northwest -annotate +%d+%d %s -fill %s -annotate +%d+%d %s %s 2>&1', $imgW, $imgH, escapeshellarg($titleFontPath), $titlePointSize, escapeshellarg($shadowFill), $titleLineSpacing, $left + $shadowOffset, $titleY + $shadowOffset, escapeshellarg($titleText), escapeshellarg($titleFill), $left, $titleY, escapeshellarg($titleText), escapeshellarg($teaserFontPath), $teaserPointSize, escapeshellarg($shadowFill), $teaserLineSpacing, $left + $shadowOffset, $teaserY + $shadowOffset, escapeshellarg($teaserText), escapeshellarg($teaserFill), $left, $teaserY, escapeshellarg($teaserText), escapeshellarg($overlayPath) ); file_put_contents($logFile, "Overlay CMD: " . $cmd1 . " ", FILE_APPEND); $out1 = shell_exec($cmd1); file_put_contents($logFile, "Overlay Output: " . $out1 . " ", 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 = [96, 92, 88, 84, 80, 76, 72, 68, 64]; $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 -resize %dx%d -gravity center -extent %dx%d -unsharp 0x0.75+0.75+0.008 -strip -interlace Plane -sampling-factor 4:2:0 -quality %d %s 2>&1', escapeshellarg($imagePath), $imgW, $imgH, $imgW, $imgH, escapeshellarg($overlayPath), $targetW, $targetH, $targetW, $targetH, $quality, escapeshellarg($attemptPath) ); file_put_contents($logFile, "Composite CMD (q=$quality): " . $cmd2 . " ", FILE_APPEND); $out2 = shell_exec($cmd2); file_put_contents($logFile, "Composite Output (q=$quality): " . $out2 . " ", 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 ", FILE_APPEND); } if (!@rename($finalPath, $imagePath)) { @unlink($overlayPath); @unlink($finalPath); throw new RuntimeException('Failed to replace thumbnail image.'); } @unlink($overlayPath); @unlink($outputPath); } function ensure_input_dir_permissions($dir){ if (!is_dir($dir) || is_link($dir)) return; @chmod($dir, 02775); $items = @scandir($dir); if (!is_array($items)) return; foreach ($items as $name) { if ($name === '.' || $name === '..') continue; $path = $dir . '/' . $name; if (is_link($path)) continue; if (is_dir($path)) @chmod($path, 02775); elseif (is_file($path)) @chmod($path, 0664); } } function input_dirs($base){ $dirs = glob($base.'/*', GLOB_ONLYDIR) ?: []; foreach ($dirs as $dir) ensure_input_dir_permissions($dir); return $dirs; } 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.'); ensure_input_dir_permissions($path); 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 mvlog_announcement_sent(string $mvlogRoot, string $articleId, string $job): bool { $cacheFile = rtrim($mvlogRoot, '/') . '/cache/push_notifications.json'; if (!is_file($cacheFile)) return false; $cache = json_decode((string)@file_get_contents($cacheFile), true); if (!is_array($cache) || !is_array($cache['shown'] ?? null)) return false; $shown = $cache['shown']; $job = basename(trim($job)); if (article_id_is_valid($articleId) && isset($shown[$articleId])) return true; return $job !== '' && isset($shown[$job]); } 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 location_language_for_country($countryCode){ $map = ['dk'=>'da','de'=>'de','at'=>'de','ch'=>'de','hr'=>'hr','se'=>'sv','no'=>'no','fi'=>'fi','pl'=>'pl','cz'=>'cs','sk'=>'sk','fr'=>'fr','it'=>'it','es'=>'es','pt'=>'pt','nl'=>'nl','be'=>'nl','ba'=>'bs','rs'=>'sr','si'=>'sl','hu'=>'hu','gb'=>'en','uk'=>'en','ie'=>'en']; $countryCode = strtolower(trim((string)$countryCode)); return $map[$countryCode] ?? 'en'; } function nominatim_reverse_json($lat, $lon, $language){ $url = 'https://nominatim.openstreetmap.org/reverse?' . http_build_query(['format'=>'jsonv2','lat'=>sprintf('%.8F', $lat),'lon'=>sprintf('%.8F', $lon),'addressdetails'=>'1','accept-language'=>$language]); $context = stream_context_create(['http'=>['method'=>'GET','header'=>'User-Agent: mvlog-admin/1.0 (reverse geocoding media metadata)\r\n','timeout'=>12]]); $json = @file_get_contents($url, false, $context); $data = $json !== false ? json_decode((string)$json, true) : null; return is_array($data) ? $data : []; } function reverse_geocode_place($lat, $lon){ $cacheFile = __DIR__ . '/cache/reverse-geocode.json'; $cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : []; if (!is_array($cache)) $cache = []; $key = sprintf('%.5F,%.5F', $lat, $lon); if (isset($cache[$key]) && is_string($cache[$key])) return $cache[$key]; $first = nominatim_reverse_json($lat, $lon, 'en'); $countryCode = (string)($first['address']['country_code'] ?? ''); $language = location_language_for_country($countryCode); $data = ($language === 'en') ? $first : nominatim_reverse_json($lat, $lon, $language); $address = is_array($data['address'] ?? null) ? $data['address'] : []; $city = (string)($address['city'] ?? $address['town'] ?? $address['village'] ?? $address['island'] ?? $address['suburb'] ?? $address['hamlet'] ?? $address['municipality'] ?? ''); $country = (string)($address['country'] ?? ''); $place = trim(implode(', ', array_filter([$city, $country], fn($v)=>trim((string)$v) !== ''))); if ($place === '') $place = trim($country); if ($place !== '') { $cache[$key] = $place; @file_put_contents($cacheFile, json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n", LOCK_EX); @chmod($cacheFile, 0664); } return $place; } function media_gps_coordinates($path){ $exiftool = trim((string)shell_exec('command -v exiftool 2>/dev/null')); if ($exiftool === '' || !is_file($path)) return null; $cmd = escapeshellarg($exiftool) . ' -json -n -GPSLatitude -GPSLongitude -GPSCoordinates ' . escapeshellarg($path) . ' 2>/dev/null'; $rows = json_decode((string)shell_exec($cmd), true); $row = is_array($rows) && isset($rows[0]) && is_array($rows[0]) ? $rows[0] : []; $lat = $row['GPSLatitude'] ?? null; $lon = $row['GPSLongitude'] ?? null; if ((!is_numeric($lat) || !is_numeric($lon)) && !empty($row['GPSCoordinates']) && preg_match('/(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/', (string)$row['GPSCoordinates'], $m)) { $lat = $m[1]; $lon = $m[2]; } if (!is_numeric($lat) || !is_numeric($lon)) return null; $lat = (float)$lat; $lon = (float)$lon; if (abs($lat) < 0.000001 && abs($lon) < 0.000001) return null; return [$lat, $lon]; } function infer_location_from_first_media($dir){ foreach (visual_order_files($dir) as $name) { $gps = media_gps_coordinates($dir . '/' . $name); if (!$gps) continue; $place = reverse_geocode_place($gps[0], $gps[1]); if ($place !== '') return $place; } return ''; } function display_month_year_from_media_datetime($value){ $value = preg_replace('/[^0-9]/', '', (string)$value); if (strlen($value) < 8) return ''; $year = (int)substr($value, 0, 4); $month = (int)substr($value, 4, 2); $day = (int)substr($value, 6, 2); if (!checkdate($month, $day, $year)) return ''; return date('F Y', mktime(0, 0, 0, $month, $day, $year)); } function infer_date_from_first_media($dir){ foreach (visual_order_files($dir) as $name) { $date = media_file_date($dir . '/' . $name); if ($date === null) continue; $display = display_month_year_from_media_datetime($date); if ($display !== '') return $display; } return ''; } function write_data($dir, $post){ $title = trim($post['title'] ?? ''); $teaser = trim($post['teaser'] ?? ''); $quote_da = trim($post['quote_da'] ?? ''); $place = trim($post['place'] ?? ''); if ($place === '') $place = infer_location_from_first_media($dir); $date = trim($post['date'] ?? ''); if ($date === '') $date = infer_date_from_first_media($dir); $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){ ensure_input_dir_permissions($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-permalink', '.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_permalink($dir){ return is_file($dir . '/' . PERMALINK_FILE); } function input_dir_can_permalink($dir, $config){ $state = input_dir_state($dir); $output = basename((string)($state['output'] ?? '')); return $output !== '' && is_file($config['videos_dir'] . '/' . $output) && !input_dir_preview($dir); } function set_input_dir_permalink($dir, $permalink){ $path = $dir . '/' . PERMALINK_FILE; if ($permalink) { file_put_contents($path, "permalink\n"); @chmod($path, 0664); } elseif (is_file($path)) unlink($path); } 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'] ?? '', 'teaser'=>$fallback['teaser'] ?? '', 'date'=>$fallback['date'] ?? '', 'sort_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)) { // Admin content fields must stay editable/source-of-truth from data.txt. // Only use rendered/index metadata for the displayed date/location under the title. foreach (['date','sort_date','location'] 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"); @chmod($target, 0664); $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); regenerate_input_dir_maps($dir, $config); if (!empty($_POST['ajax'])) { ajax_json(['ok'=>true, 'message'=>"Created movmaker input directory: in-dir/$slug", 'dir'=>$slug, 'id'=>$articleId, 'tab'=>'edit']); } header('Location: admin.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); if (input_dir_visible($dir) || input_dir_permalink($dir)) throw new RuntimeException('Disable Show and Permalink before changing render.'); $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, 'can_permalink' => input_dir_can_permalink($dir, $config), 'dir' => basename($dir), 'id' => $articleId, 'message' => $message, ]); } header('Location: admin.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); if (input_dir_visible($dir) || input_dir_permalink($dir)) throw new RuntimeException('Disable Show and Permalink before changing preview.'); $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, 'can_permalink' => input_dir_can_permalink($dir, $config), 'dir' => basename($dir), 'id' => $articleId, 'message' => $message, ]); } header('Location: admin.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']); $sendImmediately = !empty($_POST['send_immediately']); $articleAlreadyAnnounced = mvlog_announcement_sent(__DIR__, $articleId, basename($dir)); if ($articleAlreadyAnnounced) $sendImmediately = false; $state = input_dir_state($dir); $output = basename((string)($state['output'] ?? '')); $canShow = $output !== '' && is_file($config['videos_dir'].'/'.$output) && !input_dir_preview($dir); $canPermalink = input_dir_can_permalink($dir, $config); if ($visibleNow && !$canShow) throw new RuntimeException('Only finished full-quality videos can be shown.'); if ($visibleNow && !$canPermalink) throw new RuntimeException('Only finished full-quality videos can use permalinks.'); set_input_dir_visible($dir, $visibleNow && $canShow); if ($visibleNow && $canShow && $canPermalink) set_input_dir_permalink($dir, true); // 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', $sendImmediately); } else { error_log("[mvlog] send_push helper missing, cannot send notification for " . basename($dir) . " ", 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, 'permalink' => input_dir_permalink($dir), 'can_show' => $canShow, 'can_permalink' => $canPermalink, 'announced' => mvlog_announcement_sent(__DIR__, $articleId, basename($dir)), 'dir' => basename($dir), 'id' => $articleId, 'message' => $message, ]); } header('Location: admin.php?tab=edit&msg=' . rawurlencode($message)); exit; } elseif ($action === 'set_permalink') { $dir = resolve_input_dir_from_request($config['uploads_dir'], $_POST['id'] ?? '', $_POST['dir'] ?? '', $articleIndex); $articleId = ensure_state_article_id($dir); $permalinkNow = !empty($_POST['permalink']); $canPermalink = input_dir_can_permalink($dir, $config); if ($permalinkNow && !$canPermalink) throw new RuntimeException('Only finished full-quality videos can use permalinks.'); set_input_dir_permalink($dir, $permalinkNow && $canPermalink); if (!$permalinkNow) set_input_dir_visible($dir, false); $currentPermalink = input_dir_permalink($dir); $state = input_dir_state($dir); $output = basename((string)($state['output'] ?? '')); $canShow = $output !== '' && is_file($config['videos_dir'].'/'.$output) && !input_dir_preview($dir); $currentVisible = input_dir_visible($dir) && $canShow; $message = ($permalinkNow ? 'Permalink enabled for: in-dir/' : 'Permalink disabled for: in-dir/') . basename($dir); if (!empty($_POST['ajax'])) { ajax_json([ 'ok' => true, 'permalink' => $currentPermalink, 'visible' => $currentVisible, 'can_show' => $canShow, 'can_permalink' => $canPermalink, 'dir' => basename($dir), 'id' => $articleId, 'message' => $message, ]); } header('Location: admin.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); if (input_dir_visible($dir) || input_dir_permalink($dir)) throw new RuntimeException('Disable Show and Permalink before changing map visibility.'); $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: admin.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: admin.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: admin.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: admin.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: admin.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: admin.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: admin.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']); $filterPermalink = !empty($_GET['f_permalink']); $statusFilterActive = $filterPreview || $filterRender || $filterShown || $filterPermalink; $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, $filterPermalink) { $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); $displayOutput = ($preview && $hasPreviewVideo) ? $previewOutput : ($hasFullVideo ? $fullOutput : ($hasPreviewVideo ? $previewOutput : '')); $hasVideo = $displayOutput !== ''; $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 ($filterPermalink && input_dir_permalink($dir)) $statusMatch = true; if (!$statusMatch) return false; } if ($keywordValue === '') return true; $meta = $hasVideo ? cached_video_metadata($displayOutput, $data) : ['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'] ?? '')]; $title = (string)(($meta['title'] ?? '') !== '' ? $meta['title'] : basename($dir)); $teaser = (string)($meta['teaser'] ?? ''); $description = (string)($meta['description'] ?? ''); $quoteDa = (string)($meta['quote_da'] ?? ''); $date = (string)($meta['date'] ?? ''); $location = (string)($meta['location'] ?? ''); 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); ?> MVLog Admin

Videos without input dir

No orphan videos.


MB

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' : ''); ?>
Map
No video yet
Map

Thumb source:

RenderingEditEdit

Edit input dir

in-dir/

This input directory is . Editing is disabled until the job completes.

Edit input dir

in-dir/

Files and captions

No caption for audio files

Add new movie input