38b6b8d3f7
Chrome MV3 extension, browser-resident sibling to rangerhq-radio (WP plugin). Plays SomaFM via the chrome.offscreen API + a source- adapter pattern at src/sources/. Architecture highlights: - Audio runs in offscreen document — SW would get killed. - Source-adapter pattern locks Tier 1 contract (RadioSource interface in src/sources/base-source.js). Adding a network = drop a file + register one line in src/sources/index.js. - Vanilla JS, no build step. Pure ES modules. - No telemetry, no third-party JS. Outbound only to somafm.com. - Narrow permissions: offscreen + storage + somafm.com host_perms. No tabs, no <all_urls>, no webRequest. 22 files, ~30 min build following the saved plan at ~/.ranger-memory/projects/rangerhq-tuner-plan.md. Tier 2 + Tier 3 (Web Store submission) not started.
86 lines
2.9 KiB
JavaScript
86 lines
2.9 KiB
JavaScript
// SomaFM source adapter — implements the RadioSource contract from
|
|
// base-source.js. SomaFM is a listener-supported indie radio network
|
|
// that publicly exposes a station catalogue (channels.json) and per-
|
|
// station playlist files (.pls) — no API key required.
|
|
//
|
|
// Reference: https://somafm.com/
|
|
|
|
import { parsePls } from '../lib/playlist-parser.js';
|
|
|
|
const CHANNELS_URL = 'https://somafm.com/channels.json';
|
|
|
|
/** @type {import('./base-source.js').RadioSource} */
|
|
export const somaFM = {
|
|
id: 'somafm',
|
|
displayName: 'SomaFM',
|
|
|
|
async listStations() {
|
|
const res = await fetch(CHANNELS_URL, { cache: 'no-store' });
|
|
if (!res.ok) throw new Error(`SomaFM channels.json HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
const channels = Array.isArray(data?.channels) ? data.channels : [];
|
|
|
|
return channels.map(ch => ({
|
|
id: `somafm:${ch.id}`,
|
|
sourceId: 'somafm',
|
|
name: ch.title || ch.id,
|
|
description: ch.description || '',
|
|
genre: ch.genre || '',
|
|
artworkUrl: ch.image || '',
|
|
// We don't resolve the stream here — that happens lazily on play
|
|
// (one extra HTTP fetch per station change, beats ~150 upfront).
|
|
streamUrls: [],
|
|
// Keep the raw playlists list around so resolveStreamUrl can pick
|
|
// the best one without re-fetching the catalogue.
|
|
_playlists: Array.isArray(ch.playlists) ? ch.playlists : [],
|
|
}));
|
|
},
|
|
|
|
async resolveStreamUrl(station) {
|
|
// Prefer mp3 + highest available quality. SomaFM offers aac too,
|
|
// but mp3 has the broadest browser support.
|
|
const playlists = station._playlists || [];
|
|
const preferred =
|
|
playlists.find(p => p.format === 'mp3' && p.quality === 'highest') ||
|
|
playlists.find(p => p.format === 'mp3') ||
|
|
playlists[0];
|
|
|
|
if (!preferred?.url) {
|
|
throw new Error(`No playable playlist for ${station.name}`);
|
|
}
|
|
|
|
const res = await fetch(preferred.url, { cache: 'no-store' });
|
|
if (!res.ok) throw new Error(`SomaFM .pls HTTP ${res.status}`);
|
|
const text = await res.text();
|
|
const streams = parsePls(text);
|
|
|
|
if (!streams.length) {
|
|
throw new Error(`Empty playlist for ${station.name}`);
|
|
}
|
|
return streams[0];
|
|
},
|
|
|
|
async getNowPlaying(station) {
|
|
// SomaFM publishes per-channel song history at
|
|
// https://somafm.com/songs/{shortId}.json
|
|
// The first entry is the current track.
|
|
const shortId = station.id.split(':')[1];
|
|
if (!shortId) return null;
|
|
try {
|
|
const res = await fetch(`https://somafm.com/songs/${shortId}.json`, { cache: 'no-store' });
|
|
if (!res.ok) return null;
|
|
const data = await res.json();
|
|
const songs = Array.isArray(data?.songs) ? data.songs : [];
|
|
const cur = songs[0];
|
|
if (!cur) return null;
|
|
return {
|
|
title: cur.title || '',
|
|
artist: cur.artist || '',
|
|
album: cur.album || '',
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
};
|