// RangerHQ Tuner — History, Favourites, and 4-button search URLs. // // Mirrors rangerhq-radio's inc/history.php exactly (verified 2026-06-08). // Differences from the WP plugin: // • Chrome has one user per profile, so we use chrome.storage.local, // not per-user usermeta. // • All keys are namespaced under "tuner.*" so clearAll() is trivial. // // Entry shape (mirror WP plugin): // { artist: string, title: string, station: string, stationId: string, at: number } // // Rules (mirror WP plugin): // • Cap at historyCap entries (default 500), FIFO. // • Dedup against the LAST entry only (sig = lower(artist|title|stationId)). // • Skip entries where artist === "(unknown)" (SomaFM dead-air placeholder). export const HISTORY_KEY = 'tuner.history'; export const FAVS_KEY = 'tuner.favourites'; export const CAP_KEY = 'tuner.historyCap'; export const DEFAULT_CAP = 500; export const MIN_CAP = 50; export const MAX_CAP = 500; /* ---------- Safe storage accessor ---------- * chrome.storage is not guaranteed in every extension context (e.g. some * older Chrome versions hide it from offscreen documents). Anywhere it * isn't present, our readers return empty defaults and writers no-op * instead of throwing. This way history.js is safe to import from any * surface — UI pages get real storage, edge contexts degrade silently. */ function storage() { if (typeof chrome === 'undefined') return null; if (!chrome.storage || !chrome.storage.local) return null; return chrome.storage.local; } /* ---------- Readers ---------- */ export async function getHistory() { const s = storage(); if (!s) return []; const o = await s.get(HISTORY_KEY); return Array.isArray(o[HISTORY_KEY]) ? o[HISTORY_KEY] : []; } export async function getFavourites() { const s = storage(); if (!s) return []; const o = await s.get(FAVS_KEY); return Array.isArray(o[FAVS_KEY]) ? o[FAVS_KEY] : []; } export async function getHistoryCap() { const s = storage(); if (!s) return DEFAULT_CAP; const o = await s.get(CAP_KEY); const n = o[CAP_KEY]; return (typeof n === 'number' && n >= MIN_CAP && n <= MAX_CAP) ? n : DEFAULT_CAP; } /* ---------- Sanitisation + dedup ---------- */ function sanitizeEntry(entry) { if (!entry || typeof entry !== 'object') return null; const artist = String(entry.artist || '').trim().slice(0, 200); const title = String(entry.title || '').trim().slice(0, 200); const station = String(entry.station || '').trim().slice(0, 100); const stationId = String(entry.stationId || '').trim().slice(0, 100); if (!artist || !title) return null; if (artist.toLowerCase() === '(unknown)') return null; return { artist, title, station, stationId, at: Date.now() }; } export function entrySignature(e) { return [ (e.artist || '').toLowerCase().trim(), (e.title || '').toLowerCase().trim(), (e.stationId || '').toLowerCase().trim(), ].join('|'); } /* ---------- Mutations ---------- */ /** Append a track to history (deduped + capped). Returns true if appended. */ export async function logTrack(entry) { const s = storage(); if (!s) return false; const clean = sanitizeEntry(entry); if (!clean) return false; const history = await getHistory(); if (history.length > 0) { const lastSig = entrySignature(history[history.length - 1]); if (entrySignature(clean) === lastSig) return false; } history.push(clean); const cap = await getHistoryCap(); if (history.length > cap) history.splice(0, history.length - cap); await s.set({ [HISTORY_KEY]: history }); return true; } /** Toggle favourite. Returns new state: true = now favourited, false = removed. */ export async function toggleFavourite(entry) { const s = storage(); if (!s) return false; const clean = sanitizeEntry({ ...entry, at: 0 }); // at gets reset if (!clean) return false; const sig = entrySignature(clean); const favs = await getFavourites(); const found = favs.findIndex(f => entrySignature(f) === sig); if (found >= 0) { favs.splice(found, 1); await s.set({ [FAVS_KEY]: favs }); return false; } clean.at = Date.now(); favs.push(clean); await s.set({ [FAVS_KEY]: favs }); return true; } export async function isFavourited(entry) { const clean = sanitizeEntry({ ...entry, at: 0 }); if (!clean) return false; const sig = entrySignature(clean); const favs = await getFavourites(); return favs.some(f => entrySignature(f) === sig); } export async function clearHistory() { const s = storage(); if (!s) return; await s.remove(HISTORY_KEY); } export async function clearFavourites() { const s = storage(); if (!s) return; await s.remove(FAVS_KEY); } /** Wipe every `tuner.*` key. Used by the "Clear EVERYTHING" button. */ export async function clearAll() { const s = storage(); if (!s) return; const all = await s.get(null); const keys = Object.keys(all).filter(k => k.startsWith('tuner.')); if (keys.length) await s.remove(keys); } export async function setHistoryCap(n) { const s = storage(); if (!s) return DEFAULT_CAP; const cap = Math.max(MIN_CAP, Math.min(MAX_CAP, Math.round(Number(n) || DEFAULT_CAP))); await s.set({ [CAP_KEY]: cap }); const history = await getHistory(); if (history.length > cap) { await s.set({ [HISTORY_KEY]: history.slice(-cap) }); } return cap; } /* ---------- 4-button search URLs ---------- */ // Mirrors WP plugin lines 126-129 verbatim. NO auth, NO API key, NO quota. // Public search URLs that open in a new tab — user picks what to listen to. export function searchUrls(artist, title) { const enc = encodeURIComponent(`${artist || ''} ${title || ''}`.trim()); return { spotify: `https://open.spotify.com/search/${enc}`, youtube: `https://www.youtube.com/results?search_query=${enc}`, apple: `https://music.apple.com/search?term=${enc}`, bandcamp: `https://bandcamp.com/search?q=${enc}`, }; } /* ---------- Display helpers ---------- */ export function formatRelativeTime(ts) { const diffSec = Math.floor((Date.now() - ts) / 1000); if (diffSec < 60) return 'just now'; if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; if (diffSec < 604800) return `${Math.floor(diffSec / 86400)}d ago`; return new Date(ts).toLocaleDateString(); }