diff --git a/.gitignore b/.gitignore index f8b40e2..2085616 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ Thumbs.db # Claude .claude/ + +# User data +data/ diff --git a/Dockerfile b/Dockerfile index 58f457f..e89eceb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM node:20-alpine WORKDIR /app COPY package.json package-lock.json ./ -RUN npm ci --omit=dev +RUN npm install --omit=dev COPY server.js . COPY public/ public/ EXPOSE 3000 diff --git a/package.json b/package.json index 378e964..82a272d 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,11 @@ "start": "node server.js" }, "dependencies": { + "bcryptjs": "^2.4.3", "express": "^4.18.2", + "express-session": "^1.17.3", + "mime-types": "^2.1.35", "music-metadata": "^10.2.1", - "mime-types": "^2.1.35" + "session-file-store": "^1.5.0" } } diff --git a/public/app.js b/public/app.js index bc6064c..7a84e7c 100644 --- a/public/app.js +++ b/public/app.js @@ -168,6 +168,7 @@ const Player = (() => { let repeatMode = 'off'; // 'off' | 'queue' | 'one' let isSeeking = false; let currentPath = null; + let _pendingRestorePosition = 0; // position to seek to on first play after restore // ── Persistence ── const STORAGE_KEY = 'snap_state'; @@ -312,7 +313,22 @@ const Player = (() => { // ── Controls ── function playPause() { if (med.paused) { - med.play().then(() => { syncPlayIcon(true); WakeLock.acquire(); }).catch(() => {}); + if (!med.src && currentPath) { + // First play after page restore — load the track then seek to saved position + const pos = _pendingRestorePosition; + _pendingRestorePosition = 0; + loadTrack(currentPath, false).then(() => { + const doPlay = () => med.play().then(() => { syncPlayIcon(true); WakeLock.acquire(); }).catch(() => {}); + if (pos > 0) { + if (med.readyState >= 1) { med.currentTime = pos; syncSeek(); doPlay(); } + else med.addEventListener('loadedmetadata', () => { med.currentTime = pos; syncSeek(); doPlay(); }, { once: true }); + } else { + doPlay(); + } + }); + } else { + med.play().then(() => { syncPlayIcon(true); WakeLock.acquire(); }).catch(() => {}); + } } else { med.pause(); syncPlayIcon(false); @@ -594,18 +610,10 @@ const Player = (() => { syncShuffleIcon(); syncRepeatIcon(); - const savedPos = s.position || 0; + _pendingRestorePosition = s.position || 0; const restoreIsVideo = isVideoPath(currentPath); if (restoreIsVideo !== currentIsVideo) setMediaMode(restoreIsVideo); - med.src = `/api/stream?path=${enc(currentPath)}`; - med.load(); - - if (savedPos > 0) { - med.addEventListener('loadedmetadata', () => { - med.currentTime = savedPos; - syncSeek(); - }, { once: true }); - } + // Don't load media src here — deferred to first play to avoid browser "Continue playing" prompt if (!currentIsVideo) syncArt(currentPath); @@ -752,8 +760,62 @@ const Player = (() => { paused, isActive, getCurrentPath, getQueue, getQueueIndex, startQueue, addToEnd, addAfterCurrent, jumpTo, playPause, playNext, playPrev, restore, removeFromQueue, reorderQueue, - isCurrentVideo: () => currentIsVideo, + isCurrentVideo: () => currentIsVideo, + getDuration: () => isFinite(med.duration) ? med.duration : 0, + getPosition: () => isFinite(med.currentTime) ? med.currentTime : 0, + syncPlayState: (playing) => syncPlayIcon(playing), + seekTo: (pos) => { + if (isFinite(med.duration)) med.currentTime = Math.max(0, Math.min(med.duration, pos)); + else med.currentTime = Math.max(0, pos); + }, skip: (secs) => { med.currentTime = Math.max(0, Math.min(isFinite(med.duration) ? med.duration : Infinity, med.currentTime + secs)); }, + syncQueue(paths, idx) { + if (!Array.isArray(paths) || !paths.length) return; + queue = [...paths]; + originalQueue = null; + queueIndex = typeof idx === 'number' ? Math.max(0, Math.min(idx, paths.length - 1)) : 0; + const path = queue[queueIndex]; + if (path === currentPath) return; + currentPath = path; + lastGoodPosition = 0; + + // Non-active device: show artwork display only — never load video or audio stream + if (currentIsVideo) setMediaMode(false); // switch away from video mode to show art + syncArt(path); + + const name = path.split('/').pop().replace(/\.[^.]+$/, ''); + fsTitle.textContent = name; + fsArtist.textContent = ''; + fetch(`/api/metadata?path=${enc(path)}`) + .then(r => r.json()) + .then(meta => { + fsTitle.textContent = meta.title || name; + fsArtist.textContent = meta.artist || ''; + MediaSessionManager.update({ title: meta.title || name, artist: meta.artist || '', artworkPath: path }); + }) + .catch(() => { + MediaSessionManager.update({ title: name, artist: '', artworkPath: path }); + }); + + FileBrowser.setPlaying(path); + QueuePanel.refresh(); + playerBar.hidden = false; + document.body.classList.remove('player-hidden'); + }, + syncPositionDisplay(pos, totalDur) { + if (!isFinite(pos) || pos < 0) return; + const dur = (totalDur > 0 && isFinite(totalDur)) ? totalDur : (isFinite(med.duration) ? med.duration : 0); + const pct = dur > 0 ? (pos / dur) * 100 : 0; + if (!isSeeking) [seekBar, fsSeekBar].forEach(s => { s.value = pct; }); + const curStr = formatTime(pos); + timeCurrent.textContent = curStr; + fsTimeCurrent.textContent = curStr; + if (dur > 0) { + const totStr = formatTime(dur); + timeTotal.textContent = totStr; + fsTimeTotal.textContent = totStr; + } + }, }; })(); @@ -807,7 +869,9 @@ const QueueModal = (() => { btnCancel.addEventListener('click', hide); overlay.addEventListener('click', e => { if (e.target === overlay) hide(); }); - return { show }; + function getPendingPaths() { return [...pendingPaths]; } + + return { show, getPendingPaths }; })(); // ── Queue Panel ─────────────────────────────────────────────────────────────── @@ -1249,9 +1313,11 @@ const FileBrowser = (() => { let data; try { const res = await fetch(`/api/browse?path=${enc(currentPath)}`); + if (res.status === 401) return; // login overlay will appear via fetch interceptor if (!res.ok) throw new Error(res.statusText); data = await res.json(); } catch (e) { + if (!document.getElementById('login-overlay').hidden) return; browserEl.innerHTML = `
Error: ${e.message}
`; return; } @@ -1516,7 +1582,9 @@ const FileBrowser = (() => { function reload() { load(); } - return { navigate, setPlaying, refresh, reload }; + function getSelectedPaths() { return [...selectedPaths]; } + + return { navigate, setPlaying, refresh, reload, getSelectedPaths }; })(); // Keep --player-h in sync with the actual rendered player bar height @@ -1525,8 +1593,7 @@ new ResizeObserver(entries => { if (h > 0) document.documentElement.style.setProperty('--player-h', h + 'px'); }).observe(document.getElementById('player-bar')); -// Restore queue + position after all modules are initialised -Player.restore(); +// Player.restore() is called in onReady() after auth if (!Player.isActive()) document.body.classList.add('player-hidden'); // Keep the phone's WiFi radio alive via server-sent WebSocket pings. @@ -1624,3 +1691,920 @@ window.addEventListener('popstate', e => { FileBrowser.navigate(state.path, false); } }); + +// ── Auth ────────────────────────────────────────────────────────────────────── + +const Auth = (() => { + const overlay = document.getElementById('login-overlay'); + const subtitle = document.getElementById('login-subtitle'); + const userInput = document.getElementById('login-username'); + const passInput = document.getElementById('login-password'); + const passConfirmField = document.getElementById('login-confirm-field'); + const passConfirm = document.getElementById('login-password2'); + const submitBtn = document.getElementById('login-submit'); + const errorEl = document.getElementById('login-error'); + + let _user = null; + let _isSetup = false; + + function currentUser() { return _user; } + + function showOverlay(isSetup) { + _isSetup = isSetup; + subtitle.textContent = isSetup ? 'Create your admin account to get started.' : ''; + submitBtn.textContent = isSetup ? 'Create account' : 'Sign in'; + passConfirmField.hidden = !isSetup; + passConfirm.value = ''; + errorEl.textContent = ''; + overlay.hidden = false; + } + + function hideOverlay() { overlay.hidden = true; } + + async function submit() { + const username = userInput.value.trim(); + const password = passInput.value; + errorEl.textContent = ''; + if (!username || !password) { errorEl.textContent = 'Enter username and password.'; return; } + if (_isSetup && password !== passConfirm.value) { errorEl.textContent = 'Passwords do not match.'; return; } + submitBtn.disabled = true; + try { + const endpoint = _isSetup ? '/api/auth/setup' : '/api/auth/login'; + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json(); + if (!res.ok) { errorEl.textContent = data.error || 'Login failed.'; return; } + _user = data; + hideOverlay(); + onReady(); + } catch (e) { + errorEl.textContent = 'Network error.'; + } finally { + submitBtn.disabled = false; + } + } + + submitBtn.addEventListener('click', submit); + [userInput, passInput, passConfirm].forEach(el => { + el.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); }); + }); + + async function logout() { + await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {}); + location.reload(); + } + + async function init() { + try { + const setupRes = await fetch('/api/auth/setup-check'); + const { needsSetup } = await setupRes.json(); + if (needsSetup) { showOverlay(true); return; } + + const meRes = await fetch('/api/auth/me'); + if (meRes.ok) { + _user = await meRes.json(); + hideOverlay(); + onReady(); + } else { + showOverlay(false); + } + } catch { + showOverlay(false); + } + } + + // Intercept 401s globally + const _origFetch = window.fetch; + window.fetch = async function(...args) { + const res = await _origFetch(...args); + if (res.status === 401) { + const url = typeof args[0] === 'string' ? args[0] : args[0].url || ''; + if (!url.includes('/api/auth/')) { + _user = null; + showOverlay(false); + } + } + return res; + }; + + return { init, logout, currentUser }; +})(); + +// ── User menu ───────────────────────────────────────────────────────────────── + +const UserMenu = (() => { + const btn = document.getElementById('user-menu-btn'); + const label = document.getElementById('user-menu-label'); + const dropdown = document.getElementById('user-menu-dropdown'); + const adminBtn = document.getElementById('user-menu-admin'); + const logoutBtn= document.getElementById('user-menu-logout'); + + function setup(user) { + label.textContent = user.username; + adminBtn.hidden = user.role !== 'admin'; + } + + btn.addEventListener('click', e => { + e.stopPropagation(); + dropdown.hidden = !dropdown.hidden; + }); + + document.addEventListener('click', () => { dropdown.hidden = true; }); + dropdown.addEventListener('click', e => e.stopPropagation()); + + adminBtn.addEventListener('click', () => { dropdown.hidden = true; AdminPanel.open(); }); + logoutBtn.addEventListener('click', () => { Auth.logout(); }); + + return { setup }; +})(); + +// ── SyncManager ─────────────────────────────────────────────────────────────── + +const SyncManager = (() => { + const takeoverBanner = document.getElementById('takeover-banner'); + const takeoverBtn = document.getElementById('takeover-btn'); + const takeoverDismiss = document.getElementById('takeover-dismiss'); + const fsTakeoverBtn = document.getElementById('fs-btn-takeover'); + const fsTakeoverRow = document.getElementById('fs-takeover-row'); + + let myDeviceId = localStorage.getItem('snap_device_id'); + if (!myDeviceId) { + myDeviceId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0; + return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); + }); + localStorage.setItem('snap_device_id', myDeviceId); + } + + let pollTimer = null; + let serverState = null; + let takeoverShown = false; + + function isActiveDevice() { + return !serverState?.activeDeviceId || serverState.activeDeviceId === myDeviceId; + } + + function hasActiveDevice() { + if (!serverState?.activeDeviceId || serverState.activeDeviceId === myDeviceId) return false; + return (Date.now() - (serverState.activeDeviceAt || 0)) < 5 * 60 * 1000; + } + + function updateTakeoverBtn() { + if (fsTakeoverRow) fsTakeoverRow.hidden = isActiveDevice(); + } + + function getPlayerState() { + return { + queue: Player.getQueue(), + index: Player.getQueueIndex(), + position: Player.getPosition(), + duration: Player.getDuration(), + playing: !Player.paused(), + sortKey: localStorage.getItem('snap_sort_key') || 'name', + sortDir: localStorage.getItem('snap_sort_dir') || 'asc', + deviceId: myDeviceId, + }; + } + + async function push(state) { + try { + await fetch('/api/state', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(state || getPlayerState()), + }); + } catch {} + } + + async function sendCommand(type, data) { + try { + await fetch('/api/command', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, data }), + }); + } catch {} + } + + function executeCommand(cmd) { + if (!cmd?.type) return; + if (Date.now() - (cmd.sentAt || 0) > 10000) return; // stale, ignore + switch (cmd.type) { + case 'playpause': Player.playPause(); break; + case 'next': Player.playNext(); break; + case 'prev': Player.playPrev(); break; + case 'skip': Player.skip(cmd.data?.seconds || 0); break; + case 'seek': { + if (cmd.data?.seekTo != null) Player.seekTo(cmd.data.seekTo); + break; + } + case 'loadqueue': { + if (Array.isArray(cmd.data?.queue) && cmd.data.queue.length) { + Player.startQueue(cmd.data.queue, cmd.data.index ?? 0); + } + break; + } + } + } + + async function pollAndSync() { + let res; + try { res = await fetch('/api/state'); } catch { return; } + if (!res?.ok) return; + const state = await res.json(); + const prevActiveId = serverState?.activeDeviceId; + serverState = state; + updateTakeoverBtn(); + + if (isActiveDevice()) { + if (state.pendingCommand) { + // Execute command then always push to clear pendingCommand from server + executeCommand(state.pendingCommand); + setTimeout(() => push(), 200); + } else if (!Player.paused()) { + // Periodic position push so non-active devices stay in sync + push(); + } + } else { + // Sync queue/track display from active device (no auto-play) + if (state.queue?.length > 0) { + const newPath = state.queue[state.index ?? 0]; + if (newPath && newPath !== Player.getCurrentPath()) { + Player.syncQueue(state.queue, state.index); + } + } + // Sync playback position and play/pause icon + if (typeof state.position === 'number') { + Player.syncPositionDisplay(state.position, state.duration); + } + if (typeof state.playing === 'boolean') { + Player.syncPlayState(state.playing); + } + // If we just lost active status (another device took over), pause local audio + if (prevActiveId === myDeviceId && !Player.paused()) { + Player.playPause(); + } + } + } + + function startPolling() { + if (pollTimer) return; + pollTimer = setInterval(pollAndSync, 1000); + } + + function stopPolling() { + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } + } + + // ── Takeover button handlers ────────────────────────────────────────────────── + function takeoverHere() { + if (!serverState) return; + // Claim active status before calling loadState so startQueue isn't redirected back + const snap = { ...serverState }; + serverState = { ...serverState, activeDeviceId: myDeviceId }; + updateTakeoverBtn(); + takeoverBanner.hidden = true; + if (fsTakeoverRow) fsTakeoverRow.hidden = true; + Player.loadState({ queue: snap.queue, index: snap.index, position: snap.position }); + push(); // notify server + } + + function showTakeover() { + if (!takeoverShown) { + takeoverShown = true; + takeoverBanner.hidden = false; + } + } + + takeoverBtn.addEventListener('click', () => { takeoverBanner.hidden = true; takeoverHere(); }); + takeoverDismiss.addEventListener('click', () => { takeoverBanner.hidden = true; }); + if (fsTakeoverBtn) fsTakeoverBtn.addEventListener('click', takeoverHere); + + // ── Transport interception for non-active device ────────────────────────────── + // Capturing listeners fire before Player's bubble listeners on the same element. + function makeTransportGuard(type, data) { + return function(e) { + if (hasActiveDevice()) { + e.stopImmediatePropagation(); + sendCommand(type, data); + } + }; + } + ['btn-prev', 'fs-btn-prev'].forEach(id => { + document.getElementById(id)?.addEventListener('click', makeTransportGuard('prev'), true); + }); + ['btn-next', 'fs-btn-next'].forEach(id => { + document.getElementById(id)?.addEventListener('click', makeTransportGuard('next'), true); + }); + ['btn-play', 'fs-btn-play'].forEach(id => { + document.getElementById(id)?.addEventListener('click', makeTransportGuard('playpause'), true); + }); + document.getElementById('fs-btn-skip-back')?.addEventListener('click', makeTransportGuard('skip', { seconds: -30 }), true); + document.getElementById('fs-btn-skip-fwd')?.addEventListener('click', makeTransportGuard('skip', { seconds: 30 }), true); + + // ── Seek bar interception for non-active device ─────────────────────────────── + ['seek-bar', 'fs-seek-bar'].forEach(id => { + document.getElementById(id)?.addEventListener('change', e => { + if (!hasActiveDevice()) return; + const dur = serverState?.duration || 0; + if (dur > 0) sendCommand('seek', { seekTo: (parseFloat(e.target.value) / 100) * dur }); + }); + }); + + // ── Wire into audio events ──────────────────────────────────────────────────── + document.getElementById('audio').addEventListener('play', () => { startPolling(); push(); }); + document.getElementById('audio').addEventListener('pause', () => { push(); }); + + // ── Init ───────────────────────────────────────────────────────────────────── + async function init() { + let res; + try { res = await fetch('/api/state'); } catch { return; } + if (!res?.ok) return; + serverState = await res.json(); + updateTakeoverBtn(); + + if (!serverState?.queue?.length || !serverState.activeDeviceAt) { startPolling(); return; } + + const age = Date.now() - serverState.activeDeviceAt; + if (age > 30 * 60 * 1000) { startPolling(); return; } + + // Silently load queue/track on all devices + Player.syncQueue(serverState.queue, serverState.index); + + if (serverState.activeDeviceId !== myDeviceId) { + // Another device is active — show takeover banner + if (!takeoverShown) { + takeoverShown = true; + takeoverBanner.hidden = false; + } + } + startPolling(); + } + + return { init, push, sendCommand, isActiveDevice, hasActiveDevice, showTakeover, myDeviceId }; +})(); + +// ── Playlists ───────────────────────────────────────────────────────────────── + +const Playlists = (() => { + const panel = document.getElementById('playlists-panel'); + const listEl = document.getElementById('playlist-list'); + const closeBtn = document.getElementById('playlists-close'); + const newBtn = document.getElementById('new-playlist-btn'); + const topbarBtn = document.getElementById('btn-playlists'); + const fsBtnPl = document.getElementById('fs-btn-playlists'); + const addModal = document.getElementById('add-playlist-modal'); + const addListEl = document.getElementById('add-playlist-list'); + const apmNew = document.getElementById('apm-new'); + const apmCancel = document.getElementById('apm-cancel'); + + let _playlists = []; + let _addCallback = null; // { paths } pending add + + async function load() { + try { + const res = await fetch('/api/playlists'); + if (!res.ok) return; + _playlists = await res.json(); + } catch {} + } + + function open() { + panel.hidden = false; + load().then(render); + } + + function close() { panel.hidden = true; } + + function render() { + listEl.innerHTML = ''; + if (_playlists.length === 0) { + listEl.innerHTML = '
No playlists yet
'; + return; + } + for (const pl of _playlists) { + const item = document.createElement('div'); + item.className = 'playlist-item'; + + const nameEl = document.createElement('span'); + nameEl.className = 'playlist-item-name'; + nameEl.textContent = pl.name; + + const countEl = document.createElement('span'); + countEl.className = 'playlist-item-count'; + countEl.textContent = `${pl.tracks.length} track${pl.tracks.length !== 1 ? 's' : ''}`; + + const actions = document.createElement('span'); + actions.className = 'playlist-item-actions'; + + const playBtn = document.createElement('button'); + playBtn.className = 'admin-btn'; + playBtn.textContent = 'Play'; + playBtn.addEventListener('click', e => { e.stopPropagation(); playPlaylist(pl); }); + + const delBtn = document.createElement('button'); + delBtn.className = 'admin-btn danger'; + delBtn.textContent = 'Delete'; + delBtn.addEventListener('click', e => { e.stopPropagation(); deletePlaylist(pl.id); }); + + actions.append(playBtn, delBtn); + item.append(nameEl, countEl, actions); + + // Expand/collapse tracks + let expanded = false; + let tracksEl = null; + item.addEventListener('click', () => { + expanded = !expanded; + if (expanded) { + tracksEl = document.createElement('div'); + tracksEl.className = 'playlist-tracks'; + if (pl.tracks.length === 0) { + tracksEl.innerHTML = 'Empty playlist'; + } + for (const t of pl.tracks) { + const tr = document.createElement('div'); + tr.className = 'playlist-track'; + tr.textContent = (t.name || t.path.split('/').pop().replace(/\.[^.]+$/, '')); + tr.addEventListener('click', e => { e.stopPropagation(); Player.startQueue([t.path], 0); close(); }); + tracksEl.appendChild(tr); + } + item.after(tracksEl); + } else { + if (tracksEl) { tracksEl.remove(); tracksEl = null; } + } + }); + + listEl.appendChild(item); + } + } + + function playPlaylist(pl) { + if (pl.tracks.length === 0) return; + const paths = pl.tracks.map(t => t.path); + Player.startQueue(paths, 0); + close(); + } + + async function deletePlaylist(id) { + try { + await fetch(`/api/playlists/${id}`, { method: 'DELETE' }); + await load(); + render(); + } catch {} + } + + async function createPlaylist(name) { + try { + const res = await fetch('/api/playlists', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + if (!res.ok) return null; + const pl = await res.json(); + await load(); + render(); + return pl; + } catch { return null; } + } + + newBtn.addEventListener('click', async () => { + const name = prompt('Playlist name:'); + if (!name || !name.trim()) return; + await createPlaylist(name.trim()); + }); + + // Add-to-playlist modal + async function showAddModal(paths) { + _addCallback = paths; + await load(); + addListEl.innerHTML = ''; + if (_playlists.length === 0) { + addListEl.innerHTML = '
No playlists yet — create one below.
'; + } + for (const pl of _playlists) { + const btn = document.createElement('button'); + btn.className = 'modal-btn'; + btn.textContent = `${pl.name} (${pl.tracks.length} tracks)`; + btn.addEventListener('click', () => { addToPlaylist(pl, paths); addModal.hidden = true; }); + addListEl.appendChild(btn); + } + addModal.hidden = false; + } + + async function addToPlaylist(pl, paths) { + const newTracks = paths.map(p => ({ path: p, name: p.split('/').pop().replace(/\.[^.]+$/, '') })); + const tracks = [...pl.tracks, ...newTracks]; + try { + await fetch(`/api/playlists/${pl.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tracks }), + }); + } catch {} + } + + apmNew.addEventListener('click', async () => { + addModal.hidden = true; + const name = prompt('Playlist name:'); + if (!name || !name.trim()) return; + const pl = await createPlaylist(name.trim()); + if (pl && _addCallback) await addToPlaylist(pl, _addCallback); + _addCallback = null; + }); + apmCancel.addEventListener('click', () => { addModal.hidden = true; _addCallback = null; }); + addModal.addEventListener('click', e => { if (e.target === addModal) { addModal.hidden = true; _addCallback = null; } }); + + closeBtn.addEventListener('click', close); + topbarBtn.addEventListener('click', () => { panel.hidden ? open() : close(); }); + fsBtnPl.addEventListener('click', () => { FullscreenPlayer.close(); open(); }); + + return { open, close, showAddModal, createPlaylist }; +})(); + +// ── Admin Panel ─────────────────────────────────────────────────────────────── + +const AdminPanel = (() => { + const panel = document.getElementById('admin-panel'); + const closeBtn = document.getElementById('admin-close'); + const userListEl = document.getElementById('admin-user-list'); + const addUserBtn = document.getElementById('admin-add-user-btn'); + const addForm = document.getElementById('admin-add-user-form'); + const editForm = document.getElementById('admin-edit-user-form'); + const aeufSelect = document.getElementById('aeuf-user-select'); + + // Add form fields + const aaufUser = document.getElementById('aauf-username'); + const aaufPass = document.getElementById('aauf-password'); + const aaufPass2 = document.getElementById('aauf-password2'); + const aaufRole = document.getElementById('aauf-role'); + const aaufFolders = document.getElementById('aauf-folders'); + const aaufErr = document.getElementById('aauf-error'); + const aaufCancel = document.getElementById('aauf-cancel'); + const aaufSubmit = document.getElementById('aauf-submit'); + + // Edit form fields + const aeufPass = document.getElementById('aeuf-password'); + const aeufPass2 = document.getElementById('aeuf-password2'); + const aeufRole = document.getElementById('aeuf-role'); + const aeufFolders = document.getElementById('aeuf-folders'); + const aeufErr = document.getElementById('aeuf-error'); + const aeufCancel = document.getElementById('aeuf-cancel'); + const aeufSubmit = document.getElementById('aeuf-submit'); + + let _users = []; + let _editUserId = null; + + // ── Folder tree ────────────────────────────────────────────────────────────── + + function buildTreeNode(name, dirPath, checked) { + const node = document.createElement('div'); + node.className = 'ftree-node'; + + const row = document.createElement('div'); + row.className = 'ftree-row'; + + const expander = document.createElement('button'); + expander.type = 'button'; + expander.className = 'ftree-expand'; + expander.textContent = '▶'; + + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.value = dirPath; + cb.checked = checked.some(c => c === dirPath || dirPath.startsWith(c + '/')); + + const lbl = document.createElement('label'); + lbl.className = 'ftree-label'; + lbl.append(cb, document.createTextNode(' ' + name)); + + row.append(expander, lbl); + node.appendChild(row); + + const childWrap = document.createElement('div'); + childWrap.className = 'ftree-children'; + childWrap.hidden = true; + node.appendChild(childWrap); + + let loaded = false; + expander.addEventListener('click', async () => { + if (!loaded) { + childWrap.innerHTML = '
Loading…
'; + childWrap.hidden = false; + expander.textContent = '▼'; + try { + const res = await fetch(`/api/browse?path=${encodeURIComponent(dirPath)}`); + const data = await res.json(); + const subdirs = (data.items || []).filter(i => i.type === 'dir'); + childWrap.innerHTML = ''; + if (subdirs.length === 0) { + expander.style.visibility = 'hidden'; + } else { + for (const sub of subdirs) { + childWrap.appendChild(buildTreeNode(sub.name, sub.path, checked)); + } + } + } catch { + childWrap.innerHTML = '
Error loading
'; + } + loaded = true; + } else { + childWrap.hidden = !childWrap.hidden; + expander.textContent = childWrap.hidden ? '▶' : '▼'; + } + }); + + return node; + } + + async function renderFolderTree(container, checked) { + container.innerHTML = '
Loading folders…
'; + try { + const res = await fetch('/api/browse?path='); + if (!res.ok) throw new Error(); + const data = await res.json(); + const dirs = (data.items || []).filter(i => i.type === 'dir'); + container.innerHTML = ''; + if (dirs.length === 0) { + container.innerHTML = 'No folders found in media root'; + return; + } + for (const dir of dirs) { + container.appendChild(buildTreeNode(dir.name, dir.path, checked)); + } + } catch { + container.innerHTML = 'Could not load folders'; + } + } + + function getCheckedPaths(container) { + return Array.from(container.querySelectorAll('input[type=checkbox]:checked')).map(cb => cb.value); + } + + // ── Users ──────────────────────────────────────────────────────────────────── + + async function loadUsers() { + try { + const res = await fetch('/api/admin/users'); + if (!res.ok) return; + _users = await res.json(); + } catch {} + } + + function populateEditSelect() { + const currentId = aeufSelect.value; + aeufSelect.innerHTML = ''; + for (const u of _users) { + const opt = document.createElement('option'); + opt.value = u.id; + opt.textContent = u.username + (u.role === 'admin' ? ' (admin)' : ''); + aeufSelect.appendChild(opt); + } + aeufSelect.value = currentId; + } + + function renderUsers() { + userListEl.innerHTML = ''; + const currentUser = Auth.currentUser(); + if (_users.length === 0) { + userListEl.innerHTML = '
No users yet
'; + return; + } + for (const u of _users) { + const row = document.createElement('div'); + row.className = 'user-row'; + const nameEl = document.createElement('span'); + nameEl.className = 'user-row-name'; + nameEl.textContent = u.username + (u.id === currentUser?.id ? ' (you)' : ''); + const roleEl = document.createElement('span'); + roleEl.className = 'user-row-role'; + roleEl.textContent = u.role; + const delBtn = document.createElement('button'); + delBtn.className = 'admin-btn danger'; + delBtn.textContent = 'Delete'; + delBtn.disabled = u.id === currentUser?.id; + delBtn.addEventListener('click', () => deleteUser(u.id)); + row.append(nameEl, roleEl, delBtn); + userListEl.appendChild(row); + } + populateEditSelect(); + } + + async function deleteUser(id) { + if (!confirm('Delete this user?')) return; + try { + const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' }); + if (!res.ok) { const d = await res.json(); alert(d.error); return; } + await loadUsers(); + renderUsers(); + if (_editUserId === id) { editForm.hidden = true; _editUserId = null; } + } catch {} + } + + // ── Add form ───────────────────────────────────────────────────────────────── + + addUserBtn.addEventListener('click', () => { + addForm.hidden = !addForm.hidden; + if (!addForm.hidden) { + aaufUser.value = ''; + aaufPass.value = ''; + aaufPass2.value = ''; + aaufRole.value = 'user'; + aaufErr.textContent = ''; + renderFolderTree(aaufFolders, []); + aaufUser.focus(); + } + }); + + aaufCancel.addEventListener('click', () => { addForm.hidden = true; }); + + aaufSubmit.addEventListener('click', async () => { + aaufErr.textContent = ''; + if (aaufPass.value !== aaufPass2.value) { aaufErr.textContent = 'Passwords do not match.'; return; } + const body = { + username: aaufUser.value.trim(), + password: aaufPass.value, + role: aaufRole.value, + allowedPaths: getCheckedPaths(aaufFolders), + }; + try { + const res = await fetch('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { aaufErr.textContent = data.error || 'Error'; return; } + addForm.hidden = true; + await loadUsers(); + renderUsers(); + } catch { aaufErr.textContent = 'Network error'; } + }); + + // ── Edit form (driven by user select dropdown) ──────────────────────────────── + + aeufSelect.addEventListener('change', async () => { + const uid = aeufSelect.value; + if (!uid) { editForm.hidden = true; _editUserId = null; return; } + const u = _users.find(u => u.id === uid); + if (!u) return; + _editUserId = uid; + aeufPass.value = ''; + aeufPass2.value = ''; + aeufRole.value = u.role; + aeufErr.textContent = ''; + editForm.hidden = false; + await renderFolderTree(aeufFolders, u.allowedPaths || []); + }); + + aeufCancel.addEventListener('click', () => { + editForm.hidden = true; + _editUserId = null; + aeufSelect.value = ''; + }); + + aeufSubmit.addEventListener('click', async () => { + aeufErr.textContent = ''; + if (aeufPass.value && aeufPass.value !== aeufPass2.value) { + aeufErr.textContent = 'Passwords do not match.'; return; + } + const body = { + role: aeufRole.value, + allowedPaths: getCheckedPaths(aeufFolders), + }; + if (aeufPass.value) body.password = aeufPass.value; + try { + const res = await fetch(`/api/admin/users/${_editUserId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) { aeufErr.textContent = data.error || 'Error'; return; } + editForm.hidden = true; + _editUserId = null; + aeufSelect.value = ''; + await loadUsers(); + renderUsers(); + } catch { aeufErr.textContent = 'Network error'; } + }); + + closeBtn.addEventListener('click', close); + + async function open() { + await loadUsers(); + renderUsers(); + addForm.hidden = true; + editForm.hidden = true; + panel.hidden = false; + } + + function close() { panel.hidden = true; } + + return { open, close }; +})(); + +// ── Wire Player.getState / Player.loadState ─────────────────────────────────── +// These augment the already-initialised Player closure by storing refs on the +// object it returned. + +Player.getState = function() { + return { + queue: Player.getQueue(), + index: Player.getQueueIndex(), + position: (() => { + const el = document.getElementById('audio'); + return isFinite(el.currentTime) ? el.currentTime : 0; + })(), + sortKey: localStorage.getItem('snap_sort_key') || 'name', + sortDir: localStorage.getItem('snap_sort_dir') || 'asc', + }; +}; + +Player.loadState = function({ queue, index, position }) { + if (!Array.isArray(queue) || queue.length === 0) return; + // Use startQueue at the right index and seek to position after metadata loads + Player.startQueue(queue, typeof index === 'number' ? index : 0); + if (position && position > 0) { + const audioEl = document.getElementById('audio'); + const seek = () => { audioEl.currentTime = position; }; + if (audioEl.readyState >= 1) seek(); + else audioEl.addEventListener('loadedmetadata', seek, { once: true }); + } +}; + +// ── Route track selection through active device ─────────────────────────────── +// When another device is playing, selecting a track sends it to that device +// instead of playing locally. The takeover banner appears so the user can +// optionally switch playback to this device. +const _origStartQueue = Player.startQueue; +Player.startQueue = function(paths, idx) { + if (SyncManager.hasActiveDevice()) { + SyncManager.sendCommand('loadqueue', { queue: paths, index: idx || 0 }); + SyncManager.showTakeover(); + return; + } + _origStartQueue.call(this, paths, idx); +}; + +// ── Wire sort changes into SyncManager ─────────────────────────────────────── +// Patch the sort buttons rendered by FileBrowser to push state on change. +// We do this by observing clicks on .sort-btn elements via event delegation. +document.getElementById('browser').addEventListener('click', e => { + if (e.target.classList.contains('sort-btn')) { + setTimeout(() => SyncManager.push(), 50); + } +}); + +// ── Wire "add to playlist" buttons ─────────────────────────────────────────── +document.getElementById('select-add-playlist').addEventListener('click', () => { + const selected = Array.from(document.querySelectorAll('.list-item.selected, .grid-item.selected')) + .map(el => { + // Find the path from the item's click data — stored as data-path or via the + // list items rendered by FileBrowser. We'll rely on QueueModal pattern instead. + }); + // Easier: expose selected paths through FileBrowser + Playlists.showAddModal(FileBrowser.getSelectedPaths ? FileBrowser.getSelectedPaths() : []); +}); + +document.getElementById('qm-add-playlist').addEventListener('click', () => { + // get pending paths from QueueModal + const paths = QueueModal.getPendingPaths ? QueueModal.getPendingPaths() : []; + document.getElementById('queue-modal').hidden = true; + Playlists.showAddModal(paths); +}); + +// ── Password eye-toggle (global, works on any .pw-eye button) ──────────────── + +document.addEventListener('click', e => { + const btn = e.target.closest('.pw-eye'); + if (!btn) return; + const input = document.getElementById(btn.dataset.target); + if (!input) return; + const show = input.type === 'password'; + input.type = show ? 'text' : 'password'; + btn.querySelector('.eye-show').hidden = show; + btn.querySelector('.eye-hide').hidden = !show; +}); + +// ── App startup ─────────────────────────────────────────────────────────────── + +let _appReady = false; +function onReady() { + const user = Auth.currentUser(); + if (!user) return; + UserMenu.setup(user); + // Load the file browser (first real load after auth) + FileBrowser.reload(); + // Restore queue from localStorage + Player.restore(); + if (!Player.isActive()) document.body.classList.add('player-hidden'); + // Init cross-device sync + SyncManager.init(); + _appReady = true; +} + +Auth.init(); diff --git a/public/index.html b/public/index.html index f17060f..ab1d074 100644 --- a/public/index.html +++ b/public/index.html @@ -8,16 +8,62 @@ + +
+
+

SNAP

+

+ + + + + +
+
+
+ +
+ + +
@@ -72,6 +118,13 @@ + + + + + + - + diff --git a/public/style.css b/public/style.css index 5df5c7c..c4c7f3c 100644 --- a/public/style.css +++ b/public/style.css @@ -977,8 +977,12 @@ input[type=range]::-moz-range-thumb { transition: opacity 0.25s; } +.fullscreen-player.video-mode .fs-controls, .fullscreen-player.video-mode .fs-extra { justify-content: center; +} + +.fullscreen-player.video-mode .fs-extra { padding-bottom: calc(16px + env(safe-area-inset-bottom)); } @@ -1017,3 +1021,118 @@ input[type=range]::-moz-range-thumb { /* Mini player art area in video mode */ .player-art-btn.video-mode .art-fallback { display: flex !important; } .player-art-btn.video-mode img { display: none; } + +/* ── Login overlay ── */ +#login-overlay { position:fixed; inset:0; background:#111; z-index:400; display:flex; align-items:center; justify-content:center; } +#login-overlay[hidden] { display:none; } +.login-box { background:var(--surface); border:1px solid var(--border); border-radius:16px; padding:32px; width:min(360px,90vw); display:flex; flex-direction:column; gap:16px; } +.login-box h1 { color:var(--accent); font-size:24px; text-align:center; } +.login-field { display:flex; flex-direction:column; gap:6px; } +.login-field[hidden] { display:none; } +.login-field label { font-size:13px; color:var(--text-muted); } +.login-field input { background:var(--surface2); border:1px solid var(--border); color:var(--text); border-radius:8px; padding:10px 12px; font-size:14px; outline:none; } +.login-field input:focus { border-color:var(--accent); } +.login-submit { background:var(--accent); color:#000; border:none; border-radius:8px; padding:12px; font-size:15px; font-weight:600; cursor:pointer; } +.login-error { color:#e55; font-size:13px; text-align:center; min-height:18px; } + +/* ── User menu ── */ +.user-menu-wrap { position:relative; } +#user-menu-btn { background:var(--surface2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:5px 10px; cursor:pointer; font-size:13px; max-width:110px; overflow:hidden; } +#user-menu-label { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +#user-menu-dropdown { position:absolute; top:calc(100% + 6px); right:0; background:var(--surface); border:1px solid var(--border); border-radius:8px; min-width:140px; z-index:200; display:flex; flex-direction:column; overflow:hidden; } +#user-menu-dropdown[hidden] { display:none; } +.user-menu-item { background:none; border:none; color:var(--text); padding:10px 14px; text-align:left; cursor:pointer; font-size:13px; } +.user-menu-item:hover { background:var(--surface2); } + +/* ── Admin panel ── */ +#admin-panel { position:fixed; inset:0; background:var(--bg); z-index:350; display:flex; flex-direction:column; overflow:hidden; } +#admin-panel[hidden] { display:none; } +.admin-header { display:flex; align-items:center; gap:12px; padding:16px; border-bottom:1px solid var(--border); flex-shrink:0; } +.admin-header h2 { font-size:18px; font-weight:700; flex:1; } +.admin-body { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:16px; } +.admin-section { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:16px; display:flex; flex-direction:column; gap:10px; } +.admin-section h3 { font-size:14px; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:.5px; } +.user-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid var(--border); } +.user-row:last-child { border-bottom:none; } +.user-row-name { flex:1; font-size:14px; } +.user-row-role { font-size:12px; color:var(--text-muted); background:var(--surface2); border-radius:10px; padding:2px 8px; } +.admin-btn { background:var(--surface2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:5px 10px; cursor:pointer; font-size:12px; } +.admin-btn:hover { border-color:var(--accent); } +.admin-btn.danger { color:#e55; } +.admin-btn.danger:hover { border-color:#e55; } +.admin-form { display:flex; flex-direction:column; gap:10px; padding:12px; background:var(--surface2); border-radius:8px; } +.admin-form[hidden] { display:none; } +.admin-form label { font-size:12px; color:var(--text-muted); display:flex; flex-direction:column; gap:4px; } +.admin-form input, .admin-form select { background:var(--bg); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:7px 10px; font-size:13px; outline:none; } +.admin-form input:focus, .admin-form select:focus { border-color:var(--accent); } +.folder-checks { display:flex; flex-direction:column; gap:6px; max-height:200px; overflow-y:auto; } +.folder-check { display:flex; align-items:center; gap:8px; font-size:13px; cursor:pointer; } +.folder-check input[type=checkbox] { accent-color:var(--accent); } + +/* ── Password group (input + eye toggle) ── */ +.pw-group { display:flex; align-items:stretch; gap:0; } +.pw-group input { flex:1; border-radius:6px 0 0 6px !important; } +.pw-eye { background:var(--surface); border:1px solid var(--border); border-left:none; color:var(--text-muted); border-radius:0 6px 6px 0; padding:0 10px; cursor:pointer; display:flex; align-items:center; flex-shrink:0; transition:color .12s; } +.pw-eye:hover { color:var(--text); } +.pw-eye svg[hidden] { display:none; } + +/* ── Admin select (user dropdown in edit section) ── */ +.admin-select { background:var(--bg); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:7px 10px; font-size:13px; outline:none; width:100%; margin-top:4px; } +.admin-select:focus { border-color:var(--accent); } + +/* ── Folder tree ── */ +.folder-tree { max-height:260px; overflow-y:auto; border:1px solid var(--border); border-radius:6px; padding:6px 8px; background:var(--bg); } +.folder-tree::-webkit-scrollbar { width:4px; } +.folder-tree::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; } +.ftree-node { } +.ftree-children { padding-left:18px; } +.ftree-row { display:flex; align-items:center; gap:4px; padding:2px 0; } +.ftree-expand { background:none; border:none; color:var(--text-muted); cursor:pointer; font-size:9px; width:14px; text-align:center; flex-shrink:0; padding:0; line-height:1; } +.ftree-expand:hover { color:var(--text); } +.ftree-label { display:flex; align-items:center; gap:6px; cursor:pointer; font-size:13px; user-select:none; } +.admin-form .ftree-label { flex-direction:row !important; align-items:center !important; } +.ftree-label input[type=checkbox] { accent-color:var(--accent); flex-shrink:0; } +.ftree-loading { font-size:12px; color:var(--text-muted); padding:4px 2px; } +.admin-form-actions { display:flex; gap:8px; justify-content:flex-end; } +.admin-form-actions button { padding:7px 16px; border-radius:6px; border:1px solid var(--border); cursor:pointer; font-size:13px; } +.btn-primary { background:var(--accent); color:#000; border-color:var(--accent) !important; font-weight:600; } +.btn-cancel { background:none; color:var(--text-muted); } + +/* ── Playlists panel ── */ +#playlists-panel { position:fixed; top:0; right:0; bottom:0; width:320px; background:var(--surface); border-left:1px solid var(--border); z-index:200; display:flex; flex-direction:column; } +#playlists-panel[hidden] { display:none; } +.playlist-header { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; border-bottom:1px solid var(--border); font-weight:600; font-size:15px; flex-shrink:0; } +.playlist-list { flex:1; overflow-y:auto; padding:8px 0; } +.playlist-item { display:flex; align-items:center; gap:8px; padding:10px 12px; cursor:pointer; transition:background .12s; } +.playlist-item:hover { background:var(--surface2); } +.playlist-item-name { flex:1; font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.playlist-item-count { font-size:12px; color:var(--text-muted); } +.playlist-item-actions { display:flex; gap:4px; opacity:0; transition:opacity .12s; } +.playlist-item:hover .playlist-item-actions { opacity:1; } +.playlist-footer { padding:10px 12px; border-top:1px solid var(--border); flex-shrink:0; } +.new-playlist-btn { width:100%; background:var(--surface2); border:1px solid var(--border); color:var(--text); border-radius:6px; padding:8px; cursor:pointer; font-size:13px; } +.new-playlist-btn:hover { border-color:var(--accent); } + +/* Playlist track list (expanded) */ +.playlist-tracks { padding:0 12px 8px; display:flex; flex-direction:column; gap:2px; } +.playlist-track { font-size:12px; color:var(--text-muted); padding:3px 0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; cursor:pointer; } +.playlist-track:hover { color:var(--text); } + +/* ── Fullscreen takeover row ── */ +.fs-takeover { display:flex; justify-content:center; padding:8px 0 4px; } +.fs-takeover[hidden] { display:none; } +.fs-takeover-btn { background:var(--accent); color:#000; border:none; border-radius:8px; padding:8px 28px; cursor:pointer; font-size:13px; font-weight:600; } + +/* ── Sync banners ── */ +.sync-banner { position:fixed; left:0; right:0; bottom:var(--player-h); background:var(--surface); border-top:1px solid var(--accent); padding:10px 16px; display:flex; align-items:center; gap:10px; z-index:150; font-size:13px; } +.sync-banner[hidden] { display:none; } +.sync-banner-text { flex:1; } +.sync-banner-btn { background:var(--accent); color:#000; border:none; border-radius:6px; padding:5px 12px; cursor:pointer; font-size:12px; font-weight:600; } +.sync-banner-dismiss { background:none; border:none; color:var(--text-muted); cursor:pointer; font-size:18px; padding:2px 6px; } + +/* ── Mobile: hide playlists button in topbar ── */ +@media (max-width: 600px) { + #btn-playlists { display: none; } + .sync-banner { bottom: var(--player-h); } + #playlists-panel { width:100%; top:20%; border-radius:16px 16px 0 0; border:none; border-top:1px solid var(--border); } +} diff --git a/server.js b/server.js index c276f5d..f7d8d03 100644 --- a/server.js +++ b/server.js @@ -3,12 +3,89 @@ const path = require('path'); const fs = require('fs'); const mime = require('mime-types'); const crypto = require('crypto'); +const bcrypt = require('bcryptjs'); +const session = require('express-session'); +const FileStore = require('session-file-store')(session); const app = express(); -app.use(express.json({ limit: '2kb' })); +app.use(express.json({ limit: '2mb' })); const PORT = process.env.PORT || 3000; const MEDIA_ROOT = path.resolve(process.env.MEDIA_ROOT || '/media'); +// ── Data directory setup ────────────────────────────────────────────────────── +const DATA_DIR = path.join(__dirname, 'data'); +const SESSIONS_DIR = path.join(DATA_DIR, 'sessions'); +const USERS_FILE = path.join(DATA_DIR, 'users.json'); +const USERSTATE_FILE = path.join(DATA_DIR, 'userstate.json'); +const PLAYLISTS_FILE = path.join(DATA_DIR, 'playlists.json'); + +for (const dir of [DATA_DIR, SESSIONS_DIR]) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} +for (const file of [USERS_FILE, USERSTATE_FILE, PLAYLISTS_FILE]) { + if (!fs.existsSync(file)) { + fs.writeFileSync(file, file === USERS_FILE || file === PLAYLISTS_FILE ? '[]' : '{}'); + } +} + +// ── Data helpers ────────────────────────────────────────────────────────────── +function readJSON(file) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return file.endsWith('userstate.json') ? {} : []; } +} +function writeJSON(file, data) { + fs.writeFileSync(file, JSON.stringify(data, null, 2)); +} + +function getUsers() { return readJSON(USERS_FILE); } +function saveUsers(u) { writeJSON(USERS_FILE, u); } +function getUserState() { return readJSON(USERSTATE_FILE); } +function saveUserState(s) { writeJSON(USERSTATE_FILE, s); } +function getPlaylists() { return readJSON(PLAYLISTS_FILE); } +function savePlaylists(p) { writeJSON(PLAYLISTS_FILE, p); } + +// ── Session setup ───────────────────────────────────────────────────────────── +app.use(session({ + store: new FileStore({ path: SESSIONS_DIR, ttl: 30 * 24 * 3600, retries: 1, logFn: () => {} }), + secret: (() => { + const secretFile = path.join(DATA_DIR, 'session_secret'); + if (fs.existsSync(secretFile)) return fs.readFileSync(secretFile, 'utf8').trim(); + const s = crypto.randomBytes(48).toString('hex'); + fs.writeFileSync(secretFile, s); + return s; + })(), + resave: false, + saveUninitialized: false, + cookie: { httpOnly: true, maxAge: 30 * 24 * 3600 * 1000, sameSite: 'lax' }, +})); + +// ── Input validation ────────────────────────────────────────────────────────── +function validUsername(u) { return typeof u === 'string' && /^[a-zA-Z0-9_-]{3,32}$/.test(u); } +function validPassword(p) { return typeof p === 'string' && p.length >= 8; } + +// ── Auth middleware ─────────────────────────────────────────────────────────── +function requireAuth(req, res, next) { + if (!req.session.userId) return res.status(401).json({ error: 'Not authenticated' }); + const users = getUsers(); + const user = users.find(u => u.id === req.session.userId); + if (!user) { req.session.destroy(() => {}); return res.status(401).json({ error: 'Not authenticated' }); } + req.user = user; + next(); +} + +function requireAdmin(req, res, next) { + requireAuth(req, res, () => { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' }); + next(); + }); +} + +// ── Folder access check ─────────────────────────────────────────────────────── +function canAccess(user, relPath) { + if (user.role === 'admin') return true; + if (!relPath) return true; // root is ok, we filter entries + return user.allowedPaths.some(p => relPath === p || relPath.startsWith(p + '/')); +} + const AUDIO_EXTS = new Set(['.mp3', '.flac', '.ogg', '.m4a', '.mp4', '.aac', '.wav', '.opus', '.wma', '.m4b', '.mp4a']); const VIDEO_EXTS = new Set(['.mkv', '.webm', '.avi', '.mov', '.m4v', '.mpg', '.mpeg', '.wmv']); const ARTWORK_NAMES = ['folder.jpg', 'folder.jpeg', 'folder.png', 'cover.jpg', 'cover.jpeg', 'cover.png', 'album.jpg', 'album.png']; @@ -22,11 +99,200 @@ function safePath(relPath) { return full; } -// Browse directory -app.get('/api/browse', (req, res) => { +// ── Auth routes (no auth required for login/setup-check) ───────────────────── +app.get('/api/auth/setup-check', (req, res) => { + const users = getUsers(); + res.json({ needsSetup: users.length === 0 }); +}); + +app.post('/api/auth/setup', async (req, res) => { + const users = getUsers(); + if (users.length > 0) return res.status(400).json({ error: 'Setup already done' }); + const { username, password } = req.body || {}; + if (!validUsername(username)) return res.status(400).json({ error: 'Invalid username (3-32 chars, alphanumeric/underscore/dash)' }); + if (!validPassword(password)) return res.status(400).json({ error: 'Password must be at least 8 characters' }); + const passwordHash = await bcrypt.hash(password, 12); + const user = { id: crypto.randomUUID(), username, passwordHash, role: 'admin', allowedPaths: [] }; + users.push(user); + saveUsers(users); + req.session.userId = user.id; + res.json({ id: user.id, username: user.username, role: user.role }); +}); + +app.post('/api/auth/login', async (req, res) => { + const { username, password } = req.body || {}; + if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); + const users = getUsers(); + const user = users.find(u => u.username === username); + if (!user) return res.status(401).json({ error: 'Invalid credentials' }); + const ok = await bcrypt.compare(password, user.passwordHash); + if (!ok) return res.status(401).json({ error: 'Invalid credentials' }); + req.session.userId = user.id; + res.json({ id: user.id, username: user.username, role: user.role }); +}); + +app.post('/api/auth/logout', (req, res) => { + req.session.destroy(() => {}); + res.json({ ok: true }); +}); + +app.get('/api/auth/me', requireAuth, (req, res) => { + res.json({ id: req.user.id, username: req.user.username, role: req.user.role }); +}); + +// ── Admin user management ───────────────────────────────────────────────────── +app.get('/api/admin/users', requireAdmin, (req, res) => { + const users = getUsers().map(({ passwordHash, ...u }) => u); + res.json(users); +}); + +app.post('/api/admin/users', requireAdmin, async (req, res) => { + const { username, password, role, allowedPaths } = req.body || {}; + if (!validUsername(username)) return res.status(400).json({ error: 'Invalid username' }); + if (!validPassword(password)) return res.status(400).json({ error: 'Password must be at least 8 characters' }); + if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); + const users = getUsers(); + if (users.find(u => u.username === username)) return res.status(400).json({ error: 'Username taken' }); + const passwordHash = await bcrypt.hash(password, 12); + const user = { + id: crypto.randomUUID(), + username, + passwordHash, + role, + allowedPaths: Array.isArray(allowedPaths) ? allowedPaths : [], + }; + users.push(user); + saveUsers(users); + const { passwordHash: _, ...out } = user; + res.json(out); +}); + +app.patch('/api/admin/users/:id', requireAdmin, async (req, res) => { + const users = getUsers(); + const idx = users.findIndex(u => u.id === req.params.id); + if (idx === -1) return res.status(404).json({ error: 'User not found' }); + const { password, role, allowedPaths } = req.body || {}; + if (password !== undefined) { + if (!validPassword(password)) return res.status(400).json({ error: 'Password must be at least 8 characters' }); + users[idx].passwordHash = await bcrypt.hash(password, 12); + } + if (role !== undefined) { + if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); + users[idx].role = role; + } + if (allowedPaths !== undefined) { + users[idx].allowedPaths = Array.isArray(allowedPaths) ? allowedPaths : []; + } + saveUsers(users); + const { passwordHash, ...out } = users[idx]; + res.json(out); +}); + +app.delete('/api/admin/users/:id', requireAdmin, (req, res) => { + if (req.user.id === req.params.id) return res.status(400).json({ error: 'Cannot delete yourself' }); + const users = getUsers(); + const idx = users.findIndex(u => u.id === req.params.id); + if (idx === -1) return res.status(404).json({ error: 'User not found' }); + users.splice(idx, 1); + saveUsers(users); + res.json({ ok: true }); +}); + +// ── User state sync ─────────────────────────────────────────────────────────── +app.get('/api/state', requireAuth, (req, res) => { + const state = getUserState(); + res.json(state[req.user.id] || {}); +}); + +app.put('/api/state', requireAuth, (req, res) => { + const { queue, index, position, duration, playing, sortKey, sortDir, deviceId } = req.body || {}; + const state = getUserState(); + state[req.user.id] = { + queue: Array.isArray(queue) ? queue : (state[req.user.id]?.queue || []), + index: typeof index === 'number' ? index : (state[req.user.id]?.index ?? -1), + position: typeof position === 'number' ? position : (state[req.user.id]?.position || 0), + duration: typeof duration === 'number' ? duration : (state[req.user.id]?.duration || 0), + playing: typeof playing === 'boolean' ? playing : (state[req.user.id]?.playing || false), + sortKey: sortKey || state[req.user.id]?.sortKey || 'name', + sortDir: sortDir || state[req.user.id]?.sortDir || 'asc', + activeDeviceId: deviceId || state[req.user.id]?.activeDeviceId || null, + activeDeviceAt: Date.now(), + pendingCommand: null, + }; + saveUserState(state); + res.json({ ok: true }); +}); + +app.post('/api/command', requireAuth, (req, res) => { + const { type, data } = req.body || {}; + const valid = ['playpause', 'next', 'prev', 'seek', 'skip', 'loadqueue']; + if (!valid.includes(type)) return res.status(400).json({ error: 'Invalid type' }); + const state = getUserState(); + if (!state[req.user.id]) return res.status(404).json({ error: 'No active state' }); + state[req.user.id].pendingCommand = { type, data, sentAt: Date.now() }; + saveUserState(state); + res.json({ ok: true }); +}); + +// ── Playlists ───────────────────────────────────────────────────────────────── +app.get('/api/playlists', requireAuth, (req, res) => { + const playlists = getPlaylists().filter(p => p.userId === req.user.id); + res.json(playlists); +}); + +app.post('/api/playlists', requireAuth, (req, res) => { + const { name } = req.body || {}; + if (!name || typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: 'Name required' }); + const playlist = { + id: crypto.randomUUID(), + userId: req.user.id, + name: name.trim().slice(0, 100), + tracks: [], + createdAt: new Date().toISOString(), + }; + const playlists = getPlaylists(); + playlists.push(playlist); + savePlaylists(playlists); + res.json(playlist); +}); + +app.get('/api/playlists/:id', requireAuth, (req, res) => { + const playlist = getPlaylists().find(p => p.id === req.params.id && p.userId === req.user.id); + if (!playlist) return res.status(404).json({ error: 'Playlist not found' }); + res.json(playlist); +}); + +app.put('/api/playlists/:id', requireAuth, (req, res) => { + const playlists = getPlaylists(); + const idx = playlists.findIndex(p => p.id === req.params.id && p.userId === req.user.id); + if (idx === -1) return res.status(404).json({ error: 'Playlist not found' }); + const { name, tracks } = req.body || {}; + if (name !== undefined) playlists[idx].name = String(name).trim().slice(0, 100); + if (tracks !== undefined) playlists[idx].tracks = Array.isArray(tracks) ? tracks : []; + savePlaylists(playlists); + res.json(playlists[idx]); +}); + +app.delete('/api/playlists/:id', requireAuth, (req, res) => { + const playlists = getPlaylists(); + const idx = playlists.findIndex(p => p.id === req.params.id && p.userId === req.user.id); + if (idx === -1) return res.status(404).json({ error: 'Playlist not found' }); + playlists.splice(idx, 1); + savePlaylists(playlists); + res.json({ ok: true }); +}); + +// ── Browse directory ────────────────────────────────────────────────────────── +app.get('/api/browse', requireAuth, (req, res) => { + const relPath = req.query.path || ''; + + if (!canAccess(req.user, relPath)) { + return res.status(403).json({ error: 'Access denied' }); + } + let full; try { - full = safePath(req.query.path); + full = safePath(relPath); } catch { return res.status(400).json({ error: 'Invalid path' }); } @@ -43,9 +309,13 @@ app.get('/api/browse', (req, res) => { for (const entry of entries) { const ext = path.extname(entry.name).toLowerCase(); - const rel = path.join(req.query.path || '', entry.name); + const rel = path.join(relPath, entry.name); if (entry.isDirectory()) { + // At root level, filter by allowedPaths for non-admins + if (!relPath && req.user.role !== 'admin') { + if (!req.user.allowedPaths.includes(entry.name)) continue; + } let mtime = 0; try { mtime = fs.statSync(path.join(full, entry.name)).mtimeMs; } catch {} dirs.push({ name: entry.name, type: 'dir', path: rel, mtime }); @@ -60,14 +330,17 @@ app.get('/api/browse', (req, res) => { } } - res.json({ path: req.query.path || '', items: [...dirs, ...files] }); + res.json({ path: relPath, items: [...dirs, ...files] }); }); // Stream audio with range support -app.get('/api/stream', (req, res) => { +app.get('/api/stream', requireAuth, (req, res) => { + const relPath = req.query.path || ''; + if (!canAccess(req.user, relPath)) return res.status(403).json({ error: 'Access denied' }); + let full; try { - full = safePath(req.query.path); + full = safePath(relPath); } catch { return res.status(400).json({ error: 'Invalid path' }); } @@ -118,10 +391,13 @@ app.get('/api/stream', (req, res) => { }); // Artwork: embedded → folder image → 404 -app.get('/api/artwork', async (req, res) => { +app.get('/api/artwork', requireAuth, async (req, res) => { + const relPath = req.query.path || ''; + if (!canAccess(req.user, relPath)) return res.status(403).json({ error: 'Access denied' }); + let full; try { - full = safePath(req.query.path); + full = safePath(relPath); } catch { return res.status(400).json({ error: 'Invalid path' }); } @@ -155,10 +431,13 @@ app.get('/api/artwork', async (req, res) => { }); // Metadata -app.get('/api/metadata', async (req, res) => { +app.get('/api/metadata', requireAuth, async (req, res) => { + const relPath = req.query.path || ''; + if (!canAccess(req.user, relPath)) return res.status(403).json({ error: 'Access denied' }); + let full; try { - full = safePath(req.query.path); + full = safePath(relPath); } catch { return res.status(400).json({ error: 'Invalid path' }); }