diff --git a/src/backend-api/src/server.ts b/src/backend-api/src/server.ts index 51399c7d..92d0ddb8 100644 --- a/src/backend-api/src/server.ts +++ b/src/backend-api/src/server.ts @@ -10,6 +10,7 @@ import ky from 'ky' import xmlparser from 'xml-js' import { LogRequest, LogResponse } from './models/log.model' import type { MupiboxConfig } from './models/mupibox-config.model' +import type { PlaytimeStatus } from './models/playtime.model' import { ServerConfig } from './models/server.model' import type { SpotifyValidationRequest, SpotifyValidationResponse } from './models/spotify-api.model' import { SpotifyApiService } from './services/spotify-api.service' @@ -62,6 +63,7 @@ const wlanFile = `${configBasePath}/wlan.json` const monitorFile = `${configBasePath}/monitor.json` const albumstopFile = `${configBasePath}/albumstop.json` const mupihat = '/tmp/mupihat.json' +const playtimeFile = '/tmp/playtime.json' const dataLock = '/tmp/.data.lock' const resumeLock = '/tmp/.resume.lock' @@ -102,6 +104,44 @@ if (productionServe) { app.use(express.static(path.join(__dirname, 'www'))) } +// MED-2: harden /api/rssfeed against SSRF. +// +// The endpoint takes a user-supplied URL and ky-fetches it server-side, +// so a caller can pivot the box into reaching anything routable from +// the box's network — most notably the LAN's internal services +// (router admin pages, NAS shares, other boxes' admin UIs). The +// endpoint itself is auth-protected (frontend only), but treating +// an authenticated frontend as fully trusted means any XSS or admin- +// CSRF leak gives the attacker LAN-pivot for free. Defence in depth: +// +// 1. Schema allowlist: http: and https: only. Strips file:, ftp:, +// gopher:, data:, javascript: etc. that ky would otherwise honour. +// 2. Host-resolve allowlist: reject private IPv4 ranges (RFC1918, +// loopback, link-local, IPv4-mapped IPv6). Done by a synchronous +// check on the parsed hostname; we don't resolve DNS to keep the +// check fast and simple, but we DO block raw IP literals. +// 3. Hard timeout (10s) + max-content-length (5 MB) — RSS feeds are +// small text, anything bigger is either misconfigured or hostile. +const PRIVATE_IP_REGEXES = [ + /^127\./, + /^10\./, + /^192\.168\./, + /^172\.(1[6-9]|2\d|3[0-1])\./, // 172.16.0.0/12 + /^169\.254\./, // link-local + /^0\./, + /^::1$/, + /^::ffff:127\./i, + /^fe80:/i, // IPv6 link-local + /^fc00:/i, // IPv6 unique local + /^fd00:/i, +] +const isPrivateHost = (host: string): boolean => { + // Strip brackets from IPv6 literals + const h = host.replace(/^\[|\]$/g, '').toLowerCase() + if (h === 'localhost' || h === '0.0.0.0' || h === '::') return true + return PRIVATE_IP_REGEXES.some((r) => r.test(h)) +} + // Routes app.get('/api/rssfeed', async (req, res) => { const rssUrl = req.query.url @@ -109,9 +149,59 @@ app.get('/api/rssfeed', async (req, res) => { res.status(500).send('Given url is not a string.') return } - ky.get(rssUrl) + let parsed: URL + try { + parsed = new URL(rssUrl) + } catch { + res.status(400).send('Invalid URL') + return + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + res.status(400).send('Only http(s) URLs are allowed') + return + } + if (isPrivateHost(parsed.hostname)) { + res.status(403).send('Private / loopback hosts are not allowed') + return + } + // Defence-in-depth: probe with HEAD before the full GET. + // Without this, calling /api/rssfeed with a non-RSS URL (e.g. a multi-MB + // MP3 episode link as the frontend's RSS-resume code briefly did) streamed + // the entire binary body into memory before the 5MB body-cap aborted with + // 413 — ~4s wasted per request. HEAD lets us reject by content-type or + // advertised content-length in <500ms. + // Native fetch (not ky) — ky was silently failing on the 301-redirect + // chain in this codepath. HEAD is best-effort: some origin servers + // reject HEAD with 405/501. On non-2xx or network error during HEAD we + // fall through to the existing GET path; the 10s timeout + 5MB body + // cap still bound the worst case. + try { + const head = await fetch(rssUrl, { + method: 'HEAD', + redirect: 'follow', + signal: AbortSignal.timeout(5000), + }) + const ct = head.headers.get('content-type') || '' + if (ct && !/xml|rss/i.test(ct)) { + res.status(415).send(`Unsupported content-type: ${ct}`) + return + } + const cl = Number.parseInt(head.headers.get('content-length') || '0', 10) + if (cl > 5_000_000) { + res.status(413).send('Response too large (per content-length)') + return + } + } catch { + // HEAD failed — fall through to GET. + } + ky.get(rssUrl, { timeout: 10000 }) .text() .then((response) => { + // Bound the parsed payload size — RSS feeds shouldn't be megabytes. + if (response.length > 5_000_000) { + res.status(413).send('Response too large') + return + } res.send(xmlparser.xml2json(response, { compact: true, nativeType: true })) }) .catch(() => { @@ -120,63 +210,273 @@ app.get('/api/rssfeed', async (req, res) => { }) app.get('/api/data', (_req, res) => { - if (fs.existsSync(activedataFile)) { - jsonfile.readFile(activedataFile, (error, data) => { - if (error) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/data read active_data.json`) - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) - res.json([]) - } else { - res.json(data) - } - }) + // Mirror /api/resume: when active_data.json is missing the frontend would + // otherwise hang on its loading spinner, because the previous code's missing + // else-branch never sent a response. + if (!fs.existsSync(activedataFile)) { + res.json([]) + return } + jsonfile.readFile(activedataFile, (error, data) => { + if (error) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/data read active_data.json`) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) + res.json([]) + } else { + res.json(data) + } + }) }) app.get('/api/resume', (_req, res) => { - if (fs.existsSync(resumeFile)) { - tryReadFile(resumeFile) - .then((data) => { - res.json(data) - }) - .catch((error) => { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/resume read resume.json`) - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) - res.status(500).send('Internal Server Error') - }) - } else { - res.status(404).send(`File Not Found: ${resumeFile}`) + // Mirror /api/data and /api/activeresume: callers always expect an array. + // Until the first save resume.json doesn't exist (created on demand by + // check_network.sh or by the first /api/addresume), and the previous 404 + // crashed callers that did `.length` / `.findIndex` on the response. + if (!fs.existsSync(resumeFile)) { + res.json([]) + return } + tryReadFile(resumeFile) + .then((data) => { + if (Array.isArray(data)) backfillLastPlayedAt(data, Date.now()) + res.json(data) + }) + .catch((error) => { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/resume read resume.json`) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) + res.json([]) + }) }) app.get('/api/mupihat', (_req, res) => { - if (fs.existsSync(mupihat)) { - jsonfile.readFile(mupihat, (error, data) => { - if (error) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/mupihat read mupihat.json`) - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) - res.json([]) - } else { - res.json(data) + // Same hang-without-file as /api/data: a box without a MuPiHAT board + // simply has no /tmp/mupihat.json — return an empty object rather than + // letting the request stall. + if (!fs.existsSync(mupihat)) { + res.json({}) + return + } + jsonfile.readFile(mupihat, (error, data) => { + if (error) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/mupihat read mupihat.json`) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) + res.json({}) + } else { + res.json(data) + } + }) +}) + +// Playback time tracking written by backend-player to /tmp/playtime.json (tmpfs). +// Missing/unreadable file means the player hasn't ticked yet or the feature is off — +// either way, surfaces as "disabled" so the frontend can hide the UI safely. +app.get('/api/playtime', (_req, res) => { + const disabled: PlaytimeStatus = { enabled: false } + if (!fs.existsSync(playtimeFile)) { + res.json(disabled) + return + } + jsonfile.readFile(playtimeFile, (error, data) => { + if (error) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/playtime read playtime.json`) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) + res.json(disabled) + } else { + res.json(data) + } + }) +}) + +// Atomically apply a mutation to /etc/mupibox/mupiboxconfig.json. +// Used by the parent-control endpoints below (extend / release / quietnow). +// The player picks up the change ~50 ms later via fs.watch (see spotify-control.js). +async function updateMupiboxConfig(mutate: (cfg: Record) => void): Promise { + const current = (await readJsonFile(mupiboxConfigPath)) as Record + mutate(current) + const tmpPath = '/tmp/.mupiboxconfig.update.json' + await new Promise((resolve, reject) => { + jsonfile.writeFile(tmpPath, current, { spaces: 2 }, (err) => (err ? reject(err) : resolve())) + }) + await new Promise((resolve, reject) => { + // sudo cp is allowed for the dietpi user on the box (same pattern as + // /api/shutdown / /api/reboot below). Atomic: write to a tmp on the same + // filesystem region, then cp into place; player's fs.watch fires once. + exec(`sudo cp ${tmpPath} ${mupiboxConfigPath} && sudo rm -f ${tmpPath}`, (err) => (err ? reject(err) : resolve())) + }) + // Local cache invalidation (server's own mupiboxConfigCache) — fs.watch on the + // dir already does this, but be explicit so /api/config returns the new value + // immediately on the next call. + mupiboxConfigCache = undefined +} + +// Logical-day computation must match the player's `getLogicalDay` so `todayBonus` +// works consistently across processes (resetHour shifts when "today" begins). +function computeLogicalDate(now: Date, resetHour: number): string { + const shifted = new Date(now.getTime() - resetHour * 3600 * 1000) + const y = shifted.getFullYear() + const m = String(shifted.getMonth() + 1).padStart(2, '0') + const d = String(shifted.getDate()).padStart(2, '0') + return `${y}-${m}-${d}` +} + +// POST /api/playtime/extend body: { minutes: number } +// Adds bonus minutes to today's playtime cap. If the day rolls over at the +// configured resetHour, the bonus auto-clears (player checks the date field). +// Calling extend repeatedly accumulates: existing bonus for today is kept and +// added to. Always uses the *current* day at the time of call, so e.g. an +// /extend at 23:30 with resetHour=4 still applies to "today" until 04:00. +app.post('/api/playtime/extend', async (req, res) => { + const minutes = Number(req.body?.minutes) + if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 1440) { + res.status(400).json({ error: 'minutes must be a positive number <= 1440' }) + return + } + try { + await updateMupiboxConfig((cfg) => { + let pl = cfg.playtimeLimit as Record | undefined + if (!pl || typeof pl !== 'object') { + pl = {} + cfg.playtimeLimit = pl } + const resetHour = Number.isInteger(pl.resetHour) ? (pl.resetHour as number) : 0 + const today = computeLogicalDate(new Date(), resetHour) + const existing = (pl.todayBonus as { date?: string; minutes?: number } | undefined) || {} + const existingMinutes = + existing.date === today && Number.isFinite(existing.minutes) ? Number(existing.minutes) : 0 + pl.todayBonus = { date: today, minutes: Math.min(1440, existingMinutes + minutes) } }) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/playtime/extend +${minutes} min`) + res.status(200).json({ ok: true, addedMinutes: minutes }) + } catch (err) { + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/playtime/extend failed:`, err) + res.status(500).json({ error: 'internal error' }) } }) -app.get('/api/activeresume', (_req, res) => { - if (fs.existsSync(activeresumeFile)) { - jsonfile.readFile(activeresumeFile, (error, data) => { - if (error) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/activeresume read active_resume.json`) - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) - res.json([]) - } else { - res.json(data) +// POST /api/playtime/release body: { minutes?: number } +// Sets `playbackOverride.allowUntil = now + minutes*60_000`. While that timestamp +// is in the future, all blocks are bypassed. Default 60 min if not specified. +app.post('/api/playtime/release', async (req, res) => { + const minutes = req.body?.minutes !== undefined ? Number(req.body.minutes) : 60 + if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 1440) { + res.status(400).json({ error: 'minutes must be a positive number <= 1440' }) + return + } + const until = Date.now() + minutes * 60_000 + try { + await updateMupiboxConfig((cfg) => { + let ov = cfg.playbackOverride as Record | undefined + if (!ov || typeof ov !== 'object') { + ov = {} + cfg.playbackOverride = ov + } + ov.allowUntil = until + }) + console.log( + `${new Date().toLocaleString()}: [MuPiBox-Server] /api/playtime/release for ${minutes} min (until ${new Date(until).toLocaleString()})`, + ) + res.status(200).json({ ok: true, minutes, until }) + } catch (err) { + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/playtime/release failed:`, err) + res.status(500).json({ error: 'internal error' }) + } +}) + +// POST /api/playtime/limit body: { day: 'mon'|...|'sun', minutes: number } +// Sets the daily playtime cap for one weekday in mupiboxconfig.json. Used by +// the Telegram /limit set bot command so parents can adjust a single day +// without opening the admin UI. Live-reload in the player picks the change up +// within ~50 ms; no restart needed. +const PLAYTIME_DAY_KEYS = new Set(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']) +app.post('/api/playtime/limit', async (req, res) => { + const day = String(req.body?.day || '').toLowerCase() + const minutes = Number(req.body?.minutes) + if (!PLAYTIME_DAY_KEYS.has(day)) { + res.status(400).json({ error: 'day must be one of mon|tue|wed|thu|fri|sat|sun' }) + return + } + if (!Number.isFinite(minutes) || minutes < 0 || minutes > 1440) { + res.status(400).json({ error: 'minutes must be in [0, 1440]' }) + return + } + try { + await updateMupiboxConfig((cfg) => { + let pl = cfg.playtimeLimit as Record | undefined + if (!pl || typeof pl !== 'object') { + pl = {} + cfg.playtimeLimit = pl + } + let limits = pl.limitsMinutes as Record | undefined + if (!limits || typeof limits !== 'object') { + limits = {} + pl.limitsMinutes = limits + } + limits[day] = minutes + }) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/playtime/limit ${day}=${minutes} min`) + res.status(200).json({ ok: true, day, minutes }) + } catch (err) { + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/playtime/limit failed:`, err) + res.status(500).json({ error: 'internal error' }) + } +}) + +// POST /api/quiethours/now body: { minutes?: number } +// Sets `playbackOverride.forceBlockUntil = now + minutes*60_000`. Forces playback +// off immediately (kid sees the override overlay). Default 60 min. +app.post('/api/quiethours/now', async (req, res) => { + const minutes = req.body?.minutes !== undefined ? Number(req.body.minutes) : 60 + if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 1440) { + res.status(400).json({ error: 'minutes must be a positive number <= 1440' }) + return + } + const until = Date.now() + minutes * 60_000 + try { + await updateMupiboxConfig((cfg) => { + let ov = cfg.playbackOverride as Record | undefined + if (!ov || typeof ov !== 'object') { + ov = {} + cfg.playbackOverride = ov } + ov.forceBlockUntil = until }) + console.log( + `${new Date().toLocaleString()}: [MuPiBox-Server] /api/quiethours/now for ${minutes} min (until ${new Date(until).toLocaleString()})`, + ) + res.status(200).json({ ok: true, minutes, until }) + } catch (err) { + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/quiethours/now failed:`, err) + res.status(500).json({ error: 'internal error' }) } }) +app.get('/api/activeresume', (_req, res) => { + // active_resume.json is a symlink that scripts/mupibox/check_network.sh + // creates the first time the network state is determined. Until that runs + // (briefly after boot) the symlink is missing — without an explicit empty + // response the request hung silently and the resume page stuck on Loading. + if (!fs.existsSync(activeresumeFile)) { + res.json([]) + return + } + jsonfile.readFile(activeresumeFile, (error, data) => { + if (error) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/activeresume read active_resume.json`) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) + res.json([]) + } else { + // Lazy back-fill in-memory: legacy entries written before the + // lastPlayedAt field gain synthetic stamps so frontend's DESC sort + // produces the same visible order as the old blind .reverse() until + // a real save persists a fresh stamp. No write here — file gets the + // back-fill on the next /api/addresume call. + if (Array.isArray(data)) backfillLastPlayedAt(data, Date.now()) + res.json(data) + } + }) +}) + app.get('/api/network', (_req, res) => { if (fs.existsSync(networkFile)) { tryReadFile(networkFile) @@ -231,17 +531,21 @@ app.get('/api/albumstop', (_req, res) => { }) app.get('/api/wlan', (_req, res) => { - if (fs.existsSync(wlanFile)) { - jsonfile.readFile(wlanFile, (error, data) => { - if (error) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/wlan read wlan.json`) - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) - res.json([]) - } else { - res.json(data) - } - }) + // Same shape as /api/data and /api/mupihat — empty array when the file + // hasn't been written yet, instead of an open connection that never closes. + if (!fs.existsSync(wlanFile)) { + res.json([]) + return } + jsonfile.readFile(wlanFile, (error, data) => { + if (error) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/wlan read wlan.json`) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) + res.json([]) + } else { + res.json(data) + } + }) }) app.post('/api/addwlan', (req, res) => { @@ -251,202 +555,288 @@ app.post('/api/addwlan', (req, res) => { if (error) out = [] out.push(req.body) - jsonfile.writeFile(wlanFile, out, { spaces: 4 }, (error) => { - if (error) throw error + jsonfile.writeFile(wlanFile, out, { spaces: 4 }, (writeError) => { + // The previous code did `if (writeError) throw error` — async-throw + // inside a node-style callback isn't catchable by Express, so it + // crashed the entire backend-api process. Send a 500 instead. + if (writeError) { + console.error( + `${new Date().toLocaleString()}: [MuPiBox-Server] /api/addwlan write failed:`, + writeError, + ) + res.status(500).send('Failed to persist WLAN entry') + return + } res.status(200).send('ok') }) }) }) app.post('/api/add', (req, res) => { + if (fs.existsSync(dataLock)) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/add data.json is locked`) + res.status(200).send('locked') + return + } try { - if (fs.existsSync(dataLock)) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/add data.json is locked`) - res.status(200).send('locked') - } else { - fs.openSync(dataLock, 'w') - jsonfile.readFile(dataFile, (error, data) => { - if (error) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/add read data.json`) - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) - res.status(200).send('error') - } else { - data.push(req.body) - - jsonfile.writeFile(dataFile, data, { spaces: 4 }, (error) => { - if (error) throw error - res.status(200).send('ok') - }) - } - }) - fs.unlink(dataLock, (err) => { - if (err) throw err - console.log( - `${new Date().toLocaleString()}: [MuPiBox-Server] /api/add - data.json unlocked, locked file deleted!`, - ) - }) - } + fs.openSync(dataLock, 'w') } catch (err) { - console.error(err) + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/add failed to acquire lock:`, err) + res.status(200).send('error') + return } + jsonfile.readFile(dataFile, (error, data) => { + if (error) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/add read data.json`) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) + releaseLock(dataLock, '/api/add') + res.status(200).send('error') + return + } + data.push(req.body) + jsonfile.writeFile(dataFile, data, { spaces: 4 }, (writeError) => { + releaseLock(dataLock, '/api/add') + if (writeError) { + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/add write failed:`, writeError) + res.status(500).send('error') + return + } + res.status(200).send('ok') + }) + }) }) -app.post('/api/addresume', (req, res) => { - try { - if (fs.existsSync(resumeLock)) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/addresume resume.json is locked`) - res.status(200).send('locked') - } else { - fs.openSync(resumeLock, 'w') - jsonfile.readFile(resumeFile, (error, data) => { - if (error) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/add read resume.json`) - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) - res.status(200).send('error') - } else { - // Index des vorhandenen Eintrags mit derselben "id" finden - const index = data.findIndex((item: { id: any }) => item.id === req.body.id) +// Lock cleanup — used by every read-modify-write endpoint (data.json + resume.json) +// to ensure the lock is always removed once the read+write cycle has finished +// (success OR error). The historical pattern called fs.unlink outside the async +// readFile callback, so the lock was gone before the write started — two +// concurrent calls could clobber each other. +const releaseLock = (lockPath: string, context: string) => { + fs.unlink(lockPath, (err) => { + if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] ${context} - failed to unlink lock:`, err) + } + }) +} - if (index !== -1) { - // Wenn der Eintrag vorhanden ist, ersetze ihn - data[index] = req.body - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Entry with id ${req.body.id} replaced.`) - } else { - // Wenn der Eintrag nicht vorhanden ist, füge ihn hinzu - data.push(req.body) - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] New entry with id ${req.body.id} added.`) - } +// Stable composite key for resume entries. Plain `id` matching is unreliable +// because Library/RSS items often don't carry an `id`; without a stable key +// every id-less save would either overwrite the first id-less entry or pile +// up duplicates. Mirror the resolution order frontend uses to dispatch +// playback (playlistid/showid/audiobookid/id), and fall back to artist::title +// as a last resort. +const resumeKeyOf = (m: { type?: string; id?: string; playlistid?: string; showid?: string; audiobookid?: string; artist?: string; title?: string }) => + [ + m?.type || '', + m?.playlistid || m?.showid || m?.audiobookid || m?.id || `${m?.artist || ''}::${m?.title || ''}`, + ].join('|') + +// Back-fill lastPlayedAt for legacy resume entries that pre-date the field. +// Reasoning: the previous addresume implementation did update-in-place when +// an entry already existed, so an item the user was actively replaying +// stayed at its original index — and idx 0 typically holds the item that +// was last replayed in-place. Set synthetic stamps so idx 0 gets the +// LARGEST stamp (most-recently-updated) and idx N the smallest. After +// frontend's DESC sort that places the user's last-replayed item at +// position 1 (left). Real saves use Date.now(), which is always larger +// than these synthetic stamps, so a fresh playback always wins. +// Idempotent: no-ops once every entry has a numeric stamp. +function backfillLastPlayedAt(data: any[], now: number): void { + const baseTime = now - data.length * 1000 - 60000 + const lastIdx = data.length - 1 + data.forEach((entry: any, idx: number) => { + if (typeof entry.lastPlayedAt !== 'number') { + // Invert: idx 0 → largest stamp (lastIdx ms), idx N → smallest. + entry.lastPlayedAt = baseTime + (lastIdx - idx) + } + }) +} - jsonfile.writeFile(resumeFile, data, { spaces: 4 }, (error) => { - if (error) throw error - res.status(200).send('ok') - }) - } - }) - fs.unlink(resumeLock, (err) => { - if (err) throw err - console.log( - `${new Date().toLocaleString()}: [MuPiBox-Server] /api/addresume - resume.json unlocked, locked file deleted!`, - ) - }) +// Resilient resume.json reader. ENOENT (fresh box, file not yet created) and +// JSON parse errors both used to leave the endpoint stuck — every save would +// 200 "error" until somebody manually fixed the file. Now: missing file is +// treated as "[]"; corrupt file is moved aside to resume.json.bak. +// (so it can still be inspected) and the live save proceeds against an empty +// array. The contract is "next save lands no matter what" — losing one +// session of accumulated resume entries on rare corruption beats wedging the +// feature for the rest of the box's lifetime. +const readResumeOrRecover = (context: string, cb: (data: any[]) => void) => { + jsonfile.readFile(resumeFile, (error, data) => { + if (!error) { + cb(Array.isArray(data) ? data : []) + return } + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') { + cb([]) + return + } + const backupPath = `${resumeFile}.bak.${Date.now()}` + fs.rename(resumeFile, backupPath, (renameErr) => { + if (renameErr) { + console.error( + `${new Date().toLocaleString()}: [MuPiBox-Server] ${context} - resume.json unreadable and archive failed (${renameErr.message}); starting fresh.`, + ) + } else { + console.warn( + `${new Date().toLocaleString()}: [MuPiBox-Server] ${context} - resume.json was unreadable, archived to ${backupPath} and starting fresh. Original error: ${error.message}`, + ) + } + cb([]) + }) + }) +} + +app.post('/api/addresume', (req, res) => { + if (fs.existsSync(resumeLock)) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/addresume resume.json is locked`) + res.status(200).send('locked') + return + } + try { + fs.openSync(resumeLock, 'w') } catch (err) { - console.error(err) + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/addresume failed to acquire lock:`, err) + res.status(200).send('error') + return } + readResumeOrRecover('/api/addresume', (data) => { + const now = Date.now() + const incomingKey = resumeKeyOf(req.body) + backfillLastPlayedAt(data, now) + // Always stamp the incoming entry — it was just played now, so it + // should sort to position 1 on the resume page after frontend's + // DESC sort by lastPlayedAt. + const incoming = { ...req.body, lastPlayedAt: now } + const index = data.findIndex((item: any) => resumeKeyOf(item) === incomingKey) + if (index !== -1) { + data[index] = incoming + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Resume entry replaced (key=${incomingKey}).`) + } else { + data.push(incoming) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Resume entry added (key=${incomingKey}).`) + } + jsonfile.writeFile(resumeFile, data, { spaces: 4 }, (writeError) => { + releaseLock(resumeLock, '/api/addresume') + if (writeError) { + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/addresume write failed:`, writeError) + res.status(500).send('error') + return + } + res.status(200).send('ok') + }) + }) }) -app.post('/api/delete', (req, res) => { +// Drop a single resume entry by composite key. Used by the backend-player +// when a library playlist finishes naturally (mplayer playlist-finish) so +// "weiterhören" doesn't keep offering the position of an album the kid has +// listened all the way through. Body shape mirrors a Media (only the key +// fields matter — type + one of playlistid/showid/audiobookid/id, or +// artist::title as a fallback). Idempotent: if no entry matches, 200 ok. +app.post('/api/deleteresume', (req, res) => { + if (fs.existsSync(resumeLock)) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/deleteresume resume.json is locked`) + res.status(200).send('locked') + return + } try { - if (fs.existsSync(dataLock)) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/delete data.json is locked`) - res.status(200).send('locked') - } else { - fs.openSync(dataLock, 'w') - jsonfile.readFile(dataFile, (error, data) => { - if (error) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/delete read data.json`) - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) - res.status(200).send('error') - } else { - data.splice(req.body.index, 1) - - jsonfile.writeFile(dataFile, data, { spaces: 4 }, (error) => { - if (error) throw error - res.status(200).send('ok') - }) - } - }) - fs.unlink(dataLock, (err) => { - if (err) throw err - console.log( - `${new Date().toLocaleString()}: [MuPiBox-Server] /api/delete - data.json unlocked, locked file deleted!`, - ) - }) - } + fs.openSync(resumeLock, 'w') } catch (err) { - console.error(err) + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/deleteresume failed to acquire lock:`, err) + res.status(200).send('error') + return } + readResumeOrRecover('/api/deleteresume', (data) => { + const targetKey = resumeKeyOf(req.body) + const remaining = data.filter((item: any) => resumeKeyOf(item) !== targetKey) + if (remaining.length === data.length) { + releaseLock(resumeLock, '/api/deleteresume') + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/deleteresume no entry matched (key=${targetKey}).`) + res.status(200).send('ok') + return + } + jsonfile.writeFile(resumeFile, remaining, { spaces: 4 }, (writeError) => { + releaseLock(resumeLock, '/api/deleteresume') + if (writeError) { + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/deleteresume write failed:`, writeError) + res.status(500).send('error') + return + } + console.log( + `${new Date().toLocaleString()}: [MuPiBox-Server] Resume entry removed (key=${targetKey}, ${data.length - remaining.length} match(es)).`, + ) + res.status(200).send('ok') + }) + }) }) -app.post('/api/edit', (req, res) => { +app.post('/api/delete', (req, res) => { + if (fs.existsSync(dataLock)) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/delete data.json is locked`) + res.status(200).send('locked') + return + } try { - if (fs.existsSync(dataLock)) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/edit data.json is locked`) - res.status(200).send('locked') - } else { - fs.openSync(dataLock, 'w') - jsonfile.readFile(dataFile, (error, data) => { - if (error) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/edit read data.json`) - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) - res.status(200).send('error') - } else { - data.splice(req.body.index, 1, req.body.data) - - jsonfile.writeFile(dataFile, data, { spaces: 4 }, (error) => { - if (error) throw error - res.status(200).send('ok') - }) - } - }) - fs.unlink(dataLock, (err) => { - if (err) throw err - console.log( - `${new Date().toLocaleString()}: [MuPiBox-Server] /api/edit - data.json unlocked, locked file deleted!`, - ) - }) - } + fs.openSync(dataLock, 'w') } catch (err) { - console.error(err) + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/delete failed to acquire lock:`, err) + res.status(200).send('error') + return } + jsonfile.readFile(dataFile, (error, data) => { + if (error) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/delete read data.json`) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) + releaseLock(dataLock, '/api/delete') + res.status(200).send('error') + return + } + data.splice(req.body.index, 1) + jsonfile.writeFile(dataFile, data, { spaces: 4 }, (writeError) => { + releaseLock(dataLock, '/api/delete') + if (writeError) { + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/delete write failed:`, writeError) + res.status(500).send('error') + return + } + res.status(200).send('ok') + }) + }) }) -app.post('/api/editresume', (req, res) => { +app.post('/api/edit', (req, res) => { + if (fs.existsSync(dataLock)) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/edit data.json is locked`) + res.status(200).send('locked') + return + } try { - if (fs.existsSync(resumeLock)) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/editresume resume.json is locked`) - res.status(200).send('locked') - } else { - fs.openSync(resumeLock, 'w') - jsonfile.readFile(resumeFile, (error, data) => { - if (error) { - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/editresume read resume.json`) - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) - res.status(200).send('error') - } else { - // Prüfe, ob die ID bereits im Array existiert - const existingIndex = data.findIndex((item: { id: any }) => item.id === req.body.data.id) - - if (existingIndex !== -1) { - // Ersetze den vorhandenen Eintrag mit derselben ID - data[existingIndex] = req.body.data - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Entry with id ${req.body.data.id} replaced.`) - } else { - // Bestimme den zu verwendenden Index basierend auf der Array-Länge - const indexToReplace = Math.min(req.body.index, data.length - 1) - - // Ersetze den Eintrag am berechneten Index oder füge hinzu - data.splice(indexToReplace, 1, req.body.data) - console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Entry at index ${indexToReplace} replaced.`) - } - - // Speichere die geänderten Daten zurück in die Datei - jsonfile.writeFile(resumeFile, data, { spaces: 4 }, (error) => { - if (error) throw error - res.status(200).send('ok') - }) - } - }) - fs.unlink(resumeLock, (err) => { - if (err) throw err - console.log( - `${new Date().toLocaleString()}: [MuPiBox-Server] /api/editresume - resume.json unlocked, locked file deleted!`, - ) - }) - } + fs.openSync(dataLock, 'w') } catch (err) { - console.error(err) + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/edit failed to acquire lock:`, err) + res.status(200).send('error') + return } + jsonfile.readFile(dataFile, (error, data) => { + if (error) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/edit read data.json`) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) + releaseLock(dataLock, '/api/edit') + res.status(200).send('error') + return + } + data.splice(req.body.index, 1, req.body.data) + jsonfile.writeFile(dataFile, data, { spaces: 4 }, (writeError) => { + releaseLock(dataLock, '/api/edit') + if (writeError) { + console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/edit write failed:`, writeError) + res.status(500).send('error') + return + } + res.status(200).send('ok') + }) + }) }) app.get('/api/spotify/config', (_req, res) => { diff --git a/src/backend-api/src/services/spotify-api.service.ts b/src/backend-api/src/services/spotify-api.service.ts index fa1be57b..90e24bfd 100644 --- a/src/backend-api/src/services/spotify-api.service.ts +++ b/src/backend-api/src/services/spotify-api.service.ts @@ -142,6 +142,28 @@ export class SpotifyApiService { } } + // B6: hard upper bound on a single Spotify SDK call. The SDK's + // underlying fetch has no built-in timeout, and a TCP-level stall + // (no FIN, no RST, just silence from the upstream) would leave this + // promise pending forever. The pendingRequests entry in queueRequest + // never settles, so every subsequent same-key request also hangs — + // and the queue stops processing because isProcessingQueue stays + // true. 20s is generous: the slowest legitimate response we see is + // ~3-4s for an audiobook with hundreds of chapters. + private static readonly SPOTIFY_REQUEST_TIMEOUT_MS = 20000 + + private async withTimeout(operation: () => Promise, ms: number): Promise { + let timer: NodeJS.Timeout | undefined + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Spotify request timed out after ${ms}ms`)), ms) + }) + try { + return await Promise.race([operation(), timeoutPromise]) + } finally { + if (timer) clearTimeout(timer) + } + } + private async rateLimitedRequest(operation: () => Promise): Promise { // Implement simple rate limiting const now = Date.now() @@ -153,7 +175,7 @@ export class SpotifyApiService { try { this.lastRequestTime = Date.now() - return await operation() + return await this.withTimeout(operation, SpotifyApiService.SPOTIFY_REQUEST_TIMEOUT_MS) } catch (error: any) { if (error.statusCode === 429) { // Rate limited - wait and retry diff --git a/src/backend-api/src/services/spotify-media-info.service.ts b/src/backend-api/src/services/spotify-media-info.service.ts index 079379e9..6588bcc5 100644 --- a/src/backend-api/src/services/spotify-media-info.service.ts +++ b/src/backend-api/src/services/spotify-media-info.service.ts @@ -46,11 +46,16 @@ export class SpotifyMediaInfo { console.debug(`${logPrefix} Fetching playlist data from Spotify Embed: ${playlistId}`) const embedUrl = `https://open.spotify.com/embed/playlist/${playlistId}` + // B6: AbortSignal.timeout() ensures a stalled embed-page fetch + // can't hang the foreground request indefinitely (no built-in + // fetch timeout in Node). 20s matches the SDK timeout in + // SpotifyApiService. const response = await fetch(embedUrl, { headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36', }, + signal: AbortSignal.timeout(20000), }) if (!response.ok) { diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js index 732da894..23bc7267 100644 --- a/src/backend-player/src/spotify-control.js +++ b/src/backend-player/src/spotify-control.js @@ -122,6 +122,56 @@ player.on('track-change', () => { cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Local.py') }) +player.on('playlist-finish', () => { + // Library album finished naturally — drop its resume entry so the user + // isn't offered "weiterhören" at the very end next time. Spotify and RSS + // are skipped: Spotify gives no clean end-of-album signal via the + // mplayer wrapper anyway, and RSS isn't tracked with enough metadata in + // currentMeta to build a composite key. + deleteResumeForFinishedLibraryAlbum() +}) + +// POSTs a minimal Media-shape body to /api/deleteresume so the backend-api +// removes the matching resume.json entry by composite key. Best-effort: a +// failure here just leaves a stale resume entry, no playback impact. +function deleteResumeForFinishedLibraryAlbum() { + if (currentMeta.currentPlayer !== 'mplayer') return + if (currentMeta.currentType !== 'local') return + const rawPath = currentMeta.path + if (!rawPath) return + const parts = String(rawPath).split('/') + if (parts.length < 3) return + const body = JSON.stringify({ + type: 'library', + artist: decodeURIComponent(parts[1]), + title: decodeURIComponent(parts[2]), + }) + const req = http.request( + { + host: '127.0.0.1', + port: 8200, + path: '/api/deleteresume', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }, + (response) => { + response.resume() // drain + log.debug( + `${nowDate.toLocaleString()}: [Spotify Control] deleteresume status=${response.statusCode} for ${rawPath}`, + ) + }, + ) + req.on('error', (err) => { + log.debug(`${nowDate.toLocaleString()}: [Spotify Control] deleteresume failed: ${err.message}`) + }) + req.write(body) + req.end() +} + + setInterval(() => { const cmdVolume = "/usr/bin/amixer sget Master | grep 'Right:'" const exec = require('node:child_process').exec diff --git a/src/frontend-box/src/app/media.service.ts b/src/frontend-box/src/app/media.service.ts index e4452f2f..305fc11e 100644 --- a/src/frontend-box/src/app/media.service.ts +++ b/src/frontend-box/src/app/media.service.ts @@ -1,13 +1,13 @@ import { HttpClient } from '@angular/common/http' import { Injectable } from '@angular/core' import { firstValueFrom, from, iif, interval, Observable, of, Subject } from 'rxjs' -import { map, mergeAll, mergeMap, shareReplay, switchMap, toArray } from 'rxjs/operators' +import { catchError, map, mergeAll, mergeMap, shareReplay, switchMap, toArray } from 'rxjs/operators' import { environment } from '../environments/environment' import type { AlbumStop } from './albumstop' import type { Artist } from './artist' import type { CurrentMPlayer } from './current.mplayer' import type { CurrentSpotify } from './current.spotify' -import type { CategoryType, Media, MediaInfoCache } from './media' +import { isResumeEntry, type CategoryType, type Media, type MediaInfoCache } from './media' import { Mupihat } from './mupihat' import type { Network } from './network' import { NetworkService } from './network.service' @@ -158,25 +158,52 @@ export class MediaService { }), shareReplay({ bufferSize: 1, refCount: true }), ) - : // Remote: HTTP polling + : // Remote: HTTP polling. + // B11: a single HTTP failure (network blip, backend restart) + // would error the source observable, and shareReplay would + // forever replay that error to subscribers — UI stops getting + // state updates until the page is reloaded. Wrap the inner + // get in catchError(of({})) so transient failures show as + // "no current state" without tearing down the polling stream. interval(10000).pipe( switchMap( - (): Observable => this.http.get(`${this.getPlayerBackendUrl()}/state`), + (): Observable => + this.http + .get(`${this.getPlayerBackendUrl()}/state`) + .pipe(catchError(() => of({} as CurrentSpotify))), ), shareReplay({ bufferSize: 1, refCount: true }), ) + // Same B11 pattern for local$ / albumStop$ / mupihat$ — all polling + // streams that should swallow transient errors instead of becoming + // permanently broken. this.local$ = interval(1000).pipe( - switchMap((): Observable => this.http.get(`${this.getPlayerBackendUrl()}/local`)), + switchMap( + (): Observable => + this.http + .get(`${this.getPlayerBackendUrl()}/local`) + .pipe(catchError(() => of({} as CurrentMPlayer))), + ), shareReplay({ bufferSize: 1, refCount: true }), ) this.albumStop$ = interval(1000).pipe( - switchMap((): Observable => this.http.get(`${this.getApiBackendUrl()}/albumstop`)), + switchMap( + (): Observable => + this.http + .get(`${this.getApiBackendUrl()}/albumstop`) + .pipe(catchError(() => of({} as AlbumStop))), + ), shareReplay({ bufferSize: 1, refCount: false }), ) // Every 2 seconds should be enough for timely charging update. this.mupihat$ = interval(2000).pipe( - switchMap((): Observable => this.http.get(`${this.getApiBackendUrl()}/mupihat`)), + switchMap( + (): Observable => + this.http + .get(`${this.getApiBackendUrl()}/mupihat`) + .pipe(catchError(() => of({} as Mupihat))), + ), shareReplay({ bufferSize: 1, refCount: false }), ) @@ -283,18 +310,6 @@ export class MediaService { }) } - editRawResumeAtIndex(index: number, data: Media) { - const url = `${this.getApiBackendUrl()}/editresume` - const body = { - index, - data, - } - - this.http.post(url, body, { responseType: 'text' }).subscribe((response) => { - this.response = response - }) - } - addRawResume(media: Media) { const url = `${this.getApiBackendUrl()}/addresume` @@ -411,9 +426,18 @@ export class MediaService { public fetchActiveResumeData(): Observable { // Category is irrelevant if 'resume' is set to true. + // Sort by lastPlayedAt DESC so "most recently played" is at position 1. + // Previously the page used a blind `.reverse()` of the array, which + // matches the file insertion order — but addresume updates existing + // entries in place (preserving their position) so a freshly-played + // album never moved to the top until it was a *new* entry. Items + // without a timestamp (legacy entries pre-migration) sort to 0 and + // land at the bottom; the backend back-fills synthetic stamps + // preserving original order on the next addresume so this is + // self-healing. return this.updateMedia(`${this.getApiBackendUrl()}/activeresume`, true, 'resume').pipe( map((media: Media[]) => { - return media.reverse() + return [...media].sort((a, b) => (b.lastPlayedAt ?? 0) - (a.lastPlayedAt ?? 0)) }), ) } @@ -424,18 +448,25 @@ export class MediaService { // Get the media data for the current category from the server private updateMedia(url: string, resume: boolean, category: CategoryType): Observable { - // Custom rxjs pipe to override artist. + // Custom rxjs pipe applied to every iif-branch's service-call output. + // Carries the original item's user-relevant fields onto the Media that + // the spotify/rss/library service builds out of upstream API data: + // - artist: optional user-defined override + // - lastPlayedAt: ResumePage sorts DESC by this; spotify.service's + // getMediaByID etc. don't accept it as a param, so without this carry + // the field gets dropped on every resume entry that goes through a + // service call. fetchActiveResumeData's sort then sees zeros and the + // user's most-recently-played item ends up at a random swiper position. + // - isResume: marks resume entries; same loss-on-service-call risk. const overwriteArtist = (item: Media) => (source$: Observable): Observable => { return source$.pipe( - // If the user entered an user-defined artist name in addition to a query, - // overwrite orignal artist from spotify. map((items) => { - if (item.artist?.length > 0) { - for (const currentItem of items) { - currentItem.artist = item.artist - } + for (const currentItem of items) { + if (item.artist?.length > 0) currentItem.artist = item.artist + if (typeof item.lastPlayedAt === 'number') currentItem.lastPlayedAt = item.lastPlayedAt + if (item.isResume === true) currentItem.isResume = true } return items }), @@ -472,13 +503,13 @@ export class MediaService { .pipe(overwriteArtist(item)), iif( // Get media by show - () => !!(item.showid && item.showid.length > 0 && item.category !== 'resume'), + () => !!(item.showid && item.showid.length > 0 && !isResumeEntry(item)), this.spotifyService .getMediaByShowID(item.showid, item.category, item.index, item) .pipe(overwriteArtist(item)), iif( // Get media by show supporting resume - () => !!(item.showid && item.showid.length > 0 && item.category === 'resume'), + () => !!(item.showid && item.showid.length > 0 && isResumeEntry(item)), this.spotifyService .getMediaByEpisode( item.showid, @@ -513,8 +544,19 @@ export class MediaService { overwriteArtist(item), ), iif( - // Get media by rss feed - () => !!(item.type === 'rss' && item.id.length > 0 && item.category !== 'resume'), + // Get media by rss feed. + // MED-10 attempted to enrich RSS resume entries with + // fresh feed data, but the `id` of a RSS resume entry + // is the *episode's MP3 URL*, not the channel feed + // URL — the enrichment fetch streamed the MP3 audio + // (multi-MB) into the rss-parser path before MED-2's + // size-cap aborted with 413. Six RSS resume entries + // = ~24 s freeze on the resume page. Reinstate the + // resume-skip gate: every persisted field needed for + // the resume tile (title, cover, artistcover, release + // date, duration, progress) is already on disk; no + // network round-trip needed for resume rendering. + () => !!(item.type === 'rss' && item.id.length > 0 && !isResumeEntry(item)), this.rssFeedService .getRssFeed(item.id, item.category, item.index, item) .pipe(overwriteArtist(item)), @@ -699,7 +741,13 @@ export class MediaService { mediaType, } - return mediaInfo + // MED-7: cache-hit branch (line 645+) returns this.mediaInfoCache + // which has currentId + mediaType, but the previous miss-branch + // returned the raw mediaInfo without those fields. Callers that + // checked `result.mediaType` saw different shapes depending on + // whether the entry was already cached. Return the cache object + // we just wrote so the shape is consistent across hits and misses. + return this.mediaInfoCache } } catch (error) { console.warn('Failed to get media info for URI:', contextUri, error) diff --git a/src/frontend-box/src/app/media.ts b/src/frontend-box/src/app/media.ts index 147073c5..e29d73f6 100644 --- a/src/frontend-box/src/app/media.ts +++ b/src/frontend-box/src/app/media.ts @@ -21,6 +21,11 @@ export interface Media { cover?: string type: string category: CategoryType + // Marks this Media as a resume entry. New code uses this flag exclusively; + // the historical convention of overwriting `category` with the literal + // 'resume' is still recognised on read for entries written by older + // versions, but no longer produced. + isResume?: boolean artistcover?: string shuffle?: boolean aPartOfAll?: boolean @@ -36,8 +41,29 @@ export interface Media { resumelocalcurrentTracknr?: number resumelocalprogressTime?: number resumerssprogressTime?: number + // Marks an item whose Spotify metadata fetch failed (network blip, + // region lock, removed from catalogue, etc.). Set by spotify.service's + // catchError fallbacks so the item still occupies its slot in the list + // instead of silently vanishing — callers / templates can render it + // greyed-out or with an "unavailable" badge later. + unavailable?: boolean + // Set by /api/addresume to Date.now() on every save. Frontend sorts the + // resume page by this DESC so "most recently played" lands at position 1 + // even when the entry was already in the file (addresume's update-in- + // place pattern leaves the array index untouched). Optional because + // pre-existing entries written before this field was introduced will be + // back-filled lazily by the backend with synthetic stamps preserving + // file order. + lastPlayedAt?: number } +// Reads as "is this Media a resume entry?" — true for entries written by the +// new isResume-flag path AND for legacy entries where category was overwritten +// with 'resume'. Use everywhere instead of bare category comparisons so the +// same filter works through the migration window. +export const isResumeEntry = (m: Pick | null | undefined): boolean => + !!m && (m.isResume === true || m.category === 'resume') + // Cache interface for storing album/playlist/show/audiobook information export interface MediaInfoCache { total_tracks?: number diff --git a/src/frontend-box/src/app/utils.ts b/src/frontend-box/src/app/utils.ts index b6c6fcd4..e14c0e34 100644 --- a/src/frontend-box/src/app/utils.ts +++ b/src/frontend-box/src/app/utils.ts @@ -2,7 +2,7 @@ import type { Media } from './media' export type ExtraDataMedia = Pick< Media, - 'artistcover' | 'shuffle' | 'aPartOfAll' | 'aPartOfAllMin' | 'aPartOfAllMax' | 'sorting' + 'artistcover' | 'shuffle' | 'aPartOfAll' | 'aPartOfAllMin' | 'aPartOfAllMax' | 'sorting' | 'lastPlayedAt' > export namespace Utils { @@ -13,7 +13,21 @@ export namespace Utils { * @param target - The target to which the values of the properties will be copied. */ export const copyExtraMediaData = (source: ExtraDataMedia, target: Media): void => { - const keys = ['artistcover', 'shuffle', 'aPartOfAll', 'aPartOfAllMin', 'aPartOfAllMax', 'sorting'] + // lastPlayedAt MUST be in this list: media.service.updateMedia replaces + // every resume entry with a Spotify/RSS-derived Media. If lastPlayedAt + // doesn't survive the round-trip, fetchActiveResumeData's DESC sort + // sees only zeros and the resume page falls back to mergeMap-completion + // order — which makes the most-recently-played item appear at a random + // position (typically the right end of the swiper). + const keys = [ + 'artistcover', + 'shuffle', + 'aPartOfAll', + 'aPartOfAllMin', + 'aPartOfAllMax', + 'sorting', + 'lastPlayedAt', + ] for (const key of keys) { if (source[key] != null) { target[key] = source[key]