movmaker-webui/index.php

310 lines
15 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
date_default_timezone_set('Europe/Copenhagen');
$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 nice_title($s){ return trim(ucwords(str_replace(['_','-'], ' ', (string)$s))); }
function lower_text($s){ return function_exists('mb_strtolower') ? mb_strtolower((string)$s, 'UTF-8') : strtolower((string)$s); }
function keep_non_empty(array $params): array { return array_filter($params, fn($v)=>$v !== '' && $v !== null); }
function query_url(array $params): string {
$params = keep_non_empty($params);
return $params ? ('?' . http_build_query($params)) : 'index.php';
}
function hidden_video_outputs($config){
$hidden = [];
foreach (glob($config['uploads_dir'].'/*', GLOB_ONLYDIR) ?: [] as $dir) {
if (!is_file($dir . '/.mvlog-hidden')) continue;
$state = is_file($dir . '/.movmaker-state.json') ? json_decode((string)file_get_contents($dir . '/.movmaker-state.json'), true) : [];
if (is_array($state) && !empty($state['output'])) $hidden[basename((string)$state['output'])] = true;
}
return $hidden;
}
function video_metadata($path){
$base = pathinfo($path, PATHINFO_FILENAME);
$meta = [
'title' => nice_title($base),
'date' => date('Y-m-d', filemtime($path)),
'sort_date' => date('Y-m-d', filemtime($path)),
'location' => '',
'description' => ''
];
if (preg_match('/^(\d{4})(\d{2})(\d{2})[_-](.+)$/', $base, $m)) {
$meta['date'] = "$m[1]-$m[2]-$m[3]";
$meta['sort_date'] = "$m[1]-$m[2]-$m[3]";
$meta['title'] = nice_title($m[4]);
}
foreach ([dirname($path).'/'.$base.'.txt', dirname($path).'/'.$base.'.data.txt'] as $sidecar) {
if (!is_file($sidecar)) continue;
foreach (file($sidecar, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (!str_contains($line, ':')) continue;
[$k, $v] = array_map('trim', explode(':', $line, 2));
$k = strtolower($k);
if (in_array($k, ['title','date','location','place','description'], true)) {
$meta[$k === 'place' ? 'location' : $k] = $v;
}
}
}
$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 '.escapeshellarg($path).' 2>/dev/null');
$data = json_decode((string)$json, true);
$tags = $data['format']['tags'] ?? [];
$lower = [];
foreach ($tags as $k => $v) $lower[strtolower((string)$k)] = $v;
if (!empty($lower['title'])) $meta['title'] = $lower['title'];
if (!empty($lower['date'])) $meta['date'] = $lower['date'];
if (!empty($lower['creation_time'])) {
$meta['sort_date'] = substr((string)$lower['creation_time'], 0, 10);
if (empty($lower['date'])) $meta['date'] = $meta['sort_date'];
}
foreach (['location','place','location-eng','com.apple.quicktime.location.name','com.apple.quicktime.location.iso6709'] as $k) {
if (!empty($lower[$k])) { $meta['location'] = $lower[$k]; break; }
}
foreach (['description','synopsis'] as $k) {
if (!empty($lower[$k])) { $meta['description'] = $lower[$k]; break; }
}
}
return $meta;
}
function load_video_cache($videos){
$cacheFile = __DIR__ . '/cache/videos.json';
$cache = is_file($cacheFile) ? json_decode((string)file_get_contents($cacheFile), true) : [];
if (!is_array($cache) || ($cache['version'] ?? 0) !== 2) $cache = [];
$items = $cache['items'] ?? [];
$newItems = [];
$changed = false;
foreach ($videos as $path) {
$key = basename($path);
$mtime = filemtime($path);
$size = filesize($path);
$cached = $items[$key] ?? null;
if (!is_array($cached) || ($cached['mtime'] ?? null) !== $mtime || ($cached['size'] ?? null) !== $size) {
$cached = ['mtime' => $mtime, 'size' => $size, 'metadata' => video_metadata($path)];
$changed = true;
}
$newItems[$key] = $cached;
}
if (array_diff(array_keys($items), array_keys($newItems))) $changed = true;
if ($changed) {
file_put_contents($cacheFile, json_encode([
'version' => 2,
'generated_at' => date('c'),
'items' => $newItems
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
}
return $newItems;
}
$hiddenOutputs = hidden_video_outputs($config);
$videos = array_values(array_filter(
glob($config['videos_dir'].'/*.{mp4,webm,mov,m4v}', GLOB_BRACE) ?: [],
fn($v)=>empty($hiddenOutputs[basename($v)]) && !preg_match('/_preview\.(mp4|webm|mov|m4v)$/i', basename($v))
));
$videoCache = load_video_cache($videos);
usort($videos, function($a, $b) use ($videoCache) {
$am = $videoCache[basename($a)] ?? [];
$bm = $videoCache[basename($b)] ?? [];
$ad = strtotime((string)($am['metadata']['sort_date'] ?? $am['metadata']['date'] ?? '')) ?: filemtime($a);
$bd = strtotime((string)($bm['metadata']['sort_date'] ?? $bm['metadata']['date'] ?? '')) ?: filemtime($b);
return $bd <=> $ad;
});
$rawKeyword = trim((string)($_GET['q'] ?? ''));
// Backward compatibility with older filter/query formats.
if ($rawKeyword === '') {
$legacyTitle = trim((string)($_GET['f_title'] ?? ''));
$legacyDate = trim((string)($_GET['f_date'] ?? ''));
$legacyLocation = trim((string)($_GET['f_location'] ?? ''));
$legacyJob = trim((string)($_GET['job'] ?? ''));
if ($legacyTitle !== '') $rawKeyword = $legacyTitle;
elseif ($legacyDate !== '') $rawKeyword = 'date=' . $legacyDate;
elseif ($legacyLocation !== '') $rawKeyword = 'location=' . $legacyLocation;
elseif ($legacyJob !== '') {
$legacy = preg_replace('/^\d{8}[_-]/', '', $legacyJob);
$rawKeyword = nice_title($legacy ?: $legacyJob);
}
}
$keywordType = 'general';
$keywordValue = $rawKeyword;
if ($rawKeyword !== '' && preg_match('/^\s*(date|location|place)\s*=\s*(.+)\s*$/i', $rawKeyword, $m)) {
$keywordType = lower_text((string)$m[1]);
if ($keywordType === 'place') $keywordType = 'location';
$keywordValue = trim((string)$m[2]);
}
$videos = array_values(array_filter($videos, function($v) use ($videoCache, $keywordType, $keywordValue) {
if ($keywordValue === '') return true;
$name = basename($v);
$m = $videoCache[$name]['metadata'] ?? video_metadata($v);
$title = (string)($m['title'] ?? '');
$description = (string)($m['description'] ?? '');
$date = (string)($m['date'] ?? '');
$sortDate = (string)($m['sort_date'] ?? '');
$location = (string)($m['location'] ?? '');
if ($keywordType === 'date') {
return stripos($date, $keywordValue) !== false || stripos($sortDate, $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));
$per = $config['items_per_page'];
$total = count($videos);
$pages = max(1, (int)ceil($total / $per));
$page = min($page, $pages);
$slice = array_slice($videos, ($page - 1) * $per, $per);
$activeFilterCount = ($keywordValue !== '') ? 1 : 0;
$baseParams = ($rawKeyword !== '') ? ['q' => $rawKeyword] : [];
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>MVLog</title>
<link rel="alternate" type="application/rss+xml" title="MVLog RSS" href="feed.php">
<link rel="icon" type="image/png" href="assets/img/moto_travel.png">
<link rel="stylesheet" href="style.css">
<style>
.filter-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:0 0 .4rem}
.filter-input-wrap{position:relative;min-width:0}
.filter-input-wrap input{margin:0;padding-right:2.15rem}
.filter-clear-inside{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}
.filter-clear-inside:hover{background:#d97b48;color:#111315;transform:translateY(-50%) scale(1.05)}
.filter-clear-inside.is-empty{opacity:.38;pointer-events:none}
.filter-submit{display:inline-grid;place-items:center;width:2.35rem;height:2.35rem;padding:0;border-radius:.5rem;background:#C46A3A;color:#111315}
.filter-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}
.filter-summary{color:#A0A4AB;font-size:.9rem;margin:0 0 .8rem}
.video-row-head{grid-column:1 / -1;margin:0 0 .15rem}
.video-row-title{font-size:1.35rem;line-height:1.2;margin:.1rem 0 .45rem;color:#F3F4F6}
.video-row-head .meta{margin:0 0 .45rem}
.meta a{color:inherit;text-decoration:none}
.meta a:hover{text-decoration:underline}
</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</h1><p>Bubulescu.Org</p></a></div><button id="push-toggle" class="push-toggle" type="button" hidden aria-label="Notifications" title="Notifications"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22a2.7 2.7 0 0 0 2.6-2h-5.2A2.7 2.7 0 0 0 12 22Zm7-6V11a7 7 0 0 0-5-6.7V3a2 2 0 0 0-4 0v1.3A7 7 0 0 0 5 11v5l-2 2v1h18v-1l-2-2Z"/></svg></button><a class="rss-link" href="feed.php" aria-label="RSS feed" title="RSS feed"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6.2 17.8a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM2.2 8.7v3.1a10 10 0 0 1 10 10h3.1A13.1 13.1 0 0 0 2.2 8.7Zm0-5.5v3.1a15.5 15.5 0 0 1 15.5 15.5h3.1A18.6 18.6 0 0 0 2.2 3.2Z"/></svg></a></header>
<main>
<section id="videos">
<form method="get" class="filter-form">
<div class="filter-input-wrap">
<input name="q" value="<?=h($rawKeyword)?>" placeholder="Search title/description or use date=... / location=..." aria-label="Search articles">
<a class="filter-clear-inside <?=$rawKeyword === '' ? 'is-empty' : ''?>" href="index.php" aria-label="Clear filter" title="Clear filter">×</a>
</div>
<button type="submit" class="filter-submit" aria-label="Filter">
<span class="icon" aria-hidden="true">🔍</span>
<span class="sr-only">Filter</span>
</button>
</form>
<p class="filter-summary">
Showing <?=h((string)$total)?> article(s)
<?php if($activeFilterCount > 0): ?> with <?=h((string)$activeFilterCount)?> active filter(s)<?php endif; ?>
</p>
<?php if(!$slice): ?><p>No videos found for current filter.</p><?php endif; ?>
<div class="video-list">
<?php foreach($slice as $v):
$name = basename($v);
$url = $config['public_videos'].'/'.rawurlencode($name);
$m = $videoCache[$name]['metadata'] ?? video_metadata($v);
$dateFilterUrl = query_url(['q' => 'date=' . (string)($m['date'] ?? '')]);
$locFilterUrl = query_url(['q' => 'location=' . (string)($m['location'] ?? '')]);
?>
<article class="video-row">
<time class="created-at" datetime="<?=h(date('c', filemtime($v)))?>"><?=h(date('d.m.Y H:i', filemtime($v)))?></time>
<div class="video-row-head">
<h2 class="video-row-title"><?=h($m['title'])?></h2>
<p class="meta">
<span><a href="<?=h($dateFilterUrl)?>"><?=h($m['date'])?></a></span>
<?php if(!empty($m['location'])): ?><span><a href="<?=h($locFilterUrl)?>"><?=h($m['location'])?></a></span><?php endif; ?>
</p>
</div>
<div class="video-wrapper">
<video controls controlsList="nodownload" oncontextmenu="return false" preload="metadata" src="<?=h($url)?>"></video>
<?php
$map = pathinfo($name, PATHINFO_FILENAME).".png";
if(is_file($config['videos_dir']."/".$map)):
?>
<a class="map-image-link" href="<?=h($config['public_videos']."/".rawurlencode($map))?>"><img class="map-image" src="<?=h($config['public_videos']."/".rawurlencode($map))?>" alt="Map"></a>
<?php endif; ?>
</div>
<div class="video-info">
<?php if(!empty($m['description'])): ?><p class="description"><?=nl2br(h($m['description']), false)?></p><?php endif; ?>
</div>
</article>
<?php endforeach; ?>
</div>
<div class="pages">
<?php for($i=1;$i<=$pages;$i++):
$linkParams = $baseParams;
if ($i > 1) $linkParams['page'] = $i;
?>
<a class="<?=$i===$page?'active':''?>" href="<?=h(query_url($linkParams))?>"><?=$i?></a>
<?php endfor; ?>
</div>
</section>
</main>
<footer>Bubulescu.Org</footer>
<script>
(function(){
const btn=document.getElementById('push-toggle');
if(!btn || !('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return;
function b64uToUint8Array(s){ const pad='='.repeat((4-s.length%4)%4); const b64=(s+pad).replace(/-/g,'+').replace(/_/g,'/'); const raw=atob(b64); return Uint8Array.from([...raw].map(c=>c.charCodeAt(0))); }
async function state(reg){ const sub=await reg.pushManager.getSubscription(); btn.classList.toggle('active', !!sub); btn.title=sub?'Notifications on':'Notifications'; btn.hidden=false; return sub; }
navigator.serviceWorker.register('sw.js').then(async reg=>{
const cfg=await fetch('push_config.php',{cache:'no-store'}).then(r=>r.json()).catch(()=>({enabled:false}));
if(!cfg.enabled || !cfg.publicKey) return;
await state(reg);
btn.addEventListener('click', async ()=>{
btn.disabled=true;
try{
let sub=await reg.pushManager.getSubscription();
if(sub){
return;
}else{
const perm=await Notification.requestPermission();
if(perm!=='granted') return;
sub=await reg.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:b64uToUint8Array(cfg.publicKey)});
await fetch('push_subscribe.php',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(sub)});
}
await state(reg);
}catch(e){ alert(e.message || 'Could not update notifications'); }
finally{ await state(reg); }
});
});
})();
</script>
<script src="js/map_lightbox.js?v=20260531c" defer></script>
</body>
</html>