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
+304 -52
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 { getLastManagedCatalogSource, loadManagedStations } from './radio/loadManagedStations.ts';
import {
getLastManagedCatalogSource,
loadManagedStations,
loadPersistedManagedStations,
} from './radio/loadManagedStations.ts';
import {
clearPlayerPersistence,
deletePersistedUserStationById,
@@ -83,6 +87,7 @@ let stationCountryFilterOpen = false;
let artworkShineTimeoutId = null;
let artworkShineClearTimeoutId = null;
let stationTitleRafId = null;
let catalogSyncStatusTimeoutId = null;
const STATION_LIBRARY_PAGE_SIZE = 24;
const RECENT_STATION_HISTORY_LIMIT = 40;
@@ -98,6 +103,10 @@ const stationSubtitleEl = document.getElementById('station-subtitle');
const stationHealthSummaryEl = document.getElementById('station-health-summary');
const stationHealthDetailEl = document.getElementById('station-health-detail');
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 nowArtistEl = document.getElementById('now-artist');
const nowTitleEl = document.getElementById('now-title');
@@ -200,6 +209,11 @@ const IMPORT_EXPORT_VERSION = 1;
const PLAYBACK_START_TIMEOUT_MS = 12000;
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_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 ────────────────────────────────────────────────────────────────
@@ -1666,6 +1680,151 @@ function updateManagedCatalogStatus(source = 'unknown') {
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()) {
const health = stationHealth?.[station?.id];
if (!health || Number(health.attempts) <= 0) {
@@ -2603,6 +2762,63 @@ async function activateStationByIndex(idx) {
}
}
function buildMergedStations(raw, managedRaw) {
const normalizedRaw = raw
.filter((station) => isEnabledRadioCountryStation(station))
.map((station) => normalizeStationRecord(station))
.filter((station) => station.enabled !== false && station.url && station.url.length > 0);
const normalizedManaged = managedRaw
.map((station) => normalizeStationRecord(station))
.filter((station) => station.enabled !== false && station.url && station.url.length > 0);
const userNormalized = loadUserStations()
.map((station) => normalizeStationRecord(station, true))
.filter((station) => station.url && station.url.length > 0);
const mergedStations = [];
const seenStationIds = new Set();
const seenStreamUrls = new Set();
const pushStation = (station) => {
if (!station?.id || !station?.url) return;
if (seenStationIds.has(station.id) || seenStreamUrls.has(station.url)) return;
seenStationIds.add(station.id);
seenStreamUrls.add(station.url);
mergedStations.push(station);
};
normalizedManaged.forEach(pushStation);
normalizedRaw.forEach(pushStation);
userNormalized.forEach(pushStation);
return mergedStations;
}
function applyLoadedStations(mergedStations, { preferredStationId = null } = {}) {
stations = mergedStations;
updateManagedCatalogStatus(getLastManagedCatalogSource());
stationCatalogState = stations.length > 0 ? 'ready' : 'empty';
if (stations.length > 0) {
const fallbackStationId = preferredStationId || getLastStationId();
if (fallbackStationId) {
const foundIndex = stations.findIndex((station) => station.id === fallbackStationId);
currentIndex = foundIndex >= 0 ? foundIndex : 0;
} else {
currentIndex = 0;
}
loadStation(currentIndex);
renderCoverflow();
renderStationLibrary();
startCurrentSongPollers();
} else {
renderCoverflow();
renderStationLibrary();
}
}
// ── Load stations ────────────────────────────────────────────────────────────
async function loadStations() {
@@ -2612,60 +2828,39 @@ async function loadStations() {
resetStationLibraryPage();
renderStationLibrary();
stopCurrentSongPollers();
const raw = await loadRadioStations();
const managedRaw = await loadManagedStations().catch(() => []);
const persistedRadioPromise = loadPersistedRadioStations().catch(() => null);
const persistedManagedPromise = loadPersistedManagedStations().catch(() => null);
const liveRadioPromise = loadRadioStations();
const liveManagedPromise = loadManagedStations().catch(() => []);
const normalizedRaw = raw
.filter((station) => isEnabledRadioCountryStation(station))
.map((s) => normalizeStationRecord(s))
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
const [persistedRaw, persistedManaged] = await Promise.all([
persistedRadioPromise,
persistedManagedPromise,
]);
const normalizedManaged = managedRaw
.map((s) => normalizeStationRecord(s))
.filter((s) => s.enabled !== false && s.url && s.url.length > 0);
const hasPersistedCatalog = (persistedRaw?.length ?? 0) > 0 || (persistedManaged?.length ?? 0) > 0;
if (hasPersistedCatalog) {
applyLoadedStations(buildMergedStations(persistedRaw ?? [], persistedManaged ?? []));
console.debug('loadStations: hydrated from persisted station catalog');
}
const userNormalized = loadUserStations()
.map((s) => normalizeStationRecord(s, true))
.filter((s) => s.url && s.url.length > 0);
const mergedStations = [];
const seenStationIds = new Set();
const seenStreamUrls = new Set();
const pushStation = (station) => {
if (!station?.id || !station?.url) return;
if (seenStationIds.has(station.id) || seenStreamUrls.has(station.url)) return;
seenStationIds.add(station.id);
seenStreamUrls.add(station.url);
mergedStations.push(station);
};
normalizedManaged.forEach(pushStation);
normalizedRaw.forEach(pushStation);
userNormalized.forEach(pushStation);
stations = mergedStations;
updateManagedCatalogStatus(getLastManagedCatalogSource());
lastManagedCatalogLoadTime = Date.now();
stationCatalogState = stations.length > 0 ? 'ready' : 'empty';
console.debug('loadStations: loaded', stations.length, 'stations');
console.debug('managed catalog source:', getLastManagedCatalogSource());
if (stations.length > 0) {
const lastId = getLastStationId();
if (lastId) {
const found = stations.findIndex((s) => s.id === lastId);
currentIndex = found >= 0 ? found : 0;
} else {
currentIndex = 0;
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';
}
loadStation(currentIndex);
renderCoverflow();
renderStationLibrary();
startCurrentSongPollers();
} else {
renderCoverflow();
renderStationLibrary();
}
} catch (e) {
console.error('Failed to load stations', e);
@@ -3053,6 +3248,61 @@ function loadStation(index) {
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 ────────────────────────────────────────────────
// Called by the Cast SDK once it has loaded (window.__onGCastApiAvailable callback).
@@ -3793,8 +4043,10 @@ async function init() {
restoreCastBothMode();
restoreLastStationCountry();
restoreSelectedRadioCountryCodes();
renderCatalogSyncHistory(readCatalogSyncHistory());
await loadStations();
setupEventListeners();
setupServiceWorkerCatalogRefreshListener();
void sendSelectedCountriesToServiceWorker();
lockPortraitOrientation();
ensureArtworkPointerFallback();