feat: persist station sync catalogs and status

This commit is contained in:
2026-04-29 16:54:05 +02:00
parent 8bd9106ff3
commit 10a239a155
7 changed files with 642 additions and 59 deletions
+73 -4
View File
@@ -3,7 +3,7 @@
// //
// This value is rewritten automatically before each build so deployed clients // This value is rewritten automatically before each build so deployed clients
// refresh to the newest shell and cached assets. // refresh to the newest shell and cached assets.
const CACHE_NAME = 'radioplayer-pwa-v5-1777473175316'; const CACHE_NAME = 'radioplayer-pwa-v5-1777474420392';
const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-v1'; const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-v1';
const MANAGED_CATALOG_CACHE_NAME = 'radioplayer-managed-catalog-v1'; const MANAGED_CATALOG_CACHE_NAME = 'radioplayer-managed-catalog-v1';
const RADIO_BROWSER_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/stations/search'; const RADIO_BROWSER_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/stations/search';
@@ -377,10 +377,23 @@ async function refreshManagedCatalogCache() {
const response = await fetch(url, { cache: 'no-store' }); const response = await fetch(url, { cache: 'no-store' });
if (isCacheableResponse(response)) { if (isCacheableResponse(response)) {
await managedCatalogCache.put(url, response); await managedCatalogCache.put(url, response);
return { updated: true, error: null };
} }
return {
updated: false,
error: `Unexpected managed catalog response: ${response.status} ${response.statusText}`.trim(),
};
} catch { } catch {
// Network unavailable — keep existing cache as-is. // Network unavailable — keep existing cache as-is.
} }
return { updated: false, error: 'Managed catalog request failed.' };
}
async function notifyClients(message) {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
await Promise.allSettled(clients.map((client) => client.postMessage(message)));
} }
async function respondWithManagedCatalog(request) { async function respondWithManagedCatalog(request) {
@@ -530,7 +543,23 @@ self.addEventListener('activate', (event) => {
self.addEventListener('sync', (event) => { self.addEventListener('sync', (event) => {
if (event.tag !== STATION_SYNC_TAG) return; if (event.tag !== STATION_SYNC_TAG) return;
event.waitUntil(syncRadioStations('background-sync')); event.waitUntil(
syncRadioStations('background-sync')
.then((meta) => notifyClients({
type: 'station-catalog-updated',
reason: 'background-sync',
meta,
}))
.catch(async (error) => {
await notifyClients({
type: 'station-catalog-update-failed',
reason: 'background-sync',
error: error instanceof Error ? error.message : 'Unknown error',
failedAt: new Date().toISOString(),
});
throw error;
})
);
}); });
self.addEventListener('message', (event) => { self.addEventListener('message', (event) => {
@@ -544,6 +573,14 @@ self.addEventListener('message', (event) => {
? await syncRadioStations('country-selection-update', syncSettings.selectedCountryCodes) ? await syncRadioStations('country-selection-update', syncSettings.selectedCountryCodes)
: null; : null;
if (syncResult) {
await notifyClients({
type: 'station-catalog-updated',
reason: 'country-selection-update',
meta: syncResult,
});
}
event.ports?.[0]?.postMessage({ event.ports?.[0]?.postMessage({
ok: true, ok: true,
selectedCountryCodes: syncSettings.selectedCountryCodes, selectedCountryCodes: syncSettings.selectedCountryCodes,
@@ -560,11 +597,43 @@ self.addEventListener('message', (event) => {
self.addEventListener('periodicsync', (event) => { self.addEventListener('periodicsync', (event) => {
if (event.tag === STATION_PERIODIC_SYNC_TAG) { if (event.tag === STATION_PERIODIC_SYNC_TAG) {
event.waitUntil(syncRadioStations('periodic-sync')); event.waitUntil(
syncRadioStations('periodic-sync')
.then((meta) => notifyClients({
type: 'station-catalog-updated',
reason: 'periodic-sync',
meta,
}))
.catch(async (error) => {
await notifyClients({
type: 'station-catalog-update-failed',
reason: 'periodic-sync',
error: error instanceof Error ? error.message : 'Unknown error',
failedAt: new Date().toISOString(),
});
throw error;
})
);
return; return;
} }
if (event.tag === MANAGED_CATALOG_PERIODIC_SYNC_TAG) { if (event.tag === MANAGED_CATALOG_PERIODIC_SYNC_TAG) {
event.waitUntil(refreshManagedCatalogCache()); event.waitUntil(
refreshManagedCatalogCache().then((result) => {
if (!result.updated) {
return notifyClients({
type: 'managed-catalog-update-failed',
reason: 'periodic-sync',
error: result.error || 'Unknown error',
failedAt: new Date().toISOString(),
});
}
return notifyClients({
type: 'managed-catalog-updated',
reason: 'periodic-sync',
});
})
);
} }
}); });
+5
View File
@@ -199,6 +199,11 @@ function TrackInfo() {
</span> </span>
</div> </div>
<div id="managed-catalog-status" className="managed-catalog-status hidden" aria-live="polite" /> <div id="managed-catalog-status" className="managed-catalog-status hidden" aria-live="polite" />
<div id="catalog-sync-status" className="catalog-sync-status hidden" aria-live="polite">
<span id="catalog-sync-status-text" />
<time id="catalog-sync-status-time" className="catalog-sync-status-time" />
</div>
<div id="catalog-sync-history" className="catalog-sync-history hidden" aria-live="polite" />
<div id="cast-output-row" className="cast-output-row hidden" aria-live="polite"> <div id="cast-output-row" className="cast-output-row hidden" aria-live="polite">
<span className="cast-output-label">Output:</span> <span className="cast-output-label">Output:</span>
<button id="cast-output-btn" className="cast-output-toggle" aria-pressed="false" <button id="cast-output-btn" className="cast-output-toggle" aria-pressed="false"
+279 -27
View File
@@ -1,6 +1,10 @@
import { loadRadioStations } from './radio/loadRadioStations.ts'; import { loadPersistedRadioStations, loadRadioStations } from './radio/loadRadioStations.ts';
import { defaultSelectedRadioCountryCodes, managedCountryCode, radioCountries } from './radio/radioCountries.ts'; import { defaultSelectedRadioCountryCodes, managedCountryCode, radioCountries } from './radio/radioCountries.ts';
import { getLastManagedCatalogSource, loadManagedStations } from './radio/loadManagedStations.ts'; import {
getLastManagedCatalogSource,
loadManagedStations,
loadPersistedManagedStations,
} from './radio/loadManagedStations.ts';
import { import {
clearPlayerPersistence, clearPlayerPersistence,
deletePersistedUserStationById, deletePersistedUserStationById,
@@ -83,6 +87,7 @@ let stationCountryFilterOpen = false;
let artworkShineTimeoutId = null; let artworkShineTimeoutId = null;
let artworkShineClearTimeoutId = null; let artworkShineClearTimeoutId = null;
let stationTitleRafId = null; let stationTitleRafId = null;
let catalogSyncStatusTimeoutId = null;
const STATION_LIBRARY_PAGE_SIZE = 24; const STATION_LIBRARY_PAGE_SIZE = 24;
const RECENT_STATION_HISTORY_LIMIT = 40; const RECENT_STATION_HISTORY_LIMIT = 40;
@@ -98,6 +103,10 @@ const stationSubtitleEl = document.getElementById('station-subtitle');
const stationHealthSummaryEl = document.getElementById('station-health-summary'); const stationHealthSummaryEl = document.getElementById('station-health-summary');
const stationHealthDetailEl = document.getElementById('station-health-detail'); const stationHealthDetailEl = document.getElementById('station-health-detail');
const managedCatalogStatusEl = document.getElementById('managed-catalog-status'); const managedCatalogStatusEl = document.getElementById('managed-catalog-status');
const catalogSyncStatusEl = document.getElementById('catalog-sync-status');
const catalogSyncStatusTextEl = document.getElementById('catalog-sync-status-text');
const catalogSyncStatusTimeEl = document.getElementById('catalog-sync-status-time');
const catalogSyncHistoryEl = document.getElementById('catalog-sync-history');
const nowPlayingEl = document.getElementById('now-playing'); const nowPlayingEl = document.getElementById('now-playing');
const nowArtistEl = document.getElementById('now-artist'); const nowArtistEl = document.getElementById('now-artist');
const nowTitleEl = document.getElementById('now-title'); const nowTitleEl = document.getElementById('now-title');
@@ -200,6 +209,11 @@ const IMPORT_EXPORT_VERSION = 1;
const PLAYBACK_START_TIMEOUT_MS = 12000; const PLAYBACK_START_TIMEOUT_MS = 12000;
const RADIO_BROWSER_COUNTRIES_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/countries'; const RADIO_BROWSER_COUNTRIES_API_ENDPOINT = 'https://de1.api.radio-browser.info/json/countries';
const SERVICE_WORKER_SYNC_COUNTRIES_MESSAGE = 'set-sync-countries'; const SERVICE_WORKER_SYNC_COUNTRIES_MESSAGE = 'set-sync-countries';
const SERVICE_WORKER_STATION_CATALOG_UPDATED_MESSAGE = 'station-catalog-updated';
const SERVICE_WORKER_STATION_CATALOG_UPDATE_FAILED_MESSAGE = 'station-catalog-update-failed';
const SERVICE_WORKER_MANAGED_CATALOG_UPDATED_MESSAGE = 'managed-catalog-updated';
const SERVICE_WORKER_MANAGED_CATALOG_UPDATE_FAILED_MESSAGE = 'managed-catalog-update-failed';
const CATALOG_SYNC_HISTORY_STORAGE_KEY = 'radioplayer.catalogSyncHistory';
// ── Utilities ──────────────────────────────────────────────────────────────── // ── Utilities ────────────────────────────────────────────────────────────────
@@ -1666,6 +1680,151 @@ function updateManagedCatalogStatus(source = 'unknown') {
managedCatalogStatusEl.classList.remove('hidden'); managedCatalogStatusEl.classList.remove('hidden');
} }
function formatCatalogSyncTime(value) {
const parsed = value ? new Date(value) : new Date();
if (Number.isNaN(parsed.getTime())) {
return '';
}
return new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
}).format(parsed);
}
function formatCatalogSyncHistoryTime(value) {
const parsed = value ? new Date(value) : null;
if (!parsed || Number.isNaN(parsed.getTime())) {
return '';
}
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(parsed);
}
function readCatalogSyncHistory() {
try {
const raw = window.localStorage.getItem(CATALOG_SYNC_HISTORY_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return null;
}
const kind = parsed.kind === 'managed' ? 'managed' : parsed.kind === 'stations' ? 'stations' : null;
const syncedAt = typeof parsed.syncedAt === 'string' ? parsed.syncedAt : null;
if (!kind || !syncedAt || Number.isNaN(new Date(syncedAt).getTime())) {
return null;
}
return { kind, syncedAt };
} catch {
return null;
}
}
function renderCatalogSyncHistory(record) {
if (!catalogSyncHistoryEl) {
return;
}
const formattedTime = formatCatalogSyncHistoryTime(record?.syncedAt ?? null);
if (!record || !formattedTime) {
catalogSyncHistoryEl.textContent = '';
catalogSyncHistoryEl.classList.add('hidden');
return;
}
const label = record.kind === 'managed' ? 'Managed catalog' : 'Stations';
catalogSyncHistoryEl.textContent = `Last background refresh: ${label} · ${formattedTime}`;
catalogSyncHistoryEl.classList.remove('hidden');
}
function persistCatalogSyncHistory(kind, syncedAt) {
const normalizedSyncedAt = syncedAt && !Number.isNaN(new Date(syncedAt).getTime())
? new Date(syncedAt).toISOString()
: new Date().toISOString();
const record = { kind, syncedAt: normalizedSyncedAt };
try {
window.localStorage.setItem(CATALOG_SYNC_HISTORY_STORAGE_KEY, JSON.stringify(record));
} catch {
// Ignore storage failures and still render the in-memory state.
}
renderCatalogSyncHistory(record);
}
function compactCatalogSyncError(error) {
if (typeof error !== 'string') {
return '';
}
const compact = error.replace(/\s+/g, ' ').trim();
if (!compact) {
return '';
}
return compact.length > 72 ? `${compact.slice(0, 69)}...` : compact;
}
function showCatalogSyncStatus(message, syncedAt = null, tone = 'info', detail = '') {
if (!catalogSyncStatusEl || !message) return;
if (catalogSyncStatusTimeoutId) {
clearTimeout(catalogSyncStatusTimeoutId);
catalogSyncStatusTimeoutId = null;
}
const compactDetail = compactCatalogSyncError(detail);
const visibleMessage = compactDetail ? `${message}: ${compactDetail}` : message;
const accessibleMessage = detail ? `${message}: ${detail}` : message;
if (catalogSyncStatusTextEl) {
catalogSyncStatusTextEl.textContent = visibleMessage;
} else {
catalogSyncStatusEl.textContent = visibleMessage;
}
catalogSyncStatusEl.title = accessibleMessage;
catalogSyncStatusEl.setAttribute('aria-label', accessibleMessage);
if (catalogSyncStatusTimeEl) {
const formattedTime = formatCatalogSyncTime(syncedAt);
catalogSyncStatusTimeEl.textContent = formattedTime;
catalogSyncStatusTimeEl.dateTime = syncedAt && !Number.isNaN(new Date(syncedAt).getTime())
? new Date(syncedAt).toISOString()
: new Date().toISOString();
}
catalogSyncStatusEl.classList.toggle('is-warning', tone === 'warning');
catalogSyncStatusEl.classList.remove('hidden');
catalogSyncStatusTimeoutId = window.setTimeout(() => {
if (catalogSyncStatusTextEl) {
catalogSyncStatusTextEl.textContent = '';
} else {
catalogSyncStatusEl.textContent = '';
}
if (catalogSyncStatusTimeEl) {
catalogSyncStatusTimeEl.textContent = '';
catalogSyncStatusTimeEl.removeAttribute('datetime');
}
catalogSyncStatusEl.removeAttribute('title');
catalogSyncStatusEl.removeAttribute('aria-label');
catalogSyncStatusEl.classList.remove('is-warning');
catalogSyncStatusEl.classList.add('hidden');
catalogSyncStatusTimeoutId = null;
}, 5000);
}
function getStationHealthBadge(station, stationHealth = loadStationHealth()) { function getStationHealthBadge(station, stationHealth = loadStationHealth()) {
const health = stationHealth?.[station?.id]; const health = stationHealth?.[station?.id];
if (!health || Number(health.attempts) <= 0) { if (!health || Number(health.attempts) <= 0) {
@@ -2603,30 +2762,19 @@ async function activateStationByIndex(idx) {
} }
} }
// ── Load stations ──────────────────────────────────────────────────────────── function buildMergedStations(raw, managedRaw) {
async function loadStations() {
try {
stationCatalogState = 'loading';
stationCatalogError = '';
resetStationLibraryPage();
renderStationLibrary();
stopCurrentSongPollers();
const raw = await loadRadioStations();
const managedRaw = await loadManagedStations().catch(() => []);
const normalizedRaw = raw const normalizedRaw = raw
.filter((station) => isEnabledRadioCountryStation(station)) .filter((station) => isEnabledRadioCountryStation(station))
.map((s) => normalizeStationRecord(s)) .map((station) => normalizeStationRecord(station))
.filter((s) => s.enabled !== false && s.url && s.url.length > 0); .filter((station) => station.enabled !== false && station.url && station.url.length > 0);
const normalizedManaged = managedRaw const normalizedManaged = managedRaw
.map((s) => normalizeStationRecord(s)) .map((station) => normalizeStationRecord(station))
.filter((s) => s.enabled !== false && s.url && s.url.length > 0); .filter((station) => station.enabled !== false && station.url && station.url.length > 0);
const userNormalized = loadUserStations() const userNormalized = loadUserStations()
.map((s) => normalizeStationRecord(s, true)) .map((station) => normalizeStationRecord(station, true))
.filter((s) => s.url && s.url.length > 0); .filter((station) => station.url && station.url.length > 0);
const mergedStations = []; const mergedStations = [];
const seenStationIds = new Set(); const seenStationIds = new Set();
@@ -2644,21 +2792,23 @@ async function loadStations() {
normalizedRaw.forEach(pushStation); normalizedRaw.forEach(pushStation);
userNormalized.forEach(pushStation); userNormalized.forEach(pushStation);
return mergedStations;
}
function applyLoadedStations(mergedStations, { preferredStationId = null } = {}) {
stations = mergedStations; stations = mergedStations;
updateManagedCatalogStatus(getLastManagedCatalogSource()); updateManagedCatalogStatus(getLastManagedCatalogSource());
lastManagedCatalogLoadTime = Date.now();
stationCatalogState = stations.length > 0 ? 'ready' : 'empty'; stationCatalogState = stations.length > 0 ? 'ready' : 'empty';
console.debug('loadStations: loaded', stations.length, 'stations');
console.debug('managed catalog source:', getLastManagedCatalogSource());
if (stations.length > 0) { if (stations.length > 0) {
const lastId = getLastStationId(); const fallbackStationId = preferredStationId || getLastStationId();
if (lastId) { if (fallbackStationId) {
const found = stations.findIndex((s) => s.id === lastId); const foundIndex = stations.findIndex((station) => station.id === fallbackStationId);
currentIndex = found >= 0 ? found : 0; currentIndex = foundIndex >= 0 ? foundIndex : 0;
} else { } else {
currentIndex = 0; currentIndex = 0;
} }
loadStation(currentIndex); loadStation(currentIndex);
renderCoverflow(); renderCoverflow();
renderStationLibrary(); renderStationLibrary();
@@ -2667,6 +2817,51 @@ async function loadStations() {
renderCoverflow(); renderCoverflow();
renderStationLibrary(); renderStationLibrary();
} }
}
// ── Load stations ────────────────────────────────────────────────────────────
async function loadStations() {
try {
stationCatalogState = 'loading';
stationCatalogError = '';
resetStationLibraryPage();
renderStationLibrary();
stopCurrentSongPollers();
const persistedRadioPromise = loadPersistedRadioStations().catch(() => null);
const persistedManagedPromise = loadPersistedManagedStations().catch(() => null);
const liveRadioPromise = loadRadioStations();
const liveManagedPromise = loadManagedStations().catch(() => []);
const [persistedRaw, persistedManaged] = await Promise.all([
persistedRadioPromise,
persistedManagedPromise,
]);
const hasPersistedCatalog = (persistedRaw?.length ?? 0) > 0 || (persistedManaged?.length ?? 0) > 0;
if (hasPersistedCatalog) {
applyLoadedStations(buildMergedStations(persistedRaw ?? [], persistedManaged ?? []));
console.debug('loadStations: hydrated from persisted station catalog');
}
try {
const [raw, managedRaw] = await Promise.all([liveRadioPromise, liveManagedPromise]);
const preferredStationId = hasPersistedCatalog ? stations[currentIndex]?.id ?? null : null;
applyLoadedStations(buildMergedStations(raw, managedRaw), { preferredStationId });
lastManagedCatalogLoadTime = Date.now();
console.debug('loadStations: loaded', stations.length, 'stations');
console.debug('managed catalog source:', getLastManagedCatalogSource());
} catch (refreshError) {
if (!hasPersistedCatalog) {
throw refreshError;
}
console.warn('Failed to refresh station catalogs, continuing with persisted data.', refreshError);
stationCatalogError = '';
if (statusTextEl) {
statusTextEl.textContent = 'Ready';
}
}
} catch (e) { } catch (e) {
console.error('Failed to load stations', e); console.error('Failed to load stations', e);
stations = []; stations = [];
@@ -3053,6 +3248,61 @@ function loadStation(index) {
window.setTimeout(triggerSparkleOnSelected, 120); window.setTimeout(triggerSparkleOnSelected, 120);
} }
function setupServiceWorkerCatalogRefreshListener() {
if (!('serviceWorker' in navigator)) {
return;
}
navigator.serviceWorker.addEventListener('message', (event) => {
const messageType = event.data?.type;
if (
messageType !== SERVICE_WORKER_STATION_CATALOG_UPDATED_MESSAGE
&& messageType !== SERVICE_WORKER_STATION_CATALOG_UPDATE_FAILED_MESSAGE
&& messageType !== SERVICE_WORKER_MANAGED_CATALOG_UPDATED_MESSAGE
&& messageType !== SERVICE_WORKER_MANAGED_CATALOG_UPDATE_FAILED_MESSAGE
) {
return;
}
if (messageType === SERVICE_WORKER_STATION_CATALOG_UPDATE_FAILED_MESSAGE) {
showCatalogSyncStatus(
'Background station sync failed',
event.data?.failedAt ?? null,
'warning',
event.data?.error ?? '',
);
return;
}
if (messageType === SERVICE_WORKER_MANAGED_CATALOG_UPDATE_FAILED_MESSAGE) {
showCatalogSyncStatus(
'Managed catalog refresh failed',
event.data?.failedAt ?? null,
'warning',
event.data?.error ?? '',
);
return;
}
if (stationCatalogState === 'loading') {
return;
}
void loadStations().then(() => {
if (messageType === SERVICE_WORKER_MANAGED_CATALOG_UPDATED_MESSAGE) {
persistCatalogSyncHistory('managed', new Date().toISOString());
showCatalogSyncStatus('Managed catalog refreshed in background');
return;
}
persistCatalogSyncHistory('stations', event.data?.meta?.syncedAt ?? null);
showCatalogSyncStatus('Stations updated in background', event.data?.meta?.syncedAt ?? null);
}).catch(() => {
// Keep current UI if the refresh fails.
});
});
}
// ── Google Cast initialisation ──────────────────────────────────────────────── // ── Google Cast initialisation ────────────────────────────────────────────────
// Called by the Cast SDK once it has loaded (window.__onGCastApiAvailable callback). // Called by the Cast SDK once it has loaded (window.__onGCastApiAvailable callback).
@@ -3793,8 +4043,10 @@ async function init() {
restoreCastBothMode(); restoreCastBothMode();
restoreLastStationCountry(); restoreLastStationCountry();
restoreSelectedRadioCountryCodes(); restoreSelectedRadioCountryCodes();
renderCatalogSyncHistory(readCatalogSyncHistory());
await loadStations(); await loadStations();
setupEventListeners(); setupEventListeners();
setupServiceWorkerCatalogRefreshListener();
void sendSelectedCountriesToServiceWorker(); void sendSelectedCountriesToServiceWorker();
lockPortraitOrientation(); lockPortraitOrientation();
ensureArtworkPointerFallback(); ensureArtworkPointerFallback();
+24 -2
View File
@@ -1,6 +1,11 @@
const MANAGED_CATALOG_CACHE_PREFIX = 'radioplayer-managed-catalog-'; const MANAGED_CATALOG_CACHE_PREFIX = 'radioplayer-managed-catalog-';
const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source'; const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source';
import {
loadPersistedManagedStationCatalog,
persistManagedStationCatalog,
} from '../storage/stationCatalogPersistence.js';
export type ManagedCatalogSource = 'remote' | 'cached-remote' | 'bundled' | 'unknown'; export type ManagedCatalogSource = 'remote' | 'cached-remote' | 'bundled' | 'unknown';
let lastManagedCatalogSource: ManagedCatalogSource = 'unknown'; let lastManagedCatalogSource: ManagedCatalogSource = 'unknown';
@@ -44,10 +49,26 @@ function setLastManagedCatalogSource(source: ManagedCatalogSource) {
lastManagedCatalogSource = source; lastManagedCatalogSource = source;
} }
function normalizeManagedCatalogSource(source: unknown): ManagedCatalogSource {
return source === 'remote' || source === 'cached-remote' || source === 'bundled'
? source
: 'unknown';
}
export function getLastManagedCatalogSource(): ManagedCatalogSource { export function getLastManagedCatalogSource(): ManagedCatalogSource {
return lastManagedCatalogSource; return lastManagedCatalogSource;
} }
export async function loadPersistedManagedStations(): Promise<unknown[] | null> {
const persisted = await loadPersistedManagedStationCatalog();
if (!persisted) {
return null;
}
setLastManagedCatalogSource(normalizeManagedCatalogSource(persisted.source));
return normalizeManagedStationsPayload(persisted.stations);
}
export async function loadManagedStations(): Promise<unknown[]> { export async function loadManagedStations(): Promise<unknown[]> {
const remoteCatalogUrl = `${import.meta.env.BASE_URL}api/managed-stations.json`; const remoteCatalogUrl = `${import.meta.env.BASE_URL}api/managed-stations.json`;
const bundledCatalogUrl = `${import.meta.env.BASE_URL}stations.json`; const bundledCatalogUrl = `${import.meta.env.BASE_URL}stations.json`;
@@ -92,6 +113,7 @@ export async function loadManagedStations(): Promise<unknown[]> {
throw new Error(`Failed to load managed stations: ${response.status}`); throw new Error(`Failed to load managed stations: ${response.status}`);
} }
const stations = await response.json(); const stations = normalizeManagedStationsPayload(await response.json());
return normalizeManagedStationsPayload(stations); await persistManagedStationCatalog(stations, getLastManagedCatalogSource());
return stations;
} }
+12 -1
View File
@@ -1,4 +1,8 @@
import type { RadioStation } from './radioTypes.js'; import type { RadioStation } from './radioTypes.js';
import {
loadPersistedRadioStationCatalog,
persistRadioStationCatalog,
} from '../storage/stationCatalogPersistence.js';
const STATION_SYNC_CACHE_PREFIX = 'radioplayer-station-sync-'; const STATION_SYNC_CACHE_PREFIX = 'radioplayer-station-sync-';
@@ -21,6 +25,11 @@ async function loadSyncedCatalogFromCache(catalogUrl: string): Promise<Response
} }
} }
export async function loadPersistedRadioStations(): Promise<RadioStation[] | null> {
const persisted = await loadPersistedRadioStationCatalog();
return Array.isArray(persisted) ? persisted as RadioStation[] : null;
}
export async function loadRadioStations(): Promise<RadioStation[]> { export async function loadRadioStations(): Promise<RadioStation[]> {
const syncedCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations-sync.json`; const syncedCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations-sync.json`;
const bundledCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations.json`; const bundledCatalogUrl = `${import.meta.env.BASE_URL}data/radio-stations.json`;
@@ -43,5 +52,7 @@ export async function loadRadioStations(): Promise<RadioStation[]> {
} }
const stations = (await response.json()) as RadioStation[]; const stations = (await response.json()) as RadioStation[];
return Array.isArray(stations) ? stations : []; const normalizedStations = Array.isArray(stations) ? stations : [];
await persistRadioStationCatalog(normalizedStations);
return normalizedStations;
} }
+169
View File
@@ -0,0 +1,169 @@
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
type StationCatalogKey = 'radio-browser' | 'managed';
type StationCatalogRecord = {
key: StationCatalogKey;
stations: unknown[];
source: string | null;
updatedAt: string;
};
interface StationCatalogDb extends DBSchema {
catalogs: {
key: StationCatalogKey;
value: StationCatalogRecord;
};
}
const DB_NAME = 'radioplayer-station-catalogs';
const DB_VERSION = 1;
const LOCAL_STORAGE_KEYS = {
radioBrowser: 'radioplayer.stationCatalog.radioBrowser',
managed: 'radioplayer.stationCatalog.managed',
};
let dbPromise: Promise<IDBPDatabase<StationCatalogDb> | null> | null = null;
function getDb() {
if (!dbPromise) {
dbPromise = openDB<StationCatalogDb>(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains('catalogs')) {
db.createObjectStore('catalogs', { keyPath: 'key' });
}
},
}).catch((error) => {
console.warn('Station catalog IndexedDB unavailable, using localStorage fallback.', error);
return null;
});
}
return dbPromise;
}
function getLocalStorage(): Storage | null {
try {
return globalThis.localStorage ?? null;
} catch {
return null;
}
}
function getLocalStorageKey(key: StationCatalogKey) {
return key === 'managed' ? LOCAL_STORAGE_KEYS.managed : LOCAL_STORAGE_KEYS.radioBrowser;
}
function sanitizeStations(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function sanitizeSource(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function sanitizeCatalogRecord(key: StationCatalogKey, value: unknown): StationCatalogRecord | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const record = value as Partial<StationCatalogRecord>;
return {
key,
stations: sanitizeStations(record.stations),
source: sanitizeSource(record.source),
updatedAt: typeof record.updatedAt === 'string' && record.updatedAt.trim().length > 0
? record.updatedAt
: new Date(0).toISOString(),
};
}
function readCatalogFromLocalStorage(key: StationCatalogKey): StationCatalogRecord | null {
const storage = getLocalStorage();
if (!storage) {
return null;
}
const raw = storage.getItem(getLocalStorageKey(key));
if (!raw) {
return null;
}
try {
return sanitizeCatalogRecord(key, JSON.parse(raw));
} catch {
return null;
}
}
function writeCatalogToLocalStorage(record: StationCatalogRecord) {
const storage = getLocalStorage();
if (!storage) {
return;
}
try {
storage.setItem(getLocalStorageKey(record.key), JSON.stringify(record));
} catch {
// Ignore quota/write failures and rely on IndexedDB when available.
}
}
async function readCatalog(key: StationCatalogKey): Promise<StationCatalogRecord | null> {
const db = await getDb();
if (!db) {
return readCatalogFromLocalStorage(key);
}
try {
const record = await db.get('catalogs', key);
return sanitizeCatalogRecord(key, record);
} catch {
return readCatalogFromLocalStorage(key);
}
}
async function writeCatalog(key: StationCatalogKey, stations: unknown[], source: string | null = null) {
const record: StationCatalogRecord = {
key,
stations: sanitizeStations(stations),
source: sanitizeSource(source),
updatedAt: new Date().toISOString(),
};
writeCatalogToLocalStorage(record);
const db = await getDb();
if (!db) {
return;
}
try {
await db.put('catalogs', record);
} catch {
// Keep the localStorage mirror if IndexedDB writes fail.
}
}
export async function loadPersistedRadioStationCatalog(): Promise<unknown[] | null> {
const record = await readCatalog('radio-browser');
return record ? record.stations : null;
}
export async function persistRadioStationCatalog(stations: unknown[]) {
await writeCatalog('radio-browser', stations);
}
export async function loadPersistedManagedStationCatalog(): Promise<{ stations: unknown[]; source: string | null } | null> {
const record = await readCatalog('managed');
return record
? {
stations: record.stations,
source: record.source,
}
: null;
}
export async function persistManagedStationCatalog(stations: unknown[], source: string | null) {
await writeCatalog('managed', stations, source);
}
+55
View File
@@ -1585,6 +1585,61 @@ header {
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
.catalog-sync-status {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 8px;
width: fit-content;
padding: 6px 10px;
border: 1px solid rgba(var(--accent-rgb), 0.22);
border-radius: 999px;
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.16), rgba(var(--accent-3-rgb), 0.1));
color: var(--text-main);
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.01em;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.18);
}
.catalog-sync-status.is-warning {
border-color: rgba(255, 112, 135, 0.34);
background: linear-gradient(135deg, rgba(255, 112, 135, 0.18), rgba(255, 180, 92, 0.12));
}
.catalog-sync-status-time {
color: var(--text-muted);
font-weight: 700;
}
.catalog-sync-status-time:not(:empty)::before {
content: "·";
margin-right: 8px;
color: var(--text-soft);
}
.catalog-sync-status::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px rgba(var(--accent-rgb), 0.7);
}
.catalog-sync-status.is-warning::before {
background: var(--danger);
box-shadow: 0 0 10px rgba(255, 112, 135, 0.7);
}
.catalog-sync-history {
margin-top: 8px;
color: var(--text-soft);
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.status-dot { .status-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;