movmaker-webui/new.php

1213 lines
No EOL
68 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
require __DIR__ . '/auth.php';
mvlog_require_login();
$config = require __DIR__ . "/config.php";
foreach (["videos_dir", "thumbs_dir", "uploads_dir"] as $d) if (!is_dir($config[$d])) mkdir($config[$d], 0775, true);
if (!is_dir(__DIR__ . "/cache")) mkdir(__DIR__ . "/cache", 0775, true);
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, "UTF-8"); }
function ajax_json($data){ header('Content-Type: application/json; charset=UTF-8'); echo json_encode($data, JSON_UNESCAPED_UNICODE); exit; }
function ascii_safe($s){
$s = strtr((string)$s, [
'æ'=>'ae','Æ'=>'Ae','ø'=>'o','Ø'=>'O','å'=>'aa','Å'=>'Aa',
'ä'=>'ae','Ä'=>'Ae','ö'=>'oe','Ö'=>'Oe','ü'=>'ue','Ü'=>'Ue','ß'=>'ss','ẞ'=>'SS',
'č'=>'c','Č'=>'C','ć'=>'c','Ć'=>'C','ž'=>'z','Ž'=>'Z','š'=>'s','Š'=>'S','đ'=>'d','Đ'=>'D',
]);
if (function_exists('iconv')) {
$x = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
if ($x !== false) $s = $x;
}
return preg_replace('/[^\x20-\x7E]/', '', $s) ?? '';
}
function slugify($s){ $s = ascii_safe($s); $s = strtolower(trim($s)); $s = preg_replace('/[^a-z0-9]+/', '-', $s); return trim($s, '-') ?: 'movie'; }
function rrmdir($dir){ if(!is_dir($dir)) return; foreach(scandir($dir) as $f){ if($f==='.'||$f==='..') continue; $p="$dir/$f"; is_dir($p)?rrmdir($p):unlink($p);} rmdir($dir); }
function input_dirs($base){ return glob($base.'/*', GLOB_ONLYDIR) ?: []; }
function safe_input_dir($base, $name){ $name = basename((string)$name); $path = realpath($base . '/' . $name); $root = realpath($base); if (!$path || !$root || !str_starts_with($path, $root . DIRECTORY_SEPARATOR) || !is_dir($path)) throw new RuntimeException('Invalid input directory.'); return $path; }
function read_data($dir){
$data = ['title'=>'','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, ['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'] ?? '');
$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 ($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 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'}:
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),
'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) {
$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['preview'] = (bool)$preview;
$state['preview_updated_at'] = gmdate('c');
file_put_contents($stateFile, json_encode($state, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "
");
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_state($dir){
$stateFile = $dir . '/.movmaker-state.json';
$state = is_file($stateFile) ? json_decode((string)file_get_contents($stateFile), true) : [];
return is_array($state) ? $state : [];
}
function active_worker_jobs($config){
$jobs = [];
foreach (input_dirs($config['uploads_dir']) as $dir) {
$name = basename($dir);
$state = input_dir_state($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,'status'=>$status ?: ($locked ? 'processing' : 'unknown'),'started_at'=>$started];
}
}
return $jobs;
}
function format_job_age($startedAt){
$ts = strtotime((string)$startedAt);
if (!$ts) return '';
$seconds = max(0, time() - $ts);
$h = intdiv($seconds, 3600);
$m = intdiv($seconds % 3600, 60);
$s = $seconds % 60;
if ($h > 0) return $h . 'h ' . $m . 'm';
if ($m > 0) return $m . 'm ' . $s . 's';
return $s . 's';
}
function input_dir_output($dir){
$state = input_dir_state($dir);
return basename((string)($state['output'] ?? ''));
}
function regenerate_input_dir_maps($dir, $config){
$python = trim((string)shell_exec('command -v python3 2>/dev/null'));
$script = __DIR__ . '/bin/gpsmap.py';
if ($python === '' || !is_file($script)) return;
$state = input_dir_state($dir);
$targets = [];
foreach (['output', 'preview_output'] as $key) {
$name = basename((string)($state[$key] ?? ''));
if ($name === '') continue;
$targets[] = $config['videos_dir'] . '/' . pathinfo($name, PATHINFO_FILENAME) . '.png';
}
// Fallback target if no output filename is known yet.
if (!$targets) {
$targets[] = $config['videos_dir'] . '/' . basename((string)$dir) . '.png';
}
$targets = array_values(array_unique($targets));
foreach ($targets as $outPng) {
$cmd = escapeshellarg($python) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($dir) . ' ' . escapeshellarg($outPng) . ' 2>&1';
$output = (string)shell_exec($cmd);
if (is_file($outPng)) {
@chmod($outPng, 0664);
} else {
error_log('[mvlog] map regenerate failed for '.basename((string)$dir).' target='.basename((string)$outPng).' output='.trim($output)."\n", 3, '/var/log/mvlog_map.log');
}
}
}
function cached_video_metadata($output, $fallback){
$meta = ['title'=>$fallback['title'] ?? '', 'date'=>$fallback['date'] ?? '', 'location'=>$fallback['place'] ?? '', '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','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);
}
$html = '<div class="pages">';
for ($i = 1; $i <= $pages; $i++) {
$class = $i === $page ? 'active' : '';
$html .= '<a class="'.$class.'" href="new.php?tab='.rawurlencode($tab).'&page='.$i.$extra.'">'.$i.'</a>';
}
return $html . '</div>';
}
function save_uploads($field, $dest, $allowed){
if (empty($_FILES[$field])) return [];
$files = $_FILES[$field]; $saved = [];
$count = is_array($files['name']) ? count($files['name']) : 0;
for ($i=0; $i<$count; $i++) {
if ($files['error'][$i] === UPLOAD_ERR_NO_FILE) continue;
if ($files['error'][$i] !== UPLOAD_ERR_OK) throw new RuntimeException("Upload failed: ".$files['name'][$i]);
$ext = strtolower(pathinfo($files['name'][$i], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed, true)) throw new RuntimeException("File type not allowed: ".$files['name'][$i]);
$base = preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($files['name'][$i]));
$target = $dest . '/' . sprintf('%03d_', count(glob($dest.'/*') ?: [])+count($saved)+1) . $base;
if (!move_uploaded_file($files['tmp_name'][$i], $target)) throw new RuntimeException("Cannot save upload");
$saved[] = basename($target);
}
return $saved;
}
$msg = $_GET['msg'] ?? null; $err = null;
$allowedMedia = ['jpg','jpeg','png','webp','gif','mp4','mov','m4v','avi','mkv','webm'];
$allowedAudio = ['mp3','wav','m4a','aac','ogg','flac'];
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);
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, 'tab'=>'edit']);
}
header('Location: new.php?tab=edit');
exit;
} elseif ($action === 'set_enabled') {
$dir = safe_input_dir($config['uploads_dir'], $_POST['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),
'message' => $message,
]);
}
header('Location: new.php?tab=edit&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'set_preview') {
$dir = safe_input_dir($config['uploads_dir'], $_POST['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),
'message' => $message,
]);
}
header('Location: new.php?tab=edit&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'set_visible') {
$dir = safe_input_dir($config['uploads_dir'], $_POST['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(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),
'message' => $message,
]);
}
header('Location: new.php?tab=edit&msg=' . rawurlencode($message));
exit;
} elseif ($action === 'update') {
$dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? '');
if (input_dir_running($dir)) throw new RuntimeException('This input directory is being rendered. Editing is disabled until the job completes.');
write_data($dir, $_POST);
save_uploads('media', $dir, $allowedMedia);
save_uploads('audio', $dir, $allowedAudio);
// Always regenerate map image(s) on Save changes.
regenerate_input_dir_maps($dir, $config);
if (!empty($_POST['ajax'])) ajax_json(['ok'=>true, 'message'=>'Updated input directory: in-dir/' . basename($dir), 'dir'=>basename($dir)]);
header('Location: new.php?tab=edit');
exit;
} elseif ($action === 'delete_file') {
$dir = safe_input_dir($config['uploads_dir'], $_POST['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 === '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 = safe_input_dir($config['uploads_dir'], $_POST['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']);
$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;
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 !== '') {
$dirs = array_values(array_filter($dirs, function($dir) use ($keywordType, $keywordValue, $config) {
$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 : ''));
$meta = cached_video_metadata($displayOutput, $data);
$title = (string)($meta['title'] ?: ($data['title'] ?: basename($dir)));
$description = (string)($meta['description'] ?? ($data['description'] ?? ''));
$date = (string)($meta['date'] ?? ($data['date'] ?? ''));
$location = (string)($meta['location'] ?? ($data['place'] ?? ''));
if ($keywordType === 'date') return stripos($date, $keywordValue) !== false;
if ($keywordType === 'location') return stripos($location, $keywordValue) !== false;
return stripos($title, $keywordValue) !== false || stripos($description, $keywordValue) !== false;
}));
}
$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'] ?? '';
$editDir = null; $editStatus = ''; $editRunning = false; $editData = ['title'=>'','date'=>'','place'=>'','description'=>'','captions'=>[],'video_audio'=>[]]; $editFiles = [];
if ($editName !== '') { try { $editDir = safe_input_dir($config['uploads_dir'], $editName); $editStatus = input_dir_status($editDir); $editRunning = input_dir_running($editDir); if (!$editRunning) { $editData = read_data($editDir); $editFiles = media_sort_files($editDir, list_files($editDir)); } } catch (Throwable $e) { $err = $e->getMessage(); } }
$runningJobs = active_worker_jobs($config);
?>
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Admin - <?=h($config['site_name'])?></title><link rel="icon" type="image/png" href="assets/img/moto_travel.png"><link rel="stylesheet" href="style.css"><style>.admin-search-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:0 0 .6rem}.admin-search-wrap{position:relative;min-width:0}.admin-search-wrap input{margin:0;padding-right:2.15rem}.admin-search-clear{position:absolute;right:.45rem;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;border-radius:50%;background:#C46A3A;color:#111315;text-decoration:none;font-size:1rem;line-height:1;font-weight:700;box-shadow:0 1px 4px #0007;transition:opacity .15s ease,transform .15s ease}.admin-search-clear:hover{background:#d97b48;color:#111315;transform:translateY(-50%) scale(1.05)}.admin-search-clear.is-empty{opacity:.38;pointer-events:none}.admin-search-submit{display:inline-grid;place-items:center;width:2.35rem;height:2.35rem;padding:0;border-radius:.5rem;background:#C46A3A;color:#111315}.admin-search-submit .icon{font-size:1.02rem;line-height:1;transform:translateY(.01em)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}</style></head><body>
<header class="site-header admin-header"><div class="brand-wrap"><a class="header-logo" href="index.php" aria-label="MVLog home"><img src="assets/img/moto_travel.png" alt=""></a><a class="brand" href="index.php"><h1>MVLog <span class="admin-word">Admin</span></h1><p>Bubulescu.Org</p></a></div></header>
<main>
<?php if($err): ?><div class="err"><?=h($err)?></div><?php endif; ?>
<?php if($tab==='videos'): ?><section><h2>Videos without input dir</h2><?php if(!$orphanVideos): ?><p>No orphan videos.</p><?php endif; ?><div class="admin-list"><?php foreach($orphanVideosPage as $v): $vn=basename($v); ?><form method="post" class="admin-item" onsubmit="return confirm('Delete this generated video?') js-ajax-form"><div><strong><?=h($vn)?></strong><br><span><?=h(round(filesize($v)/1048576,1))?> MB</span></div><input type="hidden" name="action" value="delete_video"><input type="hidden" name="video" value="<?=h($vn)?>"><button type="submit">Delete video</button></form><?php endforeach; ?></div><?=page_links('videos',$videoPage,$videoPages)?></section><?php endif; ?>
<?php if($tab==='edit' && !$editDir): ?><section><form method="get" class="admin-search-form"><input type="hidden" name="tab" value="edit"><div class="admin-search-wrap"><input name="q" value="<?=h($rawKeyword)?>" placeholder="Search title/description or use date=... / location=..." aria-label="Search input dirs"><a class="admin-search-clear <?=$rawKeyword === '' ? 'is-empty' : ''?>" href="new.php?tab=edit" aria-label="Clear filter" title="Clear filter">×</a></div><button type="submit" class="admin-search-submit" aria-label="Filter"><span class="icon" aria-hidden="true">🔍</span><span class="sr-only">Filter</span></button></form><?php if(!$dirs): ?><p>No input directories yet.</p><?php endif; ?><div class="admin-list video-list"><?php foreach($dirsPage as $d): $runStatus=input_dir_status($d); $running=input_dir_running($d); $enabled=input_dir_enabled($d); $preview=input_dir_preview($d); $state=input_dir_state($d); $fullOutput=basename((string)($state['output'] ?? '')); $previewOutput=basename((string)($state['preview_output'] ?? '')); $hasFullVideo=$fullOutput !== '' && is_file($config['videos_dir'].'/'.$fullOutput); $hasPreviewVideo=$previewOutput !== '' && is_file($config['videos_dir'].'/'.$previewOutput); $displayOutput=($preview && $hasPreviewVideo) ? $previewOutput : ($hasFullVideo ? $fullOutput : ($hasPreviewVideo ? $previewOutput : '')); $hasVideo=$displayOutput !== ''; $displayVideoPath=$hasVideo ? $config['videos_dir'].'/'.$displayOutput : ''; $previewVideo=$hasVideo && $displayOutput === $previewOutput; $canShow=$hasFullVideo && !$preview; $visible=$canShow && input_dir_visible($d); $info=$dirInfo[basename($d)] ?? input_dir_info($d); $data=read_data($d); $meta=cached_video_metadata($displayOutput, $data); $currentSignature=$info['signature'] ?? ''; $fullFingerprint=is_array($state)?(string)($state['fingerprint'] ?? ''):''; $previewFingerprint=is_array($state)?(string)($state['preview_fingerprint'] ?? ''):''; $editedSinceRender=$hasFullVideo && $currentSignature !== '' && $fullFingerprint !== '' && $currentSignature !== $fullFingerprint; $editedSincePreview=!$hasFullVideo && $previewVideo && $currentSignature !== '' && $previewFingerprint !== '' && $currentSignature !== $previewFingerprint; $staleLabel=$editedSinceRender ? 'Edited since render' : ($editedSincePreview ? 'Edited since preview' : ''); ?><article class="video-row admin-video-row<?= $visible ? ' shown' : '' ?>" data-job="<?=h(basename($d))?>"><div><?php if($hasVideo): ?><div class="video-wrapper"><video controls preload="metadata" src="<?=h($config['public_videos'].'/'.rawurlencode($displayOutput))?>"></video><?php $map_n = ($displayOutput !== '' ? pathinfo($displayOutput, PATHINFO_FILENAME) : basename($d)) . '.png'; if(is_file($config['videos_dir'].'/'.$map_n)): ?><a class="map-image-link" href="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>"><img class="map-image" src="<?=h($config['public_videos'].'/'.rawurlencode($map_n))?>" alt="Map"></a><?php endif; ?></div><?php else: ?><div class="video-placeholder"><img src="assets/img/moto_travel.png" alt=""><span>No video yet</span></div><?php endif; ?></div><div class="video-info"><div class="admin-row-top"><div class="admin-switches">
<form method="post" class="inline-form switch-form" data-switch="preview">
<input type="hidden" name="action" value="set_preview">
<input type="hidden" name="dir" value="<?=h(basename($d))?>">
<label class="switch-label">
<input type="checkbox" name="preview" value="1" <?=$preview?'checked':''?>>
<span class="switch-ui" aria-hidden="true"></span><span class="switch-text">Preview</span>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<form method="post" class="inline-form switch-form" data-switch="render">
<input type="hidden" name="action" value="set_enabled">
<input type="hidden" name="dir" value="<?=h(basename($d))?>">
<label class="switch-label">
<input type="checkbox" name="enabled" value="1" <?=$enabled?'checked':''?>>
<span class="switch-ui" aria-hidden="true"></span><span class="switch-text">Render</span>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<form method="post" class="inline-form switch-form" data-switch="show">
<input type="hidden" name="action" value="set_visible">
<input type="hidden" name="dir" value="<?=h(basename($d))?>">
<label class="switch-label">
<input type="checkbox" name="visible" value="1" <?=$visible?'checked':''?> <?=!$canShow?'disabled':''?>>
<span class="switch-ui" aria-hidden="true"></span><span class="switch-text">Show</span>
</label>
<noscript><button type="submit">Apply</button></noscript>
</form>
<?php if($hasVideo): ?><time class="admin-time<?= $staleLabel ? ' admin-time-stale' : '' ?>" title="<?=h($staleLabel ?: 'Video created at')?>"><?=h(date('d.m.Y H:i', filemtime($displayVideoPath)))?></time><?php endif; ?>
</div><div class="admin-actions"><?php if($running): ?><span class="button disabled" title="Rendering now" data-edit-action="1">Rendering</span><?php else: ?><a class="button" href="?edit=<?=rawurlencode(basename($d))?>" data-edit-action="1" data-edit-href="?edit=<?=rawurlencode(basename($d))?>">Edit</a><?php endif; ?></div></div><h2><?=h($meta['title'] ?: ($info['title'] ?? basename($d)))?></h2><p class="meta"><?php if($meta['date']): ?><span><?=h($meta['date'])?></span><?php endif; ?><?php if($meta['location']): ?><span><?=h($meta['location'])?></span><?php endif; ?><?php if(!$hasVideo): ?><span>No video</span><?php endif; ?><?php if($previewVideo): ?><span>Preview video</span><?php endif; ?></p><?php if($meta['description']): ?><p class="description"><?=nl2br(h($meta['description']), false)?></p><?php endif; ?><p class="details"><?=h(basename($d))?> · <?=h($info['file_count'] ?? count(list_files($d)))?> files</p></div></article><?php endforeach; ?></div><?=page_links('edit',$editPage,$editPages)?></section><?php endif; ?>
<?php if($tab==='edit' && $editDir && $editRunning): ?><section><h2>Edit input dir</h2><p><code>in-dir/<?=h(basename($editDir))?></code></p><div class="err">This input directory is <?=h($editStatus)?>. Editing is disabled until the job completes.</div></section><?php endif; ?>
<?php if($tab==='edit' && $editDir && !$editRunning): ?><section><h2>Edit input dir</h2><p><code>in-dir/<?=h(basename($editDir))?></code></p><form method="post" enctype="multipart/form-data" id="edit-form"><input type="hidden" name="action" value="update"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><label>Title<input name="title" value="<?=h($editData['title'])?>"></label><label>Date<input name="date" value="<?=h($editData['date'])?>"></label><label>Place<input name="place" value="<?=h($editData['place'])?>"></label><label>Description<textarea name="description" maxlength="5000" data-counter="description-counter-edit"><?=h($editData['description'])?></textarea><small id="description-counter-edit" class="counter"></small></label><label>Add images / videos<input type="file" name="media[]" multiple accept="image/*,video/*"></label><label>Add audio<input type="file" name="audio[]" multiple accept="audio/*"></label><h3>Files and captions</h3><div class="file-list"><?php foreach($editFiles as $f): $ext=strtolower(pathinfo($f, PATHINFO_EXTENSION)); $fileUrl='in-dir/'.rawurlencode(basename($editDir)).'/'.rawurlencode($f); ?><div class="caption-row"><div class="preview"><?php if(in_array($ext,['jpg','jpeg','png','webp','gif'])): ?><img src="<?=h($fileUrl)?>" alt=""><?php elseif(in_array($ext,['mp4','mov','m4v','webm'])): ?><video src="<?=h($fileUrl)?>" controls preload="metadata"></video><a class="download-link" href="<?=h($fileUrl)?>" download>Download</a><?php else: ?><span><?=h(strtoupper($ext ?: 'FILE'))?></span><?php endif; ?></div><div class="caption-fields"><strong><?=h($f)?></strong><?php if(in_array($ext,['jpg','jpeg','png','webp','gif','mp4','mov','m4v','webm'])): ?><input type="hidden" name="caption_files[]" value="<?=h($f)?>"><textarea name="captions[]" placeholder="Optional caption for this file"><?=h($editData['captions'][$f] ?? '')?></textarea><?php if(in_array($ext,['mp4','mov','m4v','webm'])): ?><label class="checkbox-label"><input type="checkbox" name="use_audio_files[]" value="<?=h($f)?>" <?=!empty($editData['video_audio'][$f])?'checked':''?>> <span>Use video file audio</span></label><?php endif; ?><?php else: ?><small>No caption for audio files</small><?php endif; ?></div><button type="button" onclick="deleteFile(<?=h(json_encode($f))?>)">Remove</button></div><?php endforeach; ?></div><button type="submit">Save changes</button></form><form method="post" id="delete-file-form" style="display:none"><input type="hidden" name="action" value="delete_file"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><input type="hidden" name="file" id="delete-file-name" value=""></form><form method="post" onsubmit="return confirm(\'Delete this input directory\?\')" id="delete-dir-form""><input type="hidden" name="action" value="delete_dir"><input type="hidden" name="dir" value="<?=h(basename($editDir))?>"><label class="checkbox-label"><input type="checkbox" name="delete_output" value="1"> <span>Also delete generated video, if any</span></label><button class="danger" type="submit">Delete input dir</button></form></section><?php endif; ?>
<?php if($tab==='new'): ?><section><h2>Add new movie input</h2><form method="post" enctype="multipart/form-data" id="new-form"><input type="hidden" name="action" value="create"><label>Title*<input name="title" required></label><label>Date<input name="date" placeholder="2026-05-25"></label><label>Place<input name="place"></label><label>Description<textarea name="description" maxlength="5000" data-counter="description-counter-new"></textarea><small id="description-counter-new" class="counter"></small></label><label>Images / videos*<input type="file" name="media[]" multiple required accept="image/*,video/*"></label><label>Audio (optional)<input type="file" name="audio[]" multiple accept="audio/*"></label><button type="submit">Create input-dir</button></form></section><?php endif; ?>
<script>
function deleteFile(name){
if(!confirm('Remove this file?')) return;
var form = document.getElementById('delete-file-form');
if(!form){ showToast('Internal error: delete form not found', false); return; }
var dirInput = form.querySelector('input[name=dir]');
var dir = dirInput ? dirInput.value : '';
// find the caption-row for this file name
var rows = document.querySelectorAll('.caption-row');
var target = null;
for(var i=0;i<rows.length;i++){
var strong = rows[i].querySelector('.caption-fields strong') || rows[i].querySelector('strong');
if(!strong) continue;
if(strong.textContent.trim() === name){ target = rows[i]; break; }
}
var body = 'action=delete_file&dir=' + encodeURIComponent(dir) + '&file=' + encodeURIComponent(name) + '&ajax=1';
fetch('new.php', {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: body
}).then(function(r){ return r.json().catch(function(){ return { ok: false, message: 'Invalid server response' }; }); })
.then(function(json){
if(!json || !json.ok) throw new Error(json && (json.error || json.message) ? (json.error || json.message) : 'Delete failed');
if(target) target.remove();
showToast(json.message || ('Removed ' + name), true);
}).catch(function(err){
showToast(err.message || String(err), false);
});
}
</script>
<script>
function showToast(message, ok, details){
let wrap=document.getElementById('toast-wrap');
if(!wrap){
wrap=document.createElement('div');
wrap.id='toast-wrap';
wrap.className='toast-wrap';
document.body.appendChild(wrap);
}
const toast=document.createElement('div');
toast.className='toast '+(ok? 'ok-toast' : 'err-toast');
toast.style.pointerEvents = 'auto';
const text=document.createElement('div');
text.style.whiteSpace='normal';
text.textContent=message;
toast.appendChild(text);
if(details){
const actions=document.createElement('div');
actions.style.marginTop='8px';
actions.style.textAlign='right';
const dbtn=document.createElement('button');
dbtn.className='button';
dbtn.textContent='Details';
dbtn.style.padding='0.35rem 0.6rem';
dbtn.style.fontSize='0.85rem';
dbtn.addEventListener('click', function(e){ e.stopPropagation(); e.preventDefault(); showLogModal('Generator log', details); });
actions.appendChild(dbtn);
toast.appendChild(actions);
}
wrap.appendChild(toast);
requestAnimationFrame(function(){toast.classList.add('show');});
const timeout = details ? 15000 : 4000;
setTimeout(function(){
toast.classList.remove('show');
setTimeout(function(){toast.remove();},250);
}, timeout);
}
function showLogModal(title, text){
var existing=document.getElementById('mvlog-log-modal');
if(existing) existing.remove();
var overlay=document.createElement('div');
overlay.id='mvlog-log-modal';
overlay.style.cssText='position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:10001;';
var box=document.createElement('div');
box.style.cssText='background:white;color:#111;padding:16px;max-width:90%;max-height:80vh;overflow:auto;border-radius:6px;font-family:inherit;';
var h=document.createElement('h3');
h.textContent=title||'Details';
var pre=document.createElement('pre');
pre.style.cssText='white-space:pre-wrap;font-family:monospace;margin-top:10px;border:1px solid #eee;padding:12px;background:#f8f8f8;border-radius:4px; max-height:60vh; overflow:auto;';
pre.textContent=text||'';
var btnBar=document.createElement('div');
btnBar.style.cssText='margin-top:12px;text-align:right;';
var copyBtn=document.createElement('button');
copyBtn.className='button'; copyBtn.textContent='Copy';
var closeBtn=document.createElement('button'); closeBtn.className='button'; closeBtn.textContent='Close'; closeBtn.style.marginLeft='8px';
btnBar.appendChild(copyBtn); btnBar.appendChild(closeBtn);
box.appendChild(h); box.appendChild(pre); box.appendChild(btnBar); overlay.appendChild(box); document.body.appendChild(overlay);
closeBtn.addEventListener('click', function(){ overlay.remove(); });
copyBtn.addEventListener('click', function(){ try{ navigator.clipboard.writeText(text).then(function(){ showToast('Copied to clipboard', true); }, function(){ showToast('Copy failed', false); }); }catch(e){ showToast('Copy failed', false); } });
}
const editForm=document.getElementById('edit-form');
if(editForm) handleAjaxFormSubmit(editForm, 'edit');
const newForm=document.getElementById('new-form');
if(newForm) handleAjaxFormSubmit(newForm, 'edit');
const deleteFileForm=document.getElementById('delete-file-form');
if(deleteFileForm) handleAjaxFormSubmit(deleteFileForm, 'edit');
const deleteDirForm=document.getElementById('delete-dir-form');
if(deleteDirForm) handleAjaxFormSubmit(deleteDirForm, 'edit');
// For old delete video forms
// Redirect tab defaults to videos
for(const form of document.querySelectorAll('.js-ajax-form')) {
handleAjaxFormSubmit(form, form.dataset.redirectTab || 'videos');
}
function handleAjaxFormSubmit(form, redirectTab) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const submit = form.querySelector('button[type="submit"]');
const data = new FormData(form);
data.set('ajax', '1');
const action = String(data.get('action') || '');
const verb = action === 'update' ? 'Saving'
: action === 'delete_video' ? 'Deleting'
: action === 'delete_dir' ? 'Deleting'
: action === 'delete_file' ? 'Deleting'
: action === 'create' ? 'Creating'
: 'Working';
let timer = 0;
let success = false;
if (submit) {
submit.dataset.label = submit.dataset.label || submit.textContent.trim() || 'Submit';
submit.disabled = true;
submit.style.opacity = '0.78';
submit.style.cursor = 'wait';
let dots = 0;
submit.textContent = verb;
timer = window.setInterval(function(){
dots = (dots + 1) % 4;
submit.textContent = verb + '.'.repeat(dots);
}, 320);
}
try{
const res = await fetch('new.php', { method: 'POST', body: data, credentials: 'same-origin', headers: { 'Accept': 'application/json' }});
const json = await res.json().catch(() => ({}));
if(!res.ok || !json.ok) throw new Error(json.error || 'Operation failed');
success = true;
showToast(json.message || 'Operation successful', true);
setTimeout(() => { window.location.href = 'new.php?tab=' + (json.tab || redirectTab); }, 450);
} catch(e) {
showToast(e.message || 'Operation failed', false);
} finally {
if (timer) window.clearInterval(timer);
if(submit && !success) {
submit.disabled = false;
submit.style.opacity = '';
submit.style.cursor = '';
submit.textContent = submit.dataset.label || 'Submit';
}
}
});
}
const initialMessage = <?= json_encode($msg ?? '') ?>;
if (initialMessage) showToast(initialMessage, true);
initSwitchForms();
function initSwitchForms(){
document.querySelectorAll('.switch-form').forEach(function(form){
const checkbox=form.querySelector('input[type="checkbox"]');
if(!checkbox) return;
checkbox.dataset.state = checkbox.checked ? '1' : '0';
form.addEventListener('submit', function(event){ event.preventDefault(); });
checkbox.addEventListener('change', function(event){
event.preventDefault();
handleSwitchToggle(form, checkbox);
});
});
}
async function handleSwitchToggle(form, checkbox){
if(checkbox.disabled) return;
const previousState = checkbox.dataset.state === '1';
const data = new FormData(form);
data.set('ajax', '1');
if(checkbox.checked){
data.set(checkbox.name, '1');
}else{
data.delete(checkbox.name);
}
checkbox.disabled = true;
try{
const res = await fetch('new.php', { method: 'POST', body: data, credentials: 'same-origin', headers: { 'Accept': 'application/json' }});
const json = await res.json().catch(() => ({}));
if(!res.ok || !json.ok) throw new Error(json.error || 'Switch update failed');
applySwitchPayload(json);
showToast(json.message || 'Updated input directory', true);
}catch(e){
checkbox.checked = previousState;
checkbox.dataset.state = previousState ? '1' : '0';
showToast(e.message || 'Switch update failed', false);
}finally{
checkbox.disabled = false;
}
}
function applySwitchPayload(payload){
if(!payload || !payload.dir) return;
const row = document.querySelector('.admin-video-row[data-job="'+payload.dir+'"]');
if(!row) return;
function setSwitch(type, value){
const target = row.querySelector('form[data-switch="'+type+'"] input[type="checkbox"]');
if(!target) return;
target.checked = !!value;
target.dataset.state = target.checked ? '1' : '0';
}
if('preview' in payload) setSwitch('preview', payload.preview);
if('enabled' in payload) setSwitch('render', payload.enabled);
if('visible' in payload){
setSwitch('show', payload.visible);
row.classList.toggle('shown', !!payload.visible);
}
if('can_show' in payload){
const showInput = row.querySelector('form[data-switch="show"] input[type="checkbox"]');
if(showInput){
showInput.disabled = !payload.can_show;
if(!payload.can_show){
showInput.checked = false;
showInput.dataset.state = '0';
row.classList.remove('shown');
}
}
}
}
</script>
<script>
(function(){
const box=document.getElementById('job-status');
if(!box) return;
function fmt(job){
const name=(job.name||'job').replace(/_/g,' ');
const started=job.started_at ? new Date(job.started_at) : null;
const ageText = started && !Number.isNaN(started.getTime()) ? ' <span class="job-age">(' + formatAge(started) + ')</span>' : '';
return '<span class="job-dot"></span><span>'+name+'</span>'+ageText;
}
function formatAge(started){
const seconds = Math.max(0, Math.floor((Date.now() - started.getTime()) / 1000));
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return h + 'h ' + m + 'm';
if (m > 0) return m + 'm ' + s + 's';
return s + 's';
}
function syncEditButtons(runningJobs){
document.querySelectorAll('.admin-video-row[data-job]').forEach(function(row){
const job=row.dataset.job || '';
const edit=row.querySelector('[data-edit-action="1"]');
if(!edit) return;
const running=runningJobs.has(job);
if(edit.tagName === 'A'){
if(running){
if(!edit.dataset.editHref) edit.dataset.editHref = edit.getAttribute('href') || '';
edit.removeAttribute('href');
edit.classList.add('disabled');
edit.setAttribute('aria-disabled', 'true');
edit.title='Rendering now';
}else{
const href=edit.dataset.editHref || ('?edit=' + encodeURIComponent(job));
edit.setAttribute('href', href);
edit.classList.remove('disabled');
edit.removeAttribute('aria-disabled');
edit.title='Edit';
}
}else{
edit.classList.toggle('disabled', running);
edit.title=running ? 'Rendering now' : '';
}
});
}
async function updateJobs(){
try{
const r=await fetch('job_status.php',{cache:'no-store'});
if(!r.ok) throw new Error('status failed');
const data=await r.json();
const jobs=(data.jobs||[]).filter(function(job){ return job && job.name; });
const runningJobs=new Set(jobs.map(function(job){ return String(job.name); }));
syncEditButtons(runningJobs);
if(!jobs.length){
box.hidden=true;
box.innerHTML='';
return;
}
box.hidden=false;
box.innerHTML=jobs.map(fmt).join('');
}catch(e){
if(box.innerHTML.trim() !== '') box.hidden=false;
}
}
updateJobs();
setInterval(updateJobs,30000);
})();
</script></main>
<footer>Input dirs are saved under <code>in-dir/</code>.</footer>
<footer class="site-footer admin-footer"><div class="admin-left"><nav class="tabs"><a class="<?= $tab==='new'?'active':'' ?>" href="new.php?tab=new">New</a><a class="<?= $tab==='edit'?'active':'' ?>" href="new.php?tab=edit">Edit</a><a class="<?= $tab==='videos'?'active':'' ?>" href="new.php?tab=videos">Videos</a></nav></div><div id="job-status" class="job-status"<?= empty($runningJobs) ? ' hidden' : '' ?>><?php foreach($runningJobs as $job): ?><span class="job-dot"></span><span><?=h(str_replace('_', ' ', $job['name']))?></span><?php if(!empty($job['started_at'])): ?><span class="job-age">(<?=h(format_job_age($job['started_at']))?>)</span><?php endif; ?><?php endforeach; ?></div><div class="admin-right"><a class="logout-link" href="logout.php">Log off</a></div></footer>
<script>document.querySelectorAll('textarea[data-counter]').forEach(function(t){var c=document.getElementById(t.dataset.counter);function u(){c.textContent=t.value.length+' / '+t.maxLength+' characters';}t.addEventListener('input',u);u();});</script>
<script>(function(){const box=document.getElementById('job-status');if(!box)return;function fmt(job){const name=(job.name||'job').replace(/_/g,' ');const started=job.started_at ? new Date(job.started_at) : null;const ageText=started && !Number.isNaN(started.getTime()) ? ' <span class="job-age">(' + formatAge(started) + ')</span>' : '';return '<span class="job-dot"></span><span>'+name+'</span>'+ageText;}function formatAge(started){const seconds=Math.max(0,Math.floor((Date.now()-started.getTime())/1000));const h=Math.floor(seconds/3600);const m=Math.floor((seconds%3600)/60);const s=seconds%60;if(h>0) return h+'h '+m+'m';if(m>0) return m+'m '+s+'s';return s+'s';}function syncEditButtons(runningJobs){document.querySelectorAll('.admin-video-row[data-job]').forEach(function(row){const job=row.dataset.job || '';const edit=row.querySelector('[data-edit-action="1"]');if(!edit) return;const running=runningJobs.has(job);if(edit.tagName==='A'){if(running){if(!edit.dataset.editHref) edit.dataset.editHref = edit.getAttribute('href') || '';edit.removeAttribute('href');edit.classList.add('disabled');edit.setAttribute('aria-disabled','true');edit.title='Rendering now';}else{const href=edit.dataset.editHref || ('?edit=' + encodeURIComponent(job));edit.setAttribute('href', href);edit.classList.remove('disabled');edit.removeAttribute('aria-disabled');edit.title='Edit';}}else{edit.classList.toggle('disabled', running);edit.title=running ? 'Rendering now' : '';}});}async function updateJobs(){try{const r=await fetch('job_status.php',{cache:'no-store'});if(!r.ok) throw new Error('status failed');const data=await r.json();const jobs=(data.jobs||[]).filter(function(job){ return job && job.name; });const runningJobs=new Set(jobs.map(function(job){ return String(job.name); }));syncEditButtons(runningJobs);if(!jobs.length){box.hidden=true;box.innerHTML='';return;}box.hidden=false;box.innerHTML=jobs.map(fmt).join('');}catch(e){if(box.innerHTML.trim()!=='') box.hidden=false;}}updateJobs();setInterval(updateJobs,30000);})();</script>
<script>
(function(){
function attachDescribeButtons(){
document.querySelectorAll('.admin-video-row').forEach(function(row){
if (row.querySelector('.describe-button')) return;
var actions = row.querySelector('.admin-actions');
if (!actions) return;
var job = row.dataset.job;
if (!job) return;
var describe = document.createElement('a');
describe.className = 'button describe-button';
describe.href = '#';
describe.dataset.job = job;
describe.textContent = 'Describe';
var first = actions.querySelector('.button');
if (first) actions.insertBefore(describe, first);
else actions.appendChild(describe);
});
}
function showModal(job, description, rawJson){
var existing = document.getElementById('mvlog-gemini-modal');
if (existing) existing.remove();
var overlay = document.createElement('div');
overlay.id = 'mvlog-gemini-modal';
overlay.style.cssText = 'position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
var box = document.createElement('div');
box.style.cssText = 'background:white;color:#111;padding:20px;max-width:900px;max-height:80vh;overflow:auto;border-radius:6px;font-family:inherit;';
var h = document.createElement('h3');
h.textContent = 'Generated description';
var pre = document.createElement('div');
pre.style.cssText = 'white-space:pre-wrap;font-family:inherit;margin-top:10px;border:1px solid #eee;padding:12px;background:#f8f8f8;border-radius:4px;';
pre.textContent = description || '';
var btnBar = document.createElement('div');
btnBar.style.cssText = 'margin-top:12px;text-align:right;';
var useBtn = document.createElement('button');
useBtn.className = 'button';
useBtn.textContent = 'Use in form';
var cancelBtn = document.createElement('button');
cancelBtn.className = 'button';
cancelBtn.textContent = 'Close';
cancelBtn.style.marginLeft = '8px';
btnBar.appendChild(useBtn);
btnBar.appendChild(cancelBtn);
box.appendChild(h);
box.appendChild(pre);
box.appendChild(btnBar);
overlay.appendChild(box);
document.body.appendChild(overlay);
cancelBtn.addEventListener('click', function(){ overlay.remove(); });
useBtn.addEventListener('click', function(){
var editForm = document.getElementById('edit-form');
if (editForm){
var dirInput = editForm.querySelector('input[name="dir"]');
if (dirInput && dirInput.value === job){
var textarea = editForm.querySelector('textarea[name="description"]');
if (textarea){
textarea.value = description || '';
showToast('Inserted generated description into form. Click Save changes to apply.', true);
overlay.remove();
return;
}
}
}
try { localStorage.setItem('mvlog_gemini_description_' + job, description || ''); } catch(e){}
window.location.href = 'new.php?tab=edit&edit=' + encodeURIComponent(job);
});
}
document.addEventListener('click', function(e){
var el = (e.target && e.target.closest && e.target.closest('.describe-button')) || (e.target && e.target.classList && e.target.classList.contains && e.target.classList.contains('describe-button') ? e.target : null);
if (!el) return;
e.preventDefault();
var job = el.dataset.job;
if (!job) return;
el.classList.add('disabled');
var oldText = el.textContent;
el.textContent = 'Generating...';
fetch('lib/generate_data_sync.php', {
method: 'POST',
credentials: 'same-origin',
headers: {'Content-Type':'application/x-www-form-urlencoded'},
body: 'job=' + encodeURIComponent(job) + '&max_frames=16'
}).then(function(r){ return r.json().catch(()=>({})); })
.then(function(data){
if (data && data.status === 'ok'){
var desc = data.description || (data.raw_json && JSON.stringify(data.raw_json)) || '';
showModal(job, desc, data.raw_json || null);
} else {
var msg = (data && data.message) ? data.message : 'No response';
if (data && data.log) {
try {
var log = String(data.log);
var lines = log.split('\n').map(function(l){ return l.trim(); }).filter(function(l){ return l.length>0; });
var keywords = ['quota','rate limit','rate-limit','429','503','overloaded','high demand','busy','unavailable','error','failed','retry'];
var found = lines.find(function(l){ var ll=l.toLowerCase(); return keywords.some(function(k){ return ll.indexOf(k) !== -1; }); });
if (found) msg += ' — ' + found;
else if (lines.length) msg += ' — ' + lines[0].slice(0,200);
} catch(e){}
}
showToast('Error: ' + msg, false, (data && data.log) ? (typeof data.log === 'string' ? data.log : JSON.stringify(data.log)) : null);
}
}).catch(function(err){
showToast('Request failed: ' + err, false);
}).finally(function(){
el.classList.remove('disabled');
el.textContent = oldText;
});
});
attachDescribeButtons();
var list = document.querySelector('.admin-list.video-list');
if (list) new MutationObserver(function(){ attachDescribeButtons(); }).observe(list, {childList:true, subtree:true});
// On edit page load: if a generated description is in localStorage, insert it into the form.
(function(){
var editForm = document.getElementById('edit-form');
if (!editForm) return;
var dirInput = editForm.querySelector('input[name="dir"]');
if (!dirInput) return;
var job = dirInput.value;
try {
var key = 'mvlog_gemini_description_' + job;
var desc = localStorage.getItem(key);
if (desc) {
var textarea = editForm.querySelector('textarea[name="description"]');
if (textarea) {
textarea.value = desc;
showToast('Inserted generated description into form. Click Save changes to apply.', true);
}
localStorage.removeItem(key);
}
} catch(e){}
})();
})();
</script>
<script src="js/map_lightbox.js?v=20260531b" defer></script>
</body></html>