191 lines
16 KiB
PHP
191 lines
16 KiB
PHP
<?php
|
|
require __DIR__ . '/auth.php';
|
|
mvlog_require_login();
|
|
$config = require __DIR__ . "/config.php";
|
|
foreach (["videos_dir", "thumbs_dir", "uploads_dir"] as $d) if (!is_dir($config[$d])) mkdir($config[$d], 0775, true);
|
|
function h($s){ return htmlspecialchars((string)$s, ENT_QUOTES, "UTF-8"); }
|
|
function slugify($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){ $dirs = glob($base.'/*', GLOB_ONLYDIR) ?: []; usort($dirs, fn($a,$b)=>filemtime($b)<=>filemtime($a)); 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.'); return $path; }
|
|
function read_data($dir){
|
|
$data = ['title'=>'','date'=>'','place'=>'','description'=>'','captions'=>[]]; $file = $dir . '/data.txt';
|
|
if (!is_file($file)) return $data;
|
|
foreach (file($file, FILE_IGNORE_NEW_LINES) as $line) {
|
|
$line = trim($line);
|
|
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, ':')) continue;
|
|
[$key, $value] = array_map('trim', explode(':', $line, 2));
|
|
$low = strtolower($key);
|
|
if (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 ($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: ' . str_replace(["\r", "\n"], ' ', $description);
|
|
$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 list_files($dir){ $files = array_values(array_filter(scandir($dir), fn($f)=>editable_file($f) && is_file($dir.'/'.$f))); natcasesort($files); return $files; }
|
|
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 paginate($items, $page, $perPage = 10){
|
|
$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 '';
|
|
$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.'">'.$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.');
|
|
$slug = date('Ymd') . '_' . slugify($title);
|
|
$dir = $config['uploads_dir'] . '/' . $slug;
|
|
if (!mkdir($dir, 0775, true)) throw new RuntimeException('Cannot create input directory.');
|
|
chmod($dir, 02775);
|
|
$media = save_uploads('media', $dir, $allowedMedia);
|
|
if (!$media) { rrmdir($dir); throw new RuntimeException('Upload at least one image or video.'); }
|
|
save_uploads('audio', $dir, $allowedAudio);
|
|
write_data($dir, $_POST);
|
|
$msg = "Created movmaker input directory: in-dir/$slug";
|
|
$_GET['edit'] = $slug;
|
|
} elseif ($action === 'update') {
|
|
$dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? '');
|
|
write_data($dir, $_POST);
|
|
save_uploads('media', $dir, $allowedMedia);
|
|
save_uploads('audio', $dir, $allowedAudio);
|
|
$msg = 'Updated input directory: in-dir/' . basename($dir);
|
|
$_GET['edit'] = basename($dir);
|
|
} elseif ($action === 'delete_file') {
|
|
$dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? '');
|
|
$file = basename((string)($_POST['file'] ?? ''));
|
|
if (!editable_file($file) || !is_file($dir.'/'.$file)) throw new RuntimeException('Invalid file.');
|
|
unlink($dir.'/'.$file);
|
|
$msg = 'Removed file: ' . $file;
|
|
$_GET['edit'] = basename($dir);
|
|
} 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);
|
|
$msg = 'Deleted video: ' . $file;
|
|
} elseif ($action === 'delete_dir') {
|
|
$dir = safe_input_dir($config['uploads_dir'], $_POST['dir'] ?? '');
|
|
$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);
|
|
header('Location: new.php?msg=' . rawurlencode('Deleted input directory: in-dir/' . $name));
|
|
exit;
|
|
}
|
|
}
|
|
} catch (Throwable $e) { $err = $e->getMessage(); }
|
|
$dirs = input_dirs($config['uploads_dir']);
|
|
$orphanVideos = orphan_videos($config);
|
|
$tab = $_GET['tab'] ?? (isset($_GET['edit']) ? 'edit' : 'new');
|
|
$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; $editData = ['title'=>'','date'=>'','place'=>'','description'=>'','captions'=>[]]; $editFiles = [];
|
|
if ($editName !== '') { try { $editDir = safe_input_dir($config['uploads_dir'], $editName); $editData = read_data($editDir); $editFiles = list_files($editDir); } catch (Throwable $e) { $err = $e->getMessage(); } }
|
|
?>
|
|
<!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"></head><body>
|
|
<header class="site-header admin-header"><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 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 Admin</h1><p>Bubulescu.Org</p></a></div><div class="admin-right"><div id="job-status" class="job-status" hidden></div><a class="logout-link" href="logout.php">Log off</a></div></header><main>
|
|
<?php if($msg): ?><div class="ok"><?=h($msg)?></div><?php endif; ?><?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?')"><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'): ?><section><h2>Existing input dirs</h2><?php if(!$dirs): ?><p>No input directories yet.</p><?php endif; ?><div class="admin-list"><?php foreach($dirsPage as $d): $data=read_data($d); ?><div class="admin-item"><div><strong><?=h($data['title'] ?: basename($d))?></strong><br><span><?=h(basename($d))?> · <?=h(count(list_files($d)))?> files</span></div><a class="button" href="?edit=<?=rawurlencode(basename($d))?>">Edit</a></div><?php endforeach; ?></div><?=page_links('edit',$editPage,$editPages)?></section><?php endif; ?>
|
|
<?php if($tab==='edit' && $editDir): ?><section><h2>Edit input dir</h2><p><code>in-dir/<?=h(basename($editDir))?></code></p><form method="post" enctype="multipart/form-data"><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)?>" muted preload="metadata"></video><?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 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?')"><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 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"><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?')){document.getElementById('delete-file-name').value=name;document.getElementById('delete-file-form').submit();}}</script><script>
|
|
(function(){
|
|
const box=document.getElementById('job-status');
|
|
if(!box) return;
|
|
function fmt(job){
|
|
const name=(job.name||'job').replace(/_/g,' ');
|
|
return '<span class="job-dot"></span><span>'+name+'</span>';
|
|
}
|
|
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||[];
|
|
if(!jobs.length){box.hidden=true;box.innerHTML='';return;}
|
|
box.hidden=false;
|
|
box.innerHTML='<strong>Running</strong>'+jobs.map(fmt).join('');
|
|
}catch(e){box.hidden=true;}
|
|
}
|
|
updateJobs();
|
|
setInterval(updateJobs,30000);
|
|
})();
|
|
</script></main><footer>Input dirs are saved under <code>in-dir/</code>.</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></body></html>
|