405 lines
18 KiB
PHP
405 lines
18 KiB
PHP
<?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 fold_text($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;
|
||
}
|
||
$s = lower_text($s);
|
||
return preg_replace('/[^a-z0-9]+/', ' ', $s) ?? '';
|
||
}
|
||
function ci_contains($haystack, $needle){
|
||
$haystack = (string)$haystack;
|
||
$needle = (string)$needle;
|
||
if ($needle === '') return true;
|
||
if (function_exists('mb_stripos') && mb_stripos($haystack, $needle, 0, 'UTF-8') !== false) return true;
|
||
if (strpos(lower_text($haystack), lower_text($needle)) !== false) return true;
|
||
$foldHay = fold_text($haystack);
|
||
$foldNeedle = trim(fold_text($needle));
|
||
if ($foldNeedle === '') return false;
|
||
return strpos($foldHay, $foldNeedle) !== false;
|
||
}
|
||
function 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 map_hidden_outputs($config){
|
||
$hidden = [];
|
||
foreach (glob($config['uploads_dir'].'/*', GLOB_ONLYDIR) ?: [] as $dir) {
|
||
if (!is_file($dir . '/.mvlog-hide-map')) 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_thumb_public_url($config, $videoName){
|
||
$videoName = basename((string)$videoName);
|
||
if ($videoName === '') return '';
|
||
$base = pathinfo($videoName, PATHINFO_FILENAME);
|
||
foreach (['jpg','jpeg','png','webp','gif'] as $ext) {
|
||
$file = $config['thumbs_dir'] . '/' . $base . '.' . $ext;
|
||
if (is_file($file)) return $config['public_thumbs'] . '/' . rawurlencode($base . '.' . $ext) . '?v=' . filemtime($file);
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function video_metadata($path){
|
||
$base = pathinfo($path, PATHINFO_FILENAME);
|
||
$meta = [
|
||
'title' => nice_title($base),
|
||
'teaser' => '',
|
||
'date' => date('Y-m-d', filemtime($path)),
|
||
'sort_date' => date('Y-m-d', filemtime($path)),
|
||
'location' => '',
|
||
'quote_da' => '',
|
||
'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','teaser','date','location','place','description','quote_da'], true)) {
|
||
$target = ($k === 'place') ? 'location' : $k;
|
||
$meta[$target] = $target === 'quote_da' ? trim((string)$v, "\"“”") : $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['teaser'])) $meta['teaser'] = $lower['teaser'];
|
||
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; }
|
||
}
|
||
if (!empty($lower['quote_da'])) {
|
||
$meta['quote_da'] = trim((string)$lower['quote_da'], "\"“”");
|
||
}
|
||
}
|
||
|
||
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) !== 4) $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' => 4,
|
||
'generated_at' => date('c'),
|
||
'items' => $newItems
|
||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX);
|
||
}
|
||
|
||
return $newItems;
|
||
}
|
||
|
||
$hiddenOutputs = hidden_video_outputs($config);
|
||
$hiddenMapOutputs = map_hidden_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'] ?? '');
|
||
$teaser = (string)($m['teaser'] ?? '');
|
||
$description = (string)($m['description'] ?? '');
|
||
$quoteDa = (string)($m['quote_da'] ?? '');
|
||
$date = (string)($m['date'] ?? '');
|
||
$sortDate = (string)($m['sort_date'] ?? '');
|
||
$location = (string)($m['location'] ?? '');
|
||
|
||
if ($keywordType === 'date') {
|
||
return ci_contains($date, $keywordValue) || ci_contains($sortDate, $keywordValue);
|
||
}
|
||
if ($keywordType === 'location') {
|
||
return ci_contains($location, $keywordValue);
|
||
}
|
||
|
||
return ci_contains($title, $keywordValue) || ci_contains($teaser, $keywordValue) || ci_contains($quoteDa, $keywordValue) || ci_contains($description, $keywordValue);
|
||
}));
|
||
|
||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||
$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] : [];
|
||
$headerTitles = [
|
||
'Moto Vlog',
|
||
'Multiple Voices Log',
|
||
'Multi-Version Log',
|
||
'MultiVerse Log',
|
||
'Miles & Views Log',
|
||
'Moving Vistas Log',
|
||
'Motorcycle Voyage Log',
|
||
'Mapped Vagabond Log',
|
||
'Mountain Valley Log',
|
||
'Miles Versus Logic',
|
||
'Motorcycle Voyage Log',
|
||
'Mapped Vagabond Log',
|
||
'Multiple Viewpoints Log',
|
||
'Multiple Versions of Life',
|
||
'Motion, Velocity & Luck',
|
||
'Miles, Views & Life',
|
||
'Many Voices of Life',
|
||
'Many Views of Life',
|
||
'Modern Vagabond Log',
|
||
'Motorcycle Wanderlust Log',
|
||
'Mapped Wanderings Log',
|
||
'Miles Beyond Reason',
|
||
'Miles Beyond Reality',
|
||
'Motion, Velocity & Lunacy',
|
||
'Miles, Wind & Luck',
|
||
'Maps, Villages & Legends',
|
||
'Multi-Vision Log',
|
||
'Multi-Vista Log',
|
||
'Mutable View Log',
|
||
'Mistakes, Victories & Lessons',
|
||
'Mostly Verified Legends',
|
||
'Mostly Vague Logistics',
|
||
'Marginally Viable Leadership',
|
||
'Mileage Versus Luck',
|
||
'Mechanical Violence Log',
|
||
'Moderately Violent Leisure',
|
||
'Mistakes Validated Later',
|
||
];
|
||
$siteHeaderTitle = $headerTitles[array_rand($headerTitles)];
|
||
?>
|
||
<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>MVL</title>
|
||
<meta property="og:title" content="MVL">
|
||
<meta property="og:description" content="journal of an unreliable narrator.">
|
||
<meta property="og:image" content="https://bubulescu.org/assets/img/moto_travel.png">
|
||
<meta property="og:url" content="https://bubulescu.org">
|
||
<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?v=20260531u">
|
||
<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-title{font-size:1.35rem;line-height:1.2;color:#F3F4F6}
|
||
.video-row-head-mobile{display:none}
|
||
.video-info .video-row-title{margin:0 0 .35rem}
|
||
.video-info .meta{margin:0 0 .45rem}
|
||
.description-teaser{margin:.15rem 0 .45rem;color:#EADFC9}
|
||
.description-quote{margin:.15rem 0 .45rem;font-style:italic;font-size:.82rem;color:#D6D9DE}
|
||
#videos .video-wrapper{align-self:start}
|
||
#videos .video-info{align-self:start}
|
||
#videos .video-info .description{margin-top:0}
|
||
.meta a{color:inherit;text-decoration:none}
|
||
.meta a:hover{text-decoration:underline}
|
||
@media (max-width:900px){
|
||
.video-row-head-mobile{display:block;grid-column:1 / -1;margin:0 0 .05rem}
|
||
.video-row-head-mobile .description-teaser{margin:.25rem 0 .3rem}
|
||
#videos .video-wrapper{grid-column:1 / -1}
|
||
#videos .video-info{grid-column:1 / -1}
|
||
.video-info .video-row-title.video-row-title-desktop,
|
||
.video-info .meta.meta-desktop,
|
||
.video-info .description-teaser.description-teaser-desktop{display:none}
|
||
#videos .video-info .description{margin-top:.8rem}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<?php include __DIR__ . '/_header.php'; ?>
|
||
<main>
|
||
<section id="videos">
|
||
<form method="get" class="filter-form">
|
||
<div class="filter-input-wrap">
|
||
<input name="q" value="<?=h($rawKeyword)?>" placeholder="Search title/teaser/quote_da/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);
|
||
$posterUrl = video_thumb_public_url($config, $name);
|
||
$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 video-row-head-mobile">
|
||
<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>
|
||
<?php if(!empty($m['teaser'])): ?><p class="description-teaser description-teaser-mobile"><?=h((string)$m['teaser'])?></p><?php endif; ?>
|
||
</div>
|
||
<div class="video-wrapper">
|
||
<video controls controlsList="nodownload" oncontextmenu="return false" preload="metadata" src="<?=h($url)?>"<?= $posterUrl !== '' ? ' poster="'.h($posterUrl).'"' : '' ?>></video>
|
||
<?php
|
||
$map = pathinfo($name, PATHINFO_FILENAME).".png";
|
||
$hideMap = !empty($hiddenMapOutputs[$name]);
|
||
if(!$hideMap && 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">
|
||
<h2 class="video-row-title video-row-title-desktop"><?=h($m['title'])?></h2>
|
||
<p class="meta meta-desktop">
|
||
<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>
|
||
<?php if(!empty($m['teaser'])): ?><p class="description-teaser description-teaser-desktop"><?=h((string)$m['teaser'])?></p><?php endif; ?>
|
||
<?php if(!empty($m['description'])): ?><p class="description"><?=nl2br(h($m['description']), false)?></p><?php endif; ?>
|
||
<?php if(!empty($m['quote_da'])): ?><p class="description-quote">“<?=h(trim((string)$m['quote_da'], "\"“”"))?>”</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>
|
||
<?php include __DIR__ . '/_footer.php'; ?>
|
||
</body>
|
||
</html>
|