From 92362efd470aa74b8fd684c9a82273c1bdeb9d06 Mon Sep 17 00:00:00 2001 From: markmental Date: Mon, 2 Mar 2026 13:45:28 -0500 Subject: [PATCH] Code simplification, move to wizard style UI, JSON file cache (scans every 15 mins) --- .gitignore | 1 + README.md | 20 +- assets/css/style.css | 53 ++- assets/js/app.js | 786 +++++++++++++++++++++++++----------------- auth.php | 5 +- build_media_cache.php | 35 ++ get_files.php | 172 ++++----- index.php | 25 +- login.php | 5 + media_cache_lib.php | 290 ++++++++++++++++ start-media-server.sh | 73 +++- 11 files changed, 1027 insertions(+), 438 deletions(-) create mode 100644 build_media_cache.php create mode 100644 media_cache_lib.php diff --git a/.gitignore b/.gitignore index 4c49bd7..0e4891b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .env +var/cache/ diff --git a/README.md b/README.md index 0aa98bc..8dd14f4 100644 --- a/README.md +++ b/README.md @@ -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. - diff --git a/assets/css/style.css b/assets/css/style.css index 259337f..04726d0 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -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); diff --git a/assets/js/app.js b/assets/js/app.js index ab84b4a..453722f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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 = '

Choose a library to browse files.

'; + showLogin('Logged out.'); - // Clear UI - document.getElementById('fileBrowser').innerHTML = ''; - document.getElementById('playerContainer').innerHTML = ` + $('playerContainer').innerHTML = `
🎡

Select a file to start playing

`; - 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 = '
'; + 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}`); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - 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 resp = await authedFetch(url); + const fetchedAt = performance.now(); + + 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 data = await resp.json(); + const parsedAt = performance.now(); + if (data.error) { browser.innerHTML = `

${data.error}

`; - 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 = `

${data.message}

`; - 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 = `

Error loading files: ${error.message}

`; + logMediaDebug({ + type, + source: 'network', + url, + frontendFetchMs: performance.now() - startedAt, + error: error.message || String(error) + }); + + if (error.message !== 'Unauthorized') { + browser.innerHTML = '

Error loading files

'; + } + 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' }); - } - - state.currentItemEl = fileItemEl || null; +function getNodeAtPath(type, pathParts) { + let node = state.filesByType[type] || {}; + for (const part of pathParts) { + if (!node || typeof node !== 'object' || Array.isArray(node)) { + return {}; + } + 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(); - // 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; + browser.innerHTML = ''; - 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 (!node || typeof node !== 'object' || Array.isArray(node)) { + browser.innerHTML = '

No entries found.

'; + return; + } - // 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; + const entries = Object.entries(node); + if (entries.length === 0) { + browser.innerHTML = '

No media files found.

'; + return; + } - pNext = pNext.nextElementSibling; - } + for (const [name, content] of entries) { + const item = document.createElement('div'); + item.className = 'file-item'; - parent = parent.parentElement; - } + if (typeof content === 'object' && !Array.isArray(content)) { + item.classList.add('folder-entry'); + item.innerHTML = `πŸ“${name}`; + 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 = `${state.currentType === 'videos' ? '🎬' : '🎡'}${name}`; - // No next item found - return null; + if (content === state.selectedFilePath) { + item.classList.add('active'); + } + + 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 = ` +
+
⚠️
+

Error playing: ${name}

+
+ `; + }); + + 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 - next.click(); -} + const currentIdx = files.findIndex(el => el.classList.contains('active')); + if (currentIdx < 0) return; - -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 = `β–Ά${name}`; - - 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 = `${icon}${name}`; - - fileItem.dataset.path = content; - fileItem.dataset.name = name; - - fileItem.addEventListener('click', () => { - // Play and mark active - playFile(content, name, fileItem); - setActiveFileItem(fileItem); - }); - - - container.appendChild(fileItem); - } + const next = files[currentIdx + 1]; + if (next) { + next.click(); } } 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) { - 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.'; - return; - } - - if (tracks.length <= 1) { - audioControls.style.display = 'none'; - audioHint.style.display = 'none'; - return; - } - - // Build dropdown - audioSelect.innerHTML = ''; - for (let i = 0; i < tracks.length; i++) { - const t = tracks[i]; - const opt = document.createElement('option'); - opt.value = String(i); - - // 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 || tracks.length <= 1) { + if (!tracks) { + $('audioHint').textContent = 'Audio track switching not supported for this browser.'; + $('audioHint').style.display = 'block'; } + return; } - audioSelect.onchange = () => { - const idx = parseInt(audioSelect.value, 10); + $('audioSelect').innerHTML = ''; + for (let i = 0; i < tracks.length; i++) { + const opt = document.createElement('option'); + opt.value = i; + const label = tracks[i].label?.trim() || `Track ${i + 1}`; + opt.textContent = label; + $('audioSelect').appendChild(opt); + + if (tracks[i].enabled) opt.selected = true; + } + + $('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'; + $('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 = ` -
-
⚠️
-

Error playing: ${name}

-

- ${mediaElement.error?.message || 'Unknown error'} -

-
- `; - }); - - // 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'; -} \ No newline at end of file diff --git a/auth.php b/auth.php index a0e73a1..c101223 100644 --- a/auth.php +++ b/auth.php @@ -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, diff --git a/build_media_cache.php b/build_media_cache.php new file mode 100644 index 0000000..2e1c984 --- /dev/null +++ b/build_media_cache.php @@ -0,0 +1,35 @@ + '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 (don’t 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 []; +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, + $type, + 'hit', + $startedAt, + $forceRefresh, + isset($cached['generated_at']) && is_string($cached['generated_at']) ? $cached['generated_at'] : null + ); } - $realBase = rtrim($realBase, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; +} - // scandir() can return false on permissions - $items = @scandir($dir); - if ($items === false) return []; +$rebuildError = null; +$tree = fm_rebuild_cache($type, $baseDir, false, $rebuildError); - 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; +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 + ); } - // 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; + fm_emit_response( + empty($tree) ? ['message' => 'No media files found'] : $tree, + $type, + 'fallback_scan', + $startedAt, + $forceRefresh + ); } -$tree = scanDirectory($targetDir, $baseDir, $validExtensions); - -echo json_encode( +fm_emit_response( empty($tree) ? ['message' => 'No media files found'] : $tree, - JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + $type, + $forceRefresh ? 'rebuild_forced' : 'rebuild', + $startedAt, + $forceRefresh ); - diff --git a/index.php b/index.php index 1c6abdf..fe66437 100644 --- a/index.php +++ b/index.php @@ -10,19 +10,35 @@
+ freax penguin logo +

Freax Web Media Player

Your Digital Entertainment Hub, no bloat, always free, developed by MARKMENTAL
@@ -81,4 +97,3 @@ - diff --git a/login.php b/login.php index a366a9b..4bde7a3 100644 --- a/login.php +++ b/login.php @@ -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; diff --git a/media_cache_lib.php b/media_cache_lib.php new file mode 100644 index 0000000..958d2fb --- /dev/null +++ b/media_cache_lib.php @@ -0,0 +1,290 @@ + ['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); +} diff --git a/start-media-server.sh b/start-media-server.sh index 651ee43..2f5d1ad 100755 --- a/start-media-server.sh +++ b/start-media-server.sh @@ -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 [--pass | --pass-stdin | --prompt] - [--media-root ] [--port ] [--dir ] + [--media-root ] [--cache-dir ] [--cache-interval ] + [--port ] [--dir ] Options: --user 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

Base directory containing Videos/ and Music/ (recommended) + --cache-dir

Directory where media cache JSON and lock files are stored + --cache-interval + Cache rebuild interval in seconds (default: 900) --port

Listen port (default: 9000) --dir 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"