Code simplification, move to wizard style UI, JSON file cache (scans every 15 mins)

This commit is contained in:
markmental 2026-03-02 13:45:28 -05:00
commit 92362efd47
11 changed files with 1027 additions and 438 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
.env
var/cache/

View file

@ -126,11 +126,28 @@ The launcher exports:
| MEDIA_USER | Login username |
| MEDIA_PASS_HASH | bcrypt password hash |
| MEDIA_ROOT | Base directory containing Videos/ and Music/ |
| MEDIA_CACHE_DIR | Cache directory for media index JSON files |
| MEDIA_CACHE_INTERVAL | Background rebuild interval in seconds (default: 900) |
Credentials are **not stored on disk**.
They exist only in the running server environment.
If `MEDIA_CACHE_DIR` is not set, FREAX defaults to `var/cache/media` inside the project directory.
---
## Media Cache Refresh
FREAX maintains JSON cache indexes for both `Videos` and `Music`.
- `build_media_cache.php` rebuilds both caches in one pass
- `start-media-server.sh` runs a background refresh loop while the server is running
- Default refresh interval is 900 seconds (15 minutes)
- Cache and lock files are stored in `MEDIA_CACHE_DIR`
`get_files.php` reads cache first and only rescans on cache miss/corruption.
---
## LAN Usage
@ -225,7 +242,7 @@ FREAX intentionally minimizes attack surface:
* No SQL backend
* No upload endpoints
* No file writes
* No media file writes
* No user content creation
* No dynamic code execution
* Strict base directory enforcement
@ -291,4 +308,3 @@ This is free software released under the GPLv2 license. You have permission to m
---
Happy hacking.

View file

@ -118,8 +118,55 @@ h1 {
margin-bottom: 1.5rem;
}
.wizard-panel {
margin-bottom: 1rem;
}
.wizard-step {
font-family: 'Orbitron', sans-serif;
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-primary);
margin-bottom: 1rem;
}
.wizard-section {
display: grid;
gap: 0.5rem;
}
.wizard-toolbar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
}
.wizard-toolbar .btn {
padding: 0.55rem 0.4rem;
font-size: 0.7rem;
}
.wizard-library {
font-family: 'Orbitron', sans-serif;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
}
.wizard-path {
color: var(--text-primary);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.45rem 0.6rem;
font-size: 0.82rem;
word-break: break-word;
}
.tab-btn {
flex: 1;
width: 100%;
padding: 0.75rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
@ -222,6 +269,10 @@ h1 {
font-size: 0.85rem;
}
.file-item.folder-entry {
background: var(--bg-tertiary);
}
.file-item:hover {
border-color: var(--accent-primary);
background: var(--bg-tertiary);

View file

@ -1,32 +1,81 @@
function showLogin(message = '') {
document.getElementById('loginOverlay').style.display = 'flex';
document.getElementById('logoutBtn').style.display = 'none';
// Freax Media Player - Wizard Frontend
const err = document.getElementById('loginError');
if (message) {
err.textContent = message;
err.style.display = 'block';
} else {
err.textContent = '';
err.style.display = 'none';
const VIDEO_EXTENSIONS = ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'];
const LIBRARY_LABELS = { videos: 'Videos', music: 'Music' };
const state = {
step: 1,
currentType: null,
filesByType: { videos: null, music: null },
currentNode: null,
currentPathParts: [],
selectedFilePath: null
};
const mediaDebugEnabled = (() => {
const params = new URLSearchParams(window.location.search);
if (params.get('debug') === '1') return true;
try {
return window.localStorage.getItem('MEDIA_DEBUG') === '1';
} catch (_) {
return false;
}
})();
const $ = id => document.getElementById(id);
function logMediaDebug(details) {
if (!mediaDebugEnabled) return;
const {
type,
source,
url,
status,
cacheStatus,
backendMs,
frontendFetchMs,
frontendParseMs,
renderMs,
payloadBytes,
forceRefresh,
cacheGeneratedAt,
error
} = details;
console.groupCollapsed(`[media-debug] ${type}`);
if (source) console.log('source:', source);
if (url) console.log('url:', url);
if (typeof status !== 'undefined') console.log('status:', status);
if (cacheStatus) console.log('cache:', cacheStatus);
if (backendMs) console.log('backend_ms:', backendMs);
if (typeof frontendFetchMs === 'number') console.log('frontend_fetch_ms:', Math.round(frontendFetchMs));
if (typeof frontendParseMs === 'number') console.log('frontend_parse_ms:', Math.round(frontendParseMs));
if (typeof renderMs === 'number') console.log('render_ms:', Math.round(renderMs));
if (typeof payloadBytes === 'number') console.log('payload_bytes:', payloadBytes);
if (forceRefresh) console.log('force_refresh:', forceRefresh);
if (cacheGeneratedAt) console.log('cache_generated_at:', cacheGeneratedAt);
if (error) console.log('error:', error);
console.groupEnd();
}
function showLogin(message = '') {
$('loginOverlay').style.display = 'flex';
$('logoutBtn').style.display = 'none';
$('loginError').textContent = message;
$('loginError').style.display = message ? 'block' : 'none';
}
function hideLogin() {
document.getElementById('loginOverlay').style.display = 'none';
document.getElementById('loginError').style.display = 'none';
document.getElementById('logoutBtn').style.display = 'inline-block';
$('loginOverlay').style.display = 'none';
$('loginError').style.display = 'none';
$('logoutBtn').style.display = 'inline-block';
}
async function authedFetch(url, options = {}) {
// Ensure cookies (session) are sent
const resp = await fetch(url, {
...options,
credentials: 'same-origin',
});
const resp = await fetch(url, { ...options, credentials: 'same-origin' });
if (resp.status === 401) {
// Session missing/expired
showLogin('Please log in to continue.');
throw new Error('Unauthorized');
}
@ -34,39 +83,20 @@ async function authedFetch(url, options = {}) {
return resp;
}
const state = {
currentType: 'videos',
currentFile: null,
filesData: null,
currentItemEl: null
};
// Initialize
document.addEventListener('DOMContentLoaded', () => {
setupTabListeners();
setupLoginHandlers();
// Try loading files; if 401, overlay will appear.
loadFiles('videos');
setupWizardListeners();
initApp();
});
function setupLoginHandlers() {
const form = document.getElementById('loginForm');
const spinner = document.getElementById('loginSpinner');
const err = document.getElementById('loginError');
const logoutBtn = document.getElementById('logoutBtn');
// Show overlay until proven authed
showLogin('');
form.addEventListener('submit', async (e) => {
$('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
err.style.display = 'none';
spinner.style.display = 'inline-block';
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const username = $('username').value.trim();
const password = $('password').value;
$('loginError').style.display = 'none';
$('loginSpinner').style.display = 'inline-block';
try {
const resp = await fetch('login.php', {
@ -77,352 +107,458 @@ function setupLoginHandlers() {
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data.error || `Login failed (${resp.status})`);
throw new Error(data.error || 'Login failed');
}
hideLogin();
// Reload file browser after login
loadFiles(state.currentType);
initApp();
} catch (ex) {
err.textContent = ex.message || 'Login failed';
err.style.display = 'block';
$('loginError').textContent = ex.message || 'Login failed';
$('loginError').style.display = 'block';
} finally {
spinner.style.display = 'none';
$('loginSpinner').style.display = 'none';
}
});
logoutBtn.addEventListener('click', async () => {
$('logoutBtn').addEventListener('click', async () => {
try {
await fetch('logout.php', { method: 'POST', credentials: 'same-origin' });
} catch (_) {
// ignore
}
} catch (_) {}
state.step = 1;
state.currentType = null;
state.currentNode = null;
state.currentPathParts = [];
state.selectedFilePath = null;
state.filesByType = { videos: null, music: null };
updateWizardStep();
showLibraryPicker();
$('fileBrowser').innerHTML = '<p style="color: var(--text-secondary); padding: 1rem;">Choose a library to browse files.</p>';
showLogin('Logged out.');
// Clear UI
document.getElementById('fileBrowser').innerHTML = '';
document.getElementById('playerContainer').innerHTML = `
$('playerContainer').innerHTML = `
<div class="player-placeholder">
<div class="player-placeholder-icon">🎵</div>
<p>Select a file to start playing</p>
</div>
`;
document.getElementById('nowPlaying').style.display = 'none';
$('nowPlaying').style.display = 'none';
resetAudioTrackUI();
});
}
function setupTabListeners() {
document.querySelectorAll('.tab-btn').forEach(btn => {
function setupWizardListeners() {
document.querySelectorAll('[data-library]').forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.dataset.type;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.currentType = type;
loadFiles(type);
selectLibrary(btn.dataset.library).catch(() => {});
});
});
$('backBtn').addEventListener('click', () => {
if (state.currentPathParts.length === 0) {
showLibraryPicker();
return;
}
state.currentPathParts.pop();
state.currentNode = getNodeAtPath(state.currentType, state.currentPathParts);
renderCurrentDirectory();
});
$('rootBtn').addEventListener('click', () => {
if (!state.currentType) return;
goToLibraryRoot();
});
$('refreshBtn').addEventListener('click', () => {
if (!state.currentType) return;
selectLibrary(state.currentType, { forceRefresh: true }).catch(() => {});
});
}
async function loadFiles(type) {
const browser = document.getElementById('fileBrowser');
function initApp() {
updateWizardStep();
showLibraryPicker();
const urlParams = new URLSearchParams(window.location.search);
const playPath = urlParams.get('play');
if (!playPath) {
return;
}
const ext = playPath.toLowerCase().split('.').pop();
const type = VIDEO_EXTENSIONS.includes(ext) ? 'videos' : 'music';
selectLibrary(type).then(() => {
navigateToFile(playPath);
}).catch(() => {});
}
async function selectLibrary(type, options = {}) {
const { forceRefresh = false } = options;
if (!LIBRARY_LABELS[type]) return;
setLibraryButtonActive(type);
showBrowserStep();
const loaded = await ensureLibraryData(type, forceRefresh);
if (!loaded) return;
state.currentType = type;
goToLibraryRoot();
}
function goToLibraryRoot() {
state.currentPathParts = [];
state.currentNode = getNodeAtPath(state.currentType, state.currentPathParts);
renderCurrentDirectory();
}
async function ensureLibraryData(type, forceRefresh = false) {
if (!forceRefresh && state.filesByType[type] !== null) {
logMediaDebug({ type, source: 'memory' });
hideLogin();
return true;
}
const browser = $('fileBrowser');
browser.innerHTML = '<div class="loading"></div>';
const url = `get_files.php?type=${encodeURIComponent(type)}${forceRefresh ? '&refresh=1' : ''}`;
const startedAt = performance.now();
try {
const response = await authedFetch(`get_files.php?type=${type}`);
const resp = await authedFetch(url);
const fetchedAt = performance.now();
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const debugHeaders = {
cacheStatus: resp.headers.get('X-Media-Cache'),
backendMs: resp.headers.get('X-Media-Time-Ms'),
forceRefresh: resp.headers.get('X-Media-Force-Refresh'),
cacheGeneratedAt: resp.headers.get('X-Media-Cache-Generated-At')
};
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await response.text();
console.error('Non-JSON response:', text);
throw new Error('Server returned non-JSON response');
}
const data = await response.json();
console.log('Loaded files:', data);
const data = await resp.json();
const parsedAt = performance.now();
if (data.error) {
browser.innerHTML = `<p style="color: #ff6b6b; padding: 1rem;">${data.error}</p>`;
return;
logMediaDebug({
type,
source: 'network',
url,
status: resp.status,
cacheStatus: debugHeaders.cacheStatus,
backendMs: debugHeaders.backendMs,
frontendFetchMs: fetchedAt - startedAt,
frontendParseMs: parsedAt - fetchedAt,
forceRefresh: debugHeaders.forceRefresh,
cacheGeneratedAt: debugHeaders.cacheGeneratedAt,
error: data.error
});
return false;
}
if (data.message) {
browser.innerHTML = `<p style="color: var(--text-secondary); padding: 1rem;">${data.message}</p>`;
return;
const normalizedTree = data.message ? {} : data;
state.filesByType[type] = normalizedTree;
hideLogin();
let payloadBytes = null;
try {
payloadBytes = JSON.stringify(data).length;
} catch (_) {
payloadBytes = null;
}
state.filesData = data;
renderFileTree(data);
logMediaDebug({
type,
source: 'network',
url,
status: resp.status,
cacheStatus: debugHeaders.cacheStatus,
backendMs: debugHeaders.backendMs,
frontendFetchMs: fetchedAt - startedAt,
frontendParseMs: parsedAt - fetchedAt,
payloadBytes,
forceRefresh: debugHeaders.forceRefresh,
cacheGeneratedAt: debugHeaders.cacheGeneratedAt
});
return true;
} catch (error) {
console.error('Error loading files:', error);
browser.innerHTML = `<p style="color: #ff6b6b; padding: 1rem;">Error loading files: ${error.message}</p>`;
logMediaDebug({
type,
source: 'network',
url,
frontendFetchMs: performance.now() - startedAt,
error: error.message || String(error)
});
if (error.message !== 'Unauthorized') {
browser.innerHTML = '<p style="color: #ff6b6b; padding: 1rem;">Error loading files</p>';
}
return false;
}
}
function setActiveFileItem(fileItemEl) {
// Clear existing "active" state
document.querySelectorAll('.file-item').forEach(f => f.classList.remove('active'));
// Set new "active"
if (fileItemEl) {
fileItemEl.classList.add('active');
fileItemEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
function getNodeAtPath(type, pathParts) {
let node = state.filesByType[type] || {};
for (const part of pathParts) {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
return {};
}
state.currentItemEl = fileItemEl || null;
const next = node[part];
if (!next || typeof next !== 'object' || Array.isArray(next)) {
return {};
}
node = next;
}
return node;
}
/**
* Finds the "next" file-item in UI order, preferring the same folder/div
* the current item is in. If there's no next sibling, it walks up the DOM
* and continues searching.
*/
function findNextFileItem(currentEl) {
if (!currentEl) return null;
function renderCurrentDirectory() {
const renderStartedAt = performance.now();
const browser = $('fileBrowser');
const node = state.currentNode;
// 1) Prefer "next siblings" within the same container
let n = currentEl.nextElementSibling;
while (n) {
if (n.classList && n.classList.contains('file-item')) return n;
n = n.nextElementSibling;
updateWizardStep();
updateWizardPath();
browser.innerHTML = '';
if (!node || typeof node !== 'object' || Array.isArray(node)) {
browser.innerHTML = '<p style="color: var(--text-secondary); padding: 1rem;">No entries found.</p>';
return;
}
// 2) If none, walk upward and look for the next file-item after the
// parent container (e.g., next folder section)
let parent = currentEl.parentElement;
while (parent) {
// Stop at the file browser root
if (parent.id === 'fileBrowser') break;
let pNext = parent.nextElementSibling;
while (pNext) {
// If the next sibling is a file-item, play it
if (pNext.classList && pNext.classList.contains('file-item')) return pNext;
// If it's a folder, search inside it for the first file-item
const inside = pNext.querySelector && pNext.querySelector('.file-item');
if (inside) return inside;
pNext = pNext.nextElementSibling;
const entries = Object.entries(node);
if (entries.length === 0) {
browser.innerHTML = '<p style="color: var(--text-secondary); padding: 1rem;">No media files found.</p>';
return;
}
parent = parent.parentElement;
for (const [name, content] of entries) {
const item = document.createElement('div');
item.className = 'file-item';
if (typeof content === 'object' && !Array.isArray(content)) {
item.classList.add('folder-entry');
item.innerHTML = `<span class="file-icon">📁</span><span>${name}</span>`;
item.addEventListener('click', () => {
state.currentPathParts.push(name);
state.currentNode = content;
renderCurrentDirectory();
});
} else if (typeof content === 'string') {
item.dataset.kind = 'file';
item.dataset.path = content;
item.dataset.name = name;
item.innerHTML = `<span class="file-icon">${state.currentType === 'videos' ? '🎬' : '🎵'}</span><span>${name}</span>`;
if (content === state.selectedFilePath) {
item.classList.add('active');
}
// No next item found
return null;
item.addEventListener('click', () => {
navigateToPlayback(content);
});
} else {
continue;
}
browser.appendChild(item);
}
logMediaDebug({
type: state.currentType,
source: 'memory',
renderMs: performance.now() - renderStartedAt
});
}
function navigateToFile(filePath) {
if (!state.currentType) return;
const parts = filePath.split('/').filter(Boolean);
const rootName = state.currentType === 'videos' ? 'Videos' : 'Music';
if (parts[0] && parts[0].toLowerCase() === rootName.toLowerCase()) {
parts.shift();
}
const fileName = parts.pop();
if (!fileName) return;
let node = state.filesByType[state.currentType] || {};
const folderParts = [];
for (const part of parts) {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
break;
}
const next = node[part];
if (!next || typeof next !== 'object' || Array.isArray(next)) {
break;
}
folderParts.push(part);
node = next;
}
state.currentPathParts = folderParts;
state.currentNode = node;
const fileValue = node[fileName];
if (typeof fileValue === 'string') {
state.selectedFilePath = fileValue;
playFile(fileValue, fileName);
requestAnimationFrame(() => {
renderCurrentDirectory();
});
return;
}
renderCurrentDirectory();
}
function updateWizardStep() {
const stepText = {
1: 'Step 1 - Choose Library',
2: 'Step 2 - Browse',
3: 'Step 3 - Play'
}[state.step] || 'Step 1 - Choose Library';
$('wizardStep').textContent = stepText;
}
function showLibraryPicker() {
state.step = 1;
updateWizardStep();
$('libraryPicker').style.display = 'grid';
$('browserStep').style.display = 'none';
document.querySelectorAll('[data-library]').forEach(btn => btn.classList.remove('active'));
}
function showBrowserStep() {
state.step = 2;
updateWizardStep();
$('libraryPicker').style.display = 'none';
$('browserStep').style.display = 'grid';
}
function updateWizardPath() {
if (!state.currentType) return;
const label = LIBRARY_LABELS[state.currentType] || state.currentType;
const path = state.currentPathParts.length ? `/${state.currentPathParts.join('/')}` : '/';
$('currentLibraryLabel').textContent = label;
$('breadcrumb').textContent = path;
$('backBtn').disabled = state.currentPathParts.length === 0;
$('rootBtn').disabled = state.currentPathParts.length === 0;
}
function setLibraryButtonActive(type) {
document.querySelectorAll('[data-library]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.library === type);
});
}
function navigateToPlayback(path) {
const params = new URLSearchParams();
params.set('play', path);
const currentParams = new URLSearchParams(window.location.search);
if (currentParams.get('debug') === '1') {
params.set('debug', '1');
}
window.location.href = `index.php?${params.toString()}`;
}
function playFile(path, name) {
const container = $('playerContainer');
const ext = path.toLowerCase().split('.').pop();
const isVideo = VIDEO_EXTENSIONS.includes(ext);
const media = document.createElement(isVideo ? 'video' : 'audio');
media.controls = true;
media.autoplay = true;
media.src = `serve_media.php?file=${encodeURIComponent(path)}`;
media.addEventListener('error', () => {
container.innerHTML = `
<div class="player-placeholder">
<div class="player-placeholder-icon"></div>
<p style="color: #ff6b6b;">Error playing: ${name}</p>
</div>
`;
});
media.addEventListener('ended', playNextItem);
if (isVideo) {
media.addEventListener('loadedmetadata', () => setupAudioTrackSelector(media));
}
container.innerHTML = '';
container.appendChild(media);
$('nowPlayingTitle').textContent = name;
$('nowPlaying').style.display = 'block';
state.step = 3;
updateWizardStep();
resetAudioTrackUI();
}
function playNextItem() {
const next = findNextFileItem(state.currentItemEl);
if (!next) {
console.log('Reached end of list; no next item.');
return;
}
const files = Array.from($('fileBrowser').querySelectorAll('.file-item[data-kind="file"]'));
if (files.length === 0) return;
// Trigger the same behavior as clicking it
const currentIdx = files.findIndex(el => el.classList.contains('active'));
if (currentIdx < 0) return;
const next = files[currentIdx + 1];
if (next) {
next.click();
}
function renderFileTree(files, container = document.getElementById('fileBrowser'), depth = 0) {
if (depth === 0) {
container.innerHTML = '';
}
for (const [name, content] of Object.entries(files)) {
if (typeof content === 'object' && !Array.isArray(content)) {
// It's a folder
const folder = document.createElement('div');
folder.className = 'folder';
const header = document.createElement('div');
header.className = 'folder-header';
header.innerHTML = `<span class="folder-icon">▶</span><span>${name}</span>`;
const folderContent = document.createElement('div');
folderContent.className = 'folder-content';
header.addEventListener('click', () => {
folder.classList.toggle('open');
});
folder.appendChild(header);
folder.appendChild(folderContent);
container.appendChild(folder);
renderFileTree(content, folderContent, depth + 1);
} else if (typeof content === 'string') {
// It's a file
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
const icon = state.currentType === 'videos' ? '🎬' : '🎵';
fileItem.innerHTML = `<span class="file-icon">${icon}</span><span>${name}</span>`;
fileItem.dataset.path = content;
fileItem.dataset.name = name;
fileItem.addEventListener('click', () => {
// Play and mark active
playFile(content, name, fileItem);
setActiveFileItem(fileItem);
});
container.appendChild(fileItem);
}
}
}
function resetAudioTrackUI() {
const audioControls = document.getElementById('audioControls');
const audioSelect = document.getElementById('audioSelect');
const audioHint = document.getElementById('audioHint');
audioControls.style.display = 'none';
audioHint.style.display = 'none';
audioHint.textContent = '';
audioSelect.innerHTML = '';
$('audioControls').style.display = 'none';
$('audioHint').style.display = 'none';
$('audioHint').textContent = '';
$('audioSelect').innerHTML = '';
}
function setupAudioTrackSelector(videoEl) {
const audioControls = document.getElementById('audioControls');
const audioSelect = document.getElementById('audioSelect');
const audioHint = document.getElementById('audioHint');
// Not all browsers expose audioTracks (and MKV often won't)
const tracks = videoEl.audioTracks;
if (!tracks || tracks.length <= 1) {
if (!tracks) {
audioControls.style.display = 'none';
audioHint.style.display = 'block';
audioHint.textContent = 'Audio track switching not supported for this file/browser, open in VLC to change audio track if needed.';
$('audioHint').textContent = 'Audio track switching not supported for this browser.';
$('audioHint').style.display = 'block';
}
return;
}
if (tracks.length <= 1) {
audioControls.style.display = 'none';
audioHint.style.display = 'none';
return;
}
// Build dropdown
audioSelect.innerHTML = '';
$('audioSelect').innerHTML = '';
for (let i = 0; i < tracks.length; i++) {
const t = tracks[i];
const opt = document.createElement('option');
opt.value = String(i);
opt.value = i;
const label = tracks[i].label?.trim() || `Track ${i + 1}`;
opt.textContent = label;
$('audioSelect').appendChild(opt);
// label is often empty; language may exist depending on browser/container
const label = (t.label && t.label.trim()) ? t.label.trim() : null;
const lang = (t.language && t.language.trim()) ? t.language.trim() : null;
opt.textContent = label || (lang ? `Track ${i + 1} (${lang})` : `Track ${i + 1}`);
audioSelect.appendChild(opt);
// Set currently enabled track as selected
if (t.enabled) {
audioSelect.value = String(i);
}
if (tracks[i].enabled) opt.selected = true;
}
audioSelect.onchange = () => {
const idx = parseInt(audioSelect.value, 10);
$('audioSelect').onchange = () => {
const idx = parseInt($('audioSelect').value, 10);
for (let i = 0; i < tracks.length; i++) {
tracks[i].enabled = (i === idx);
}
};
audioHint.style.display = 'none';
audioControls.style.display = 'flex';
}
function playFile(path, name, sourceItemEl = null) {
const playerContainer = document.getElementById('playerContainer');
const nowPlaying = document.getElementById('nowPlaying');
const nowPlayingTitle = document.getElementById('nowPlayingTitle');
state.currentFile = { path, name };
resetAudioTrackUI();
const isVideo = state.currentType === 'videos';
const mediaElement = document.createElement(isVideo ? 'video' : 'audio');
mediaElement.controls = true;
mediaElement.autoplay = true;
// Keep track of which UI element launched playback (used for "next")
if (sourceItemEl) {
state.currentItemEl = sourceItemEl;
}
if (isVideo) {
// Some browsers only populate audioTracks after metadata is available
mediaElement.addEventListener('loadedmetadata', () => {
setupAudioTrackSelector(mediaElement);
});
}
// Add error handling
mediaElement.addEventListener('error', (e) => {
console.error('Media playback error:', {
error: mediaElement.error,
code: mediaElement.error?.code,
message: mediaElement.error?.message,
path: path,
name: name
});
playerContainer.innerHTML = `
<div class="player-placeholder">
<div class="player-placeholder-icon"></div>
<p style="color: #ff6b6b;">Error playing: ${name}</p>
<p style="color: var(--text-secondary); font-size: 0.8rem; margin-top: 0.5rem;">
${mediaElement.error?.message || 'Unknown error'}
</p>
</div>
`;
});
// Add loading state
mediaElement.addEventListener('loadstart', () => {
console.log('Loading media:', name);
});
mediaElement.addEventListener('canplay', () => {
console.log('Media ready to play:', name);
});
mediaElement.addEventListener('ended', () => {
// Auto-advance to next item when current finishes
playNextItem();
});
// URL encode the path properly
const encodedPath = encodeURIComponent(path);
mediaElement.src = `serve_media.php?file=${encodedPath}`;
console.log('Playing file:', {
name: name,
path: path,
encodedPath: encodedPath,
fullUrl: `serve_media.php?file=${encodedPath}`
});
playerContainer.innerHTML = '';
playerContainer.appendChild(mediaElement);
nowPlayingTitle.textContent = name;
nowPlaying.style.display = 'block';
$('audioHint').style.display = 'none';
$('audioControls').style.display = 'flex';
}

View file

@ -6,8 +6,11 @@ function auth_session_start(): void
{
//$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
// 7 days session persistence for stable media browsing
$sessionLifetime = 7 * 24 * 60 * 60; // 7 days in seconds
session_set_cookie_params([
'lifetime' => 0,
'lifetime' => $sessionLifetime,
'path' => '/',
'domain' => '',
'secure' => true,

35
build_media_cache.php Normal file
View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/media_cache_lib.php';
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "build_media_cache.php must run in CLI mode\n");
exit(1);
}
$resolveError = null;
$baseDir = fm_resolve_media_root($resolveError);
if ($baseDir === null) {
fwrite(STDERR, ($resolveError ?? 'MEDIA_ROOT base directory not found') . PHP_EOL);
exit(1);
}
$exitCode = 0;
foreach (['videos', 'music'] as $type) {
$start = microtime(true);
$rebuildError = null;
$tree = fm_rebuild_cache($type, $baseDir, true, $rebuildError);
$durationMs = (int)round((microtime(true) - $start) * 1000);
if (!is_array($tree)) {
fwrite(STDERR, sprintf('[%s] cache rebuild failed: %s', $type, $rebuildError ?? 'unknown error') . PHP_EOL);
$exitCode = 1;
continue;
}
fwrite(STDOUT, sprintf('[%s] cache rebuilt in %dms', $type, $durationMs) . PHP_EOL);
}
exit($exitCode);

View file

@ -5,124 +5,96 @@
declare(strict_types=1);
require_once __DIR__ . '/auth.php';
require_once __DIR__ . '/media_cache_lib.php';
require_auth(true);
header('Content-Type: application/json; charset=utf-8');
/**
* Emit JSON response with cache/timing observability headers.
*/
function fm_emit_response(array $payload, string $type, string $cacheStatus, float $startedAt, bool $forceRefresh, ?string $cacheGeneratedAt = null): void
{
$elapsedMs = (int)round((microtime(true) - $startedAt) * 1000);
header('X-Media-Type: ' . $type);
header('X-Media-Cache: ' . $cacheStatus);
header('X-Media-Time-Ms: ' . (string)$elapsedMs);
header('X-Media-Force-Refresh: ' . ($forceRefresh ? '1' : '0'));
if (is_string($cacheGeneratedAt) && $cacheGeneratedAt !== '') {
header('X-Media-Cache-Generated-At: ' . $cacheGeneratedAt);
}
echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
$startedAt = microtime(true);
// Media type from query parameter (default: videos)
$type = $_GET['type'] ?? 'videos';
// Resolve base directory via MEDIA_ROOT
$mediaRoot = getenv('MEDIA_ROOT');
if ($mediaRoot === false || trim($mediaRoot) === '') {
echo json_encode(['error' => 'MEDIA_ROOT is not set']);
exit;
if (!is_string($type)) {
$type = 'unknown';
}
$baseDir = realpath(trim($mediaRoot));
if ($baseDir === false || !is_dir($baseDir)) {
echo json_encode(['error' => 'MEDIA_ROOT base directory not found']);
exit;
$forceRefresh = isset($_GET['refresh']) && (string)$_GET['refresh'] === '1';
if (!is_string($type) || !fm_is_valid_type($type)) {
fm_emit_response(['error' => 'Invalid media type'], $type, 'invalid_type', $startedAt, $forceRefresh);
}
$directories = [
'videos' => $baseDir . DIRECTORY_SEPARATOR . 'Videos',
'music' => $baseDir . DIRECTORY_SEPARATOR . 'Music',
];
// Extensions per tab (dont mix)
$extensionsByType = [
'videos' => ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'],
'music' => ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'wma', 'opus'],
];
if (!isset($directories[$type])) {
echo json_encode(['error' => 'Invalid media type']);
exit;
$resolveError = null;
$baseDir = fm_resolve_media_root($resolveError);
if ($baseDir === null) {
fm_emit_response(['error' => $resolveError ?? 'MEDIA_ROOT base directory not found'], $type, 'error', $startedAt, $forceRefresh);
}
$targetDir = $directories[$type];
$validExtensions = $extensionsByType[$type];
// Fail fast if dir missing
if (!is_dir($targetDir)) {
echo json_encode(['error' => 'Directory not found']);
exit;
}
/**
* Recursively scan a directory and build a folder->(folders/files) structure.
* Files are returned as paths relative to $baseDir.
*/
function scanDirectory(string $dir, string $baseDir, array $validExtensions): array
{
$result = [];
// Resolve base once (prevents per-file realpath overhead and handles normalization)
$realBase = realpath($baseDir);
if ($realBase === false) {
return [];
}
$realBase = rtrim($realBase, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
// scandir() can return false on permissions
$items = @scandir($dir);
if ($items === false) return [];
foreach ($items as $item) {
// Skip dot/hidden entries
if ($item === '.' || $item === '..' || ($item !== '' && $item[0] === '.')) {
continue;
}
$fullPath = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($fullPath)) {
$sub = scanDirectory($fullPath, $baseDir, $validExtensions);
if (!empty($sub)) {
$result[$item] = $sub;
}
continue;
}
$ext = strtolower(pathinfo($item, PATHINFO_EXTENSION));
if (!in_array($ext, $validExtensions, true)) {
continue;
}
// Normalize and enforce: file must live under baseDir
$realFile = realpath($fullPath);
if ($realFile === false) {
continue;
}
if (strpos($realFile, $realBase) !== 0) {
continue;
}
// Return relative path from baseDir
$relativePath = substr($realFile, strlen($realBase));
$result[$item] = $relativePath;
}
// Sort: folders first, then files, alphabetically
uksort($result, function ($a, $b) use ($result) {
$aIsDir = is_array($result[$a]);
$bIsDir = is_array($result[$b]);
if ($aIsDir && !$bIsDir) return -1;
if (!$aIsDir && $bIsDir) return 1;
// IMPORTANT: keys can be ints if the filename is numeric (e.g. "01", "2026")
return strcasecmp((string)$a, (string)$b);
});
return $result;
}
$tree = scanDirectory($targetDir, $baseDir, $validExtensions);
echo json_encode(
if (!$forceRefresh) {
$cacheError = null;
$cached = fm_read_cache_payload($type, $baseDir, $cacheError);
if (is_array($cached)) {
$tree = $cached['tree'];
fm_emit_response(
empty($tree) ? ['message' => 'No media files found'] : $tree,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
$type,
'hit',
$startedAt,
$forceRefresh,
isset($cached['generated_at']) && is_string($cached['generated_at']) ? $cached['generated_at'] : null
);
}
}
$rebuildError = null;
$tree = fm_rebuild_cache($type, $baseDir, false, $rebuildError);
if (!is_array($tree)) {
$scanError = null;
$tree = fm_scan_media_tree($type, $baseDir, $scanError);
if (!is_array($tree)) {
fm_emit_response(
['error' => $scanError ?? $rebuildError ?? 'Failed to scan media directory'],
$type,
'error',
$startedAt,
$forceRefresh
);
}
fm_emit_response(
empty($tree) ? ['message' => 'No media files found'] : $tree,
$type,
'fallback_scan',
$startedAt,
$forceRefresh
);
}
fm_emit_response(
empty($tree) ? ['message' => 'No media files found'] : $tree,
$type,
$forceRefresh ? 'rebuild_forced' : 'rebuild',
$startedAt,
$forceRefresh
);

View file

@ -10,19 +10,35 @@
<body>
<div class="container">
<header>
<a href="index.php" aria-label="Go to home page">
<img width="250px" alt="freax penguin logo" src="freaxlogo.png"/>
</a>
<h1>Freax Web Media Player</h1>
<div class="subtitle">Your Digital Entertainment Hub, no bloat, always free, developed by MARKMENTAL</div>
</header>
<div class="main-layout">
<aside class="sidebar">
<div class="media-type-tabs">
<button class="tab-btn active" data-type="videos">Videos</button>
<button class="tab-btn" data-type="music">Music</button>
<div class="wizard-panel">
<div class="wizard-step" id="wizardStep">Step 1 - Choose Library</div>
<div class="wizard-section" id="libraryPicker">
<button class="tab-btn" data-library="videos">Videos</button>
<button class="tab-btn" data-library="music">Music</button>
</div>
<div class="wizard-section" id="browserStep" style="display:none;">
<div class="wizard-toolbar">
<button class="btn" id="backBtn" type="button">Back</button>
<button class="btn" id="rootBtn" type="button">Library Root</button>
<button class="btn" id="refreshBtn" type="button">Refresh</button>
</div>
<div class="wizard-library" id="currentLibraryLabel"></div>
<div class="wizard-path" id="breadcrumb">/</div>
</div>
</div>
<div class="file-browser" id="fileBrowser">
<div class="loading"></div>
<p style="color: var(--text-secondary); padding: 1rem;">Choose a library to browse files.</p>
</div>
</aside>
@ -81,4 +97,3 @@
<script src="assets/js/app.js"></script>
</body>
</html>

View file

@ -53,6 +53,11 @@ if (!$ok) {
// Success: mark session authed, rotate session id
session_regenerate_id(true);
// Re-apply cookie params to ensure extended lifetime is set
$sessionLifetime = 7 * 24 * 60 * 60;
setcookie(session_name(), session_id(), time() + $sessionLifetime, '/', '', false, true);
$_SESSION['authed'] = true;
$_SESSION['user'] = $username;

290
media_cache_lib.php Normal file
View file

@ -0,0 +1,290 @@
<?php
declare(strict_types=1);
function fm_extensions_by_type(): array
{
return [
'videos' => ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'],
'music' => ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'wma', 'opus'],
];
}
function fm_is_valid_type(string $type): bool
{
return array_key_exists($type, fm_extensions_by_type());
}
function fm_directories_for_base(string $baseDir): array
{
return [
'videos' => $baseDir . DIRECTORY_SEPARATOR . 'Videos',
'music' => $baseDir . DIRECTORY_SEPARATOR . 'Music',
];
}
function fm_resolve_media_root(?string &$error = null): ?string
{
$mediaRoot = getenv('MEDIA_ROOT');
if ($mediaRoot === false || trim($mediaRoot) === '') {
$error = 'MEDIA_ROOT is not set';
return null;
}
$baseDir = realpath(trim($mediaRoot));
if ($baseDir === false || !is_dir($baseDir)) {
$error = 'MEDIA_ROOT base directory not found';
return null;
}
return $baseDir;
}
function fm_resolve_cache_dir(): string
{
$configured = getenv('MEDIA_CACHE_DIR');
if ($configured === false || trim($configured) === '') {
return __DIR__ . DIRECTORY_SEPARATOR . 'var' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'media';
}
$configured = trim($configured);
if (strpos($configured, DIRECTORY_SEPARATOR) === 0) {
return rtrim($configured, DIRECTORY_SEPARATOR);
}
return rtrim(__DIR__ . DIRECTORY_SEPARATOR . $configured, DIRECTORY_SEPARATOR);
}
function fm_ensure_cache_dir(?string &$error = null): ?string
{
$cacheDir = fm_resolve_cache_dir();
if (is_dir($cacheDir)) {
return $cacheDir;
}
if (!@mkdir($cacheDir, 0775, true) && !is_dir($cacheDir)) {
$error = 'Failed to create cache directory';
return null;
}
return $cacheDir;
}
function fm_cache_paths(string $type, ?string &$error = null): ?array
{
if (!fm_is_valid_type($type)) {
$error = 'Invalid media type';
return null;
}
$cacheDir = fm_ensure_cache_dir($error);
if ($cacheDir === null) {
return null;
}
return [
'cache' => $cacheDir . DIRECTORY_SEPARATOR . $type . '.cache.json',
'lock' => $cacheDir . DIRECTORY_SEPARATOR . $type . '.cache.lock',
];
}
function fm_scan_directory_recursive(string $dir, string $realBase, array $validExtensions): array
{
$result = [];
$items = @scandir($dir);
if ($items === false) {
return [];
}
foreach ($items as $item) {
if ($item === '.' || $item === '..' || ($item !== '' && $item[0] === '.')) {
continue;
}
$fullPath = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($fullPath)) {
$sub = fm_scan_directory_recursive($fullPath, $realBase, $validExtensions);
if (!empty($sub)) {
$result[$item] = $sub;
}
continue;
}
$ext = strtolower(pathinfo($item, PATHINFO_EXTENSION));
if (!in_array($ext, $validExtensions, true)) {
continue;
}
$realFile = realpath($fullPath);
if ($realFile === false || strpos($realFile, $realBase) !== 0) {
continue;
}
$result[$item] = substr($realFile, strlen($realBase));
}
uksort($result, function ($a, $b) use ($result) {
$aIsDir = is_array($result[$a]);
$bIsDir = is_array($result[$b]);
if ($aIsDir && !$bIsDir) {
return -1;
}
if (!$aIsDir && $bIsDir) {
return 1;
}
return strcasecmp((string)$a, (string)$b);
});
return $result;
}
function fm_scan_media_tree(string $type, string $baseDir, ?string &$error = null): ?array
{
if (!fm_is_valid_type($type)) {
$error = 'Invalid media type';
return null;
}
$directories = fm_directories_for_base($baseDir);
$targetDir = $directories[$type];
if (!is_dir($targetDir)) {
$error = 'Directory not found';
return null;
}
$realBase = rtrim($baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$validExtensions = fm_extensions_by_type()[$type];
return fm_scan_directory_recursive($targetDir, $realBase, $validExtensions);
}
function fm_read_cache_payload(string $type, string $baseDir, ?string &$error = null): ?array
{
$paths = fm_cache_paths($type, $error);
if ($paths === null) {
return null;
}
if (!is_file($paths['cache']) || !is_readable($paths['cache'])) {
return null;
}
$json = @file_get_contents($paths['cache']);
if ($json === false) {
$error = 'Failed to read cache file';
return null;
}
$payload = json_decode($json, true);
if (!is_array($payload) || !isset($payload['tree']) || !is_array($payload['tree'])) {
$error = 'Invalid cache payload';
return null;
}
if (($payload['media_root'] ?? null) !== $baseDir) {
return null;
}
if (($payload['type'] ?? null) !== $type) {
return null;
}
return $payload;
}
function fm_write_cache_payload(string $type, array $payload, ?string &$error = null): bool
{
$paths = fm_cache_paths($type, $error);
if ($paths === null) {
return false;
}
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
$error = 'Failed to encode cache payload';
return false;
}
try {
$tmpSuffix = bin2hex(random_bytes(4));
} catch (Throwable $e) {
$tmpSuffix = uniqid('', true);
}
$tmpPath = $paths['cache'] . '.tmp.' . getmypid() . '.' . $tmpSuffix;
if (@file_put_contents($tmpPath, $json) === false) {
$error = 'Failed to write temporary cache file';
return false;
}
if (!@rename($tmpPath, $paths['cache'])) {
@unlink($tmpPath);
$error = 'Failed to finalize cache file';
return false;
}
return true;
}
function fm_with_type_lock(string $type, callable $callback, ?string &$error = null)
{
$paths = fm_cache_paths($type, $error);
if ($paths === null) {
return null;
}
$lockHandle = @fopen($paths['lock'], 'c');
if ($lockHandle === false) {
$error = 'Failed to open cache lock file';
return null;
}
if (!@flock($lockHandle, LOCK_EX)) {
@fclose($lockHandle);
$error = 'Failed to acquire cache lock';
return null;
}
try {
return $callback();
} finally {
@flock($lockHandle, LOCK_UN);
@fclose($lockHandle);
}
}
function fm_rebuild_cache(string $type, string $baseDir, bool $strictWrite, ?string &$error = null): ?array
{
return fm_with_type_lock($type, function () use ($type, $baseDir, $strictWrite, &$error) {
$scanError = null;
$tree = fm_scan_media_tree($type, $baseDir, $scanError);
if (!is_array($tree)) {
$error = $scanError ?? 'Failed to scan media directory';
return null;
}
$payload = [
'generated_at' => gmdate(DATE_ATOM),
'media_root' => $baseDir,
'type' => $type,
'tree' => $tree,
];
$writeError = null;
$written = fm_write_cache_payload($type, $payload, $writeError);
if (!$written && $strictWrite) {
$error = $writeError ?? 'Failed to write cache payload';
return null;
}
if (!$written) {
$error = $writeError;
}
return $tree;
}, $error);
}

View file

@ -6,6 +6,8 @@ USER=""
PASS=""
DOCROOT=""
MEDIA_ROOT_ARG=""
MEDIA_CACHE_DIR_ARG=""
MEDIA_CACHE_INTERVAL_ARG=""
read_pass_stdin=false
prompt=false
@ -14,7 +16,8 @@ usage() {
cat <<'EOF'
Usage:
start-media-server.sh --user <username> [--pass <password> | --pass-stdin | --prompt]
[--media-root <path>] [--port <port>] [--dir <docroot>]
[--media-root <path>] [--cache-dir <path>] [--cache-interval <seconds>]
[--port <port>] [--dir <docroot>]
Options:
--user <u> Username to set in MEDIA_USER (required)
@ -22,12 +25,15 @@ Options:
--pass-stdin Read password from stdin (safer)
--prompt Prompt for password (safer; hidden input)
--media-root <p> Base directory containing Videos/ and Music/ (recommended)
--cache-dir <p> Directory where media cache JSON and lock files are stored
--cache-interval <s>
Cache rebuild interval in seconds (default: 900)
--port <p> Listen port (default: 9000)
--dir <path> cd into docroot before starting (optional)
-h, --help Show help
Examples:
./start-media-server.sh --user admin --prompt --media-root /mnt/media --port 9000 --dir /home/me/samba-serv
./start-media-server.sh --user admin --prompt --media-root /mnt/media --cache-dir var/cache/media --port 9000 --dir /home/me/samba-serv
printf '%s\n' 'test123' | ./start-media-server.sh --user admin --pass-stdin --media-root /mnt/media
EOF
}
@ -41,7 +47,7 @@ pick_php_runner() {
fi
if have_cmd frankenphp; then
if frankenphp php-cli -v >/dev/null 2>&1; then
if frankenphp -v >/dev/null 2>&1; then
echo "frankenphp php-cli"
return 0
fi
@ -57,6 +63,8 @@ while (($#)); do
--pass-stdin) read_pass_stdin=true; shift ;;
--prompt) prompt=true; shift ;;
--media-root) MEDIA_ROOT_ARG="${2-}"; shift 2 ;;
--cache-dir) MEDIA_CACHE_DIR_ARG="${2-}"; shift 2 ;;
--cache-interval) MEDIA_CACHE_INTERVAL_ARG="${2-}"; shift 2 ;;
--port) PORT="${2-}"; shift 2 ;;
--dir) DOCROOT="${2-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
@ -97,6 +105,13 @@ if ! [[ "$PORT" =~ ^[0-9]+$ ]] || ((PORT < 1 || PORT > 65535)); then
exit 2
fi
if [[ -n "$MEDIA_CACHE_INTERVAL_ARG" ]]; then
if ! [[ "$MEDIA_CACHE_INTERVAL_ARG" =~ ^[0-9]+$ ]] || ((MEDIA_CACHE_INTERVAL_ARG < 1)); then
echo "Error: invalid --cache-interval '$MEDIA_CACHE_INTERVAL_ARG'" >&2
exit 2
fi
fi
if ! have_cmd frankenphp; then
echo "Error: frankenphp not found in PATH" >&2
exit 127
@ -120,6 +135,14 @@ if [[ -n "${MEDIA_ROOT_ARG}" ]]; then
export MEDIA_ROOT="${MEDIA_ROOT_ARG}"
fi
if [[ -n "${MEDIA_CACHE_DIR_ARG}" ]]; then
export MEDIA_CACHE_DIR="${MEDIA_CACHE_DIR_ARG}"
fi
if [[ -n "${MEDIA_CACHE_INTERVAL_ARG}" ]]; then
export MEDIA_CACHE_INTERVAL="${MEDIA_CACHE_INTERVAL_ARG}"
fi
if [[ -n "$DOCROOT" ]]; then
cd "$DOCROOT"
fi
@ -133,7 +156,49 @@ if [[ -n "${MEDIA_ROOT:-}" ]]; then
else
echo "MEDIA_ROOT not set (get_files.php / serve_media.php will error)"
fi
if [[ -n "${MEDIA_CACHE_DIR:-}" ]]; then
echo "MEDIA_CACHE_DIR=$MEDIA_CACHE_DIR"
else
echo "MEDIA_CACHE_DIR=$(pwd)/var/cache/media"
fi
echo "MEDIA_CACHE_INTERVAL=${MEDIA_CACHE_INTERVAL:-900}s"
echo
exec frankenphp php-server --listen ":$PORT"
run_cache_builder() {
if $PHP_RUNNER "$(pwd)/build_media_cache.php"; then
return 0
fi
echo "Warning: media cache build failed" >&2
return 1
}
cache_loop() {
local interval="${MEDIA_CACHE_INTERVAL:-900}"
while true; do
run_cache_builder || true
sleep "$interval"
done
}
cleanup() {
if [[ -n "${CACHE_LOOP_PID:-}" ]]; then
kill "${CACHE_LOOP_PID}" >/dev/null 2>&1 || true
wait "${CACHE_LOOP_PID}" >/dev/null 2>&1 || true
fi
if [[ -n "${SERVER_PID:-}" ]]; then
kill "${SERVER_PID}" >/dev/null 2>&1 || true
fi
}
run_cache_builder || true
cache_loop &
CACHE_LOOP_PID=$!
trap cleanup EXIT INT TERM
frankenphp php-server --listen ":$PORT" &
SERVER_PID=$!
wait "$SERVER_PID"