Files
RadioPlayerWeb/public/sw.js

561 lines
17 KiB
JavaScript

// 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-1777463324180';
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 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_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,
}));
}
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') {
const syncCache = await caches.open(STATION_SYNC_CACHE_NAME);
const aggregatedStations = [];
const seenStationIds = new Set();
const seenStreamUrls = new Set();
const failedCountries = [];
let syncedCountries = 0;
for (const country of RADIO_COUNTRIES) {
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,
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('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)
);
});