// Local cover-art storage for stations. // // Files live under /data/images/stations/. and are served by // express.static at /media/stations/.. The DB tracks just the // relative path in stations.image_path (e.g. "stations/12.jpg"), while // stations.image_url keeps the original remote URL for refetch/debugging. import { mkdirSync, existsSync, writeFileSync, readdirSync, unlinkSync, statSync } from 'node:fs'; import { resolve, join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { getDb } from '../db/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); // Where cover art lives. Overridable via env so Electron (packaged) can point // it at app.getPath('userData')/data/images instead of the read-only asar dir. const ROOT = process.env.ORADIO_IMAGE_ROOT ? resolve(process.env.ORADIO_IMAGE_ROOT) : resolve(__dirname, '..', '..', 'data', 'images'); const STATIONS_DIR = join(ROOT, 'stations'); // A browser-like UA is required by Wikimedia and several CDNs; an opaque // UA like "OnlineRadioExplorer/0.1" gets HTTP 400/403 from upload.wikimedia.org. // Override via env if you want to publish a contact URL. const UA = process.env.IMAGE_FETCH_UA || 'Mozilla/5.0 (compatible; OnlineRadioExplorer/0.1; +https://github.com/marcoheine/onlineRadioExplorer)'; const FETCH_TIMEOUT_MS = 10_000; const MAX_BYTES = 4 * 1024 * 1024; // 4 MB per image const MIME_EXT = { 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/pjpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/svg+xml': 'svg', 'image/x-icon': 'ico', 'image/vnd.microsoft.icon': 'ico' }; export function ensureImageDirs() { mkdirSync(STATIONS_DIR, { recursive: true }); } export function getImageRoot() { return ROOT; } // Strip any existing file for this station id (with any extension). function removeExistingStationFile(id) { if (!existsSync(STATIONS_DIR)) return; const prefix = `${id}.`; for (const f of readdirSync(STATIONS_DIR)) { if (f.startsWith(prefix)) { try { unlinkSync(join(STATIONS_DIR, f)); } catch { } } } } function extFromMime(mime) { if (!mime) return null; const base = mime.split(';')[0].trim().toLowerCase(); return MIME_EXT[base] || null; } function extFromMagic(buf) { if (buf.length < 12) return null; // PNG if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return 'png'; // JPEG if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return 'jpg'; // GIF if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'gif'; // WEBP: "RIFF...WEBP" if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return 'webp'; // SVG: starts with " ctl.abort(), FETCH_TIMEOUT_MS); try { const res = await fetch(url, { headers: { 'User-Agent': UA, 'Accept': 'image/*' }, redirect: 'follow', signal: ctl.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const ct = res.headers.get('content-type') || ''; const reader = res.body?.getReader(); if (!reader) throw new Error('no body'); const chunks = []; let received = 0; while (true) { const { done, value } = await reader.read(); if (done) break; received += value.length; if (received > MAX_BYTES) { try { await reader.cancel(); } catch { } throw new Error('too large'); } chunks.push(Buffer.from(value)); } return { buffer: Buffer.concat(chunks), contentType: ct }; } finally { clearTimeout(t); } } /** * Persist a buffer as the station's cover-art file and update the DB. * Returns the relative path (e.g. "stations/12.jpg"). */ export function saveStationImageFromBuffer(stationId, buf, mime, { source = 'upload' } = {}) { ensureImageDirs(); // Reject obvious HTML responses (404 pages, SPA index, login walls) even // when the upstream lies about the content-type. const head = buf.slice(0, 512).toString('utf8').trimStart().toLowerCase(); if (head.startsWith('