// NOTE: This service worker is for the web/PWA build. // For development we aggressively unregister SWs in `src/player.js`. // // This value is rewritten automatically before each build so deployed clients // refresh to the newest shell and cached assets. const CACHE_NAME = 'radioplayer-pwa-v5-1777473175316'; const STATION_SYNC_CACHE_NAME = 'radioplayer-station-sync-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 STATION_SYNC_TAG = 'radio-stations-refresh'; const STATION_PERIODIC_SYNC_TAG = 'radio-stations-periodic-refresh'; const MANAGED_CATALOG_PERIODIC_SYNC_TAG = 'radioplayer-managed-catalog-refresh'; const STATION_SYNC_HEADERS = { 'content-type': 'application/json; charset=utf-8', }; const MANAGED_CATALOG_SOURCE_HEADER = 'x-radioplayer-managed-source'; const RADIO_COUNTRIES = [ { name: 'Austria', code: 'AT' }, { name: 'Belgium', code: 'BE' }, { name: 'Bulgaria', code: 'BG' }, { name: 'Cyprus', code: 'CY' }, { name: 'Czechia', code: 'CZ' }, { name: 'Denmark', code: 'DK' }, { name: 'Estonia', code: 'EE' }, { name: 'Finland', code: 'FI' }, { name: 'France', code: 'FR' }, { name: 'Germany', code: 'DE' }, { name: 'Greece', code: 'GR' }, { name: 'Russia', code: 'RU' }, { name: 'Hungary', code: 'HU' }, { name: 'Ireland', code: 'IE' }, { name: 'Italy', code: 'IT' }, { name: 'Japan', code: 'JP' }, { name: 'Latvia', code: 'LV' }, { name: 'Lithuania', code: 'LT' }, { name: 'Luxembourg', code: 'LU' }, { name: 'Malta', code: 'MT' }, { name: 'Mexico', code: 'MX' }, { name: 'Netherlands', code: 'NL' }, { name: 'Poland', code: 'PL' }, { name: 'Brazil', code: 'BR' }, { name: 'Portugal', code: 'PT' }, { name: 'Romania', code: 'RO' }, { name: 'Croatia', code: 'HR' }, { name: 'Serbia', code: 'RS' }, { name: 'Montenegro', code: 'ME' }, { name: 'Bosnia & Herzegovina', code: 'BA' }, { name: 'Argentina', code: 'AR' }, { name: 'United Kingdom', code: 'GB' }, { name: 'Slovenia', code: 'SI' }, { name: 'Slovakia', code: 'SK' }, { name: 'Spain', code: 'ES' }, { name: 'USA', code: 'US' }, { name: 'Canada', code: 'CA' }, { name: 'Australia', code: 'AU' }, { name: 'China', code: 'CN' }, { name: 'Sweden', code: 'SE' }, { name: 'Switzerland', code: 'CH' }, { name: 'Turkey', code: 'TR' }, { name: 'Ukraine', code: 'UA' }, ]; const DEFAULT_SYNC_COUNTRY_CODES = RADIO_COUNTRIES.map((country) => country.code); const MANAGED_COUNTRY_CODE = 'SI'; const MAX_TAGS = 12; const OBVIOUSLY_UNSUPPORTED_CODECS = new Set([ 'wma', 'wmav2', 'asf', 'ra', 'rm', 'ape', 'alac', 'amr', ]); const CORE_ASSETS = [ './', 'index.html', 'privacy.html', 'data/radio-stations.json', 'data/radio-stations-sync.json', 'stations.json', 'manifest.json', 'images/radio-placeholder.svg', 'assets/radioplayer-logo-192.png', 'assets/radioplayer-logo-512.png', ]; const CORE_PATHS = new Set(CORE_ASSETS.map((p) => new URL(p, self.registration.scope).pathname)); const DATA_PATHS = new Set([ new URL('data/radio-stations.json', self.registration.scope).pathname, new URL('data/radio-stations-sync.json', self.registration.scope).pathname, new URL('stations.json', self.registration.scope).pathname, new URL('manifest.json', self.registration.scope).pathname, ]); const IMAGE_FALLBACK_PATH = new URL('images/radio-placeholder.svg', self.registration.scope).pathname; const SYNC_CATALOG_URL = new URL('data/radio-stations-sync.json', self.registration.scope).href; const SYNC_CATALOG_PATH = new URL('data/radio-stations-sync.json', self.registration.scope).pathname; const SYNC_META_URL = new URL('data/radio-stations-sync-meta.json', self.registration.scope).href; const SYNC_SETTINGS_URL = new URL('data/radio-stations-sync-settings.json', self.registration.scope).href; const SYNC_COUNTRY_PREFIX_PATH = new URL('data/countries/', self.registration.scope).pathname; const BUNDLED_MANAGED_CATALOG_URL = new URL('stations.json', self.registration.scope).href; const MANAGED_CATALOG_PATH = new URL('api/managed-stations.json', self.registration.scope).pathname; function trimToNull(value) { if (typeof value !== 'string') return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function toNumber(value) { const parsed = typeof value === 'number' ? value : Number(value ?? 0); return Number.isFinite(parsed) ? parsed : 0; } function toNullableNumber(value) { const parsed = typeof value === 'number' ? value : Number(value ?? NaN); return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; } function parseTags(tags) { if (typeof tags !== 'string' || tags.trim().length === 0) return []; const seen = new Set(); const parsed = []; for (const rawTag of tags.split(',')) { const tag = rawTag.trim(); if (!tag) continue; const normalizedTag = tag.toLowerCase(); if (seen.has(normalizedTag)) continue; seen.add(normalizedTag); parsed.push(tag); if (parsed.length >= MAX_TAGS) break; } return parsed; } function isHttpsUrl(value) { if (!value) return false; try { const url = new URL(value); return url.protocol === 'https:'; } catch { return false; } } function normalizeRadioBrowserStation(station, countryName) { const stationUuid = trimToNull(station?.stationuuid); if (!stationUuid) return null; const name = trimToNull(station?.name); if (!name) return null; const streamUrl = trimToNull(station?.url_resolved) ?? trimToNull(station?.url); if (!isHttpsUrl(streamUrl)) return null; const codec = trimToNull(station?.codec); if (codec && OBVIOUSLY_UNSUPPORTED_CODECS.has(codec.toLowerCase())) { return null; } return { id: stationUuid, name, country: trimToNull(station?.country) ?? countryName, countryCode: trimToNull(station?.countrycode) ?? '', language: trimToNull(station?.language), tags: parseTags(station?.tags), codec, bitrate: toNullableNumber(station?.bitrate), streamUrl, homepage: trimToNull(station?.homepage), logoUrl: trimToNull(station?.favicon), votes: toNumber(station?.votes), clickcount: toNumber(station?.clickcount), source: 'radio-browser', sourceStationUuid: stationUuid, }; } function getCountryCatalogUrl(countryCode) { return new URL(`data/countries/${String(countryCode || '').toLowerCase()}.json`, self.registration.scope).href; } async function writeJsonToCache(cache, requestUrl, payload) { await cache.put(requestUrl, new Response(JSON.stringify(payload), { headers: STATION_SYNC_HEADERS, })); } function sanitizeSelectedCountryCodes(countryCodes) { const uniqueCodes = Array.isArray(countryCodes) ? Array.from(new Set( countryCodes .map((entry) => (typeof entry === 'string' ? entry.trim().toUpperCase() : '')) .filter((entry) => /^[A-Z]{2}$/.test(entry)), )) : []; if (!uniqueCodes.includes(MANAGED_COUNTRY_CODE)) { uniqueCodes.unshift(MANAGED_COUNTRY_CODE); } return uniqueCodes.length > 0 ? uniqueCodes : [...DEFAULT_SYNC_COUNTRY_CODES]; } async function readSyncSettings(cache) { const cached = await cache.match(SYNC_SETTINGS_URL); if (!cached) { return { selectedCountryCodes: [...DEFAULT_SYNC_COUNTRY_CODES] }; } try { const payload = await cached.json(); return { selectedCountryCodes: sanitizeSelectedCountryCodes(payload?.selectedCountryCodes), }; } catch { return { selectedCountryCodes: [...DEFAULT_SYNC_COUNTRY_CODES] }; } } async function writeSyncSettings(cache, countryCodes) { const payload = { selectedCountryCodes: sanitizeSelectedCountryCodes(countryCodes), updatedAt: new Date().toISOString(), }; await writeJsonToCache(cache, SYNC_SETTINGS_URL, payload); return payload; } function getSyncCountries(selectedCountryCodes) { return sanitizeSelectedCountryCodes(selectedCountryCodes).map((code) => { const existing = RADIO_COUNTRIES.find((country) => country.code === code); return existing || { name: code, code }; }); } async function fetchCountryStations(country) { const url = new URL(RADIO_BROWSER_API_ENDPOINT); url.search = new URLSearchParams({ countrycode: country.code, hidebroken: 'true', is_https: 'true', order: 'clickcount', reverse: 'true', limit: '100', }).toString(); const response = await fetch(url, { cache: 'no-store', headers: { accept: 'application/json', }, mode: 'cors', }); if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`); } const payload = await response.json(); if (!Array.isArray(payload)) { throw new Error('Expected an array response from Radio Browser.'); } return payload .map((station) => normalizeRadioBrowserStation(station, country.name)) .filter(Boolean); } async function syncRadioStations(reason = 'sync', selectedCountryCodesOverride = null) { const syncCache = await caches.open(STATION_SYNC_CACHE_NAME); const syncSettings = selectedCountryCodesOverride ? { selectedCountryCodes: sanitizeSelectedCountryCodes(selectedCountryCodesOverride) } : await readSyncSettings(syncCache); const syncCountries = getSyncCountries(syncSettings.selectedCountryCodes); const aggregatedStations = []; const seenStationIds = new Set(); const seenStreamUrls = new Set(); const failedCountries = []; let syncedCountries = 0; for (const country of syncCountries) { try { const stations = await fetchCountryStations(country); await writeJsonToCache(syncCache, getCountryCatalogUrl(country.code), stations); for (const station of stations) { if (seenStationIds.has(station.id)) continue; if (seenStreamUrls.has(station.streamUrl)) continue; seenStationIds.add(station.id); seenStreamUrls.add(station.streamUrl); aggregatedStations.push(station); } syncedCountries += 1; } catch (error) { failedCountries.push(country.code); console.debug(`[sw] Station sync failed for ${country.name} (${country.code})`, error); } } aggregatedStations.sort((left, right) => { const countryOrder = left.country.localeCompare(right.country, undefined, { sensitivity: 'base' }); if (countryOrder !== 0) return countryOrder; if (right.clickcount !== left.clickcount) return right.clickcount - left.clickcount; return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }); }); const meta = { reason, syncedAt: new Date().toISOString(), countryCount: syncedCountries, selectedCountryCodes: syncSettings.selectedCountryCodes, failedCountries, stationCount: aggregatedStations.length, }; if (aggregatedStations.length === 0) { const existingCatalog = await syncCache.match(SYNC_CATALOG_URL); if (existingCatalog) { await writeJsonToCache(syncCache, SYNC_META_URL, { ...meta, reusedCachedCatalog: true, }); return { ...meta, reusedCachedCatalog: true, }; } throw new Error('Station sync fetched no stations.'); } await Promise.all([ writeJsonToCache(syncCache, SYNC_CATALOG_URL, aggregatedStations), writeJsonToCache(syncCache, SYNC_META_URL, meta), ]); return meta; } async function respondWithSyncedCatalog(request) { const syncCache = await caches.open(STATION_SYNC_CACHE_NAME); const cached = await syncCache.match(request); if (cached) return cached; const bundled = await caches.match(new URL('data/radio-stations.json', self.registration.scope).href) || await caches.match('data/radio-stations.json'); if (bundled) return bundled; return fetch(new Request('data/radio-stations.json', { cache: 'reload' })); } async function respondWithSyncedCountryCatalog(request) { const syncCache = await caches.open(STATION_SYNC_CACHE_NAME); const cached = await syncCache.match(request); if (cached) return cached; return new Response('[]', { headers: STATION_SYNC_HEADERS, status: 200, }); } async function refreshManagedCatalogCache() { const managedCatalogCache = await caches.open(MANAGED_CATALOG_CACHE_NAME); const url = new URL('api/managed-stations.json', self.registration.scope).href; try { const response = await fetch(url, { cache: 'no-store' }); if (isCacheableResponse(response)) { await managedCatalogCache.put(url, response); } } catch { // Network unavailable — keep existing cache as-is. } } async function respondWithManagedCatalog(request) { const managedCatalogCache = await caches.open(MANAGED_CATALOG_CACHE_NAME); try { const networkResponse = await fetch(request); if (isCacheableResponse(networkResponse)) { await managedCatalogCache.put(request, networkResponse.clone()); return withManagedCatalogSource(networkResponse, 'remote'); } } catch { // Fall back to the last good remote response or bundled catalog below. } const cachedRemoteResponse = await managedCatalogCache.match(request); if (cachedRemoteResponse) { return withManagedCatalogSource(cachedRemoteResponse, 'cached-remote'); } const bundledFallback = await caches.match(BUNDLED_MANAGED_CATALOG_URL); if (bundledFallback) { return withManagedCatalogSource(bundledFallback, 'bundled'); } return withManagedCatalogSource(await fetch(BUNDLED_MANAGED_CATALOG_URL), 'bundled'); } function withManagedCatalogSource(response, source) { const headers = new Headers(response.headers); headers.set(MANAGED_CATALOG_SOURCE_HEADER, source); return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); } function isCacheableResponse(response) { return Boolean(response && response.ok && response.type === 'basic'); } async function putInCache(request, response) { if (!isCacheableResponse(response)) return; const cache = await caches.open(CACHE_NAME); await cache.put(request, response.clone()); } async function precacheBuiltAssets(cache) { try { const indexResp = await fetch(new Request('./', { cache: 'reload' })); if (isCacheableResponse(indexResp)) { await cache.put('./', indexResp.clone()); } const indexText = await indexResp.text(); const assetUrls = [...indexText.matchAll(/(?:src|href)="([^"]+)"/g)] .map((match) => match[1]) .filter((assetPath) => assetPath.startsWith('./assets/') || assetPath.startsWith('assets/')) .map((assetPath) => new URL(assetPath, self.registration.scope).href); await Promise.allSettled(assetUrls.map(async (assetUrl) => { const assetResp = await fetch(new Request(assetUrl, { cache: 'reload' })); if (!isCacheableResponse(assetResp)) return; await cache.put(assetUrl, assetResp); })); } catch (e) { // If HTML parsing fails, runtime caching below still catches assets. } } async function networkFirst(request, fallbackRequest) { try { const networkResp = await fetch(request); await putInCache(request, networkResp); return networkResp; } catch (error) { const cached = await caches.match(request); if (cached) return cached; if (fallbackRequest) { const fallback = await caches.match(fallbackRequest); if (fallback) return fallback; } throw error; } } async function staleWhileRevalidate(request, fallbackRequest) { const cached = await caches.match(request); const networkPromise = fetch(request) .then(async (networkResp) => { await putInCache(request, networkResp); return networkResp; }) .catch(async () => { if (fallbackRequest) { const fallback = await caches.match(fallbackRequest); if (fallback) return fallback; } return null; }); if (cached) { return cached; } const networkResp = await networkPromise; if (networkResp) return networkResp; return new Response('', { status: 503, statusText: 'Service Unavailable' }); } self.addEventListener('install', (event) => { // Activate updated SW as soon as it's installed. self.skipWaiting(); event.waitUntil( caches.open(CACHE_NAME).then(async (cache) => { const reqs = CORE_ASSETS.map((p) => new Request(p, { cache: 'reload' })); await Promise.allSettled(reqs.map(async (request) => { const response = await fetch(request); if (!isCacheableResponse(response)) return; await cache.put(request, response); })); // Vite fingerprints JS/CSS assets in production. Parse the built HTML so // the installed PWA can launch offline after its first install. await precacheBuiltAssets(cache); }) ); }); self.addEventListener('activate', (event) => { event.waitUntil( Promise.all([ self.clients.claim(), caches.keys().then((keys) => Promise.all( keys.map((k) => { if (k !== CACHE_NAME && k !== STATION_SYNC_CACHE_NAME && k !== MANAGED_CATALOG_CACHE_NAME) return caches.delete(k); return null; }) )), ]) ); }); self.addEventListener('sync', (event) => { if (event.tag !== STATION_SYNC_TAG) return; event.waitUntil(syncRadioStations('background-sync')); }); self.addEventListener('message', (event) => { if (event.data?.type !== 'set-sync-countries') return; event.waitUntil((async () => { try { const syncCache = await caches.open(STATION_SYNC_CACHE_NAME); const syncSettings = await writeSyncSettings(syncCache, event.data?.countryCodes); const syncResult = event.data?.syncNow ? await syncRadioStations('country-selection-update', syncSettings.selectedCountryCodes) : null; event.ports?.[0]?.postMessage({ ok: true, selectedCountryCodes: syncSettings.selectedCountryCodes, syncResult, }); } catch (error) { event.ports?.[0]?.postMessage({ ok: false, error: error instanceof Error ? error.message : 'Unknown error', }); } })()); }); self.addEventListener('periodicsync', (event) => { if (event.tag === STATION_PERIODIC_SYNC_TAG) { event.waitUntil(syncRadioStations('periodic-sync')); return; } if (event.tag === MANAGED_CATALOG_PERIODIC_SYNC_TAG) { event.waitUntil(refreshManagedCatalogCache()); } }); self.addEventListener('fetch', (event) => { // Only handle GET requests if (event.request.method !== 'GET') return; if (event.request.headers.has('range')) return; const url = new URL(event.request.url); // Don't cache cross-origin requests (station logos, APIs, etc.). if (url.origin !== self.location.origin) { return; } const isCore = CORE_PATHS.has(url.pathname); const isDataRequest = DATA_PATHS.has(url.pathname); const isHtmlNavigation = event.request.mode === 'navigate' || (event.request.headers.get('accept') || '').includes('text/html'); const isStaticAsset = ['script', 'style', 'font', 'worker'].includes(event.request.destination) || url.pathname.includes('/assets/'); const isImage = event.request.destination === 'image'; if (url.pathname.endsWith('/sw.js')) { return; } if (url.pathname === SYNC_CATALOG_PATH) { event.respondWith(respondWithSyncedCatalog(event.request)); return; } if (url.pathname === MANAGED_CATALOG_PATH) { event.respondWith(respondWithManagedCatalog(event.request)); return; } if (url.pathname.startsWith(SYNC_COUNTRY_PREFIX_PATH) && url.pathname.endsWith('.json')) { event.respondWith(respondWithSyncedCountryCatalog(event.request)); return; } // Network-first for navigations and core assets to prevent "old UI" issues. if (isHtmlNavigation) { event.respondWith( networkFirst(event.request, './').catch( () => caches.match('./') || caches.match('index.html') || new Response('', { status: 503, statusText: 'Offline' }) ) ); return; } if (isCore || isDataRequest) { event.respondWith( networkFirst(event.request).catch( () => caches.match(event.request) || new Response('', { status: 503, statusText: 'Offline' }) ) ); return; } if (isStaticAsset) { event.respondWith(staleWhileRevalidate(event.request)); return; } if (isImage) { event.respondWith(staleWhileRevalidate(event.request, IMAGE_FALLBACK_PATH)); return; } event.respondWith( staleWhileRevalidate(event.request) ); });