Files
rangerhq-tuner/src/sources/somafm.js
T
ranger 38b6b8d3f7 feat: initial commit — RangerHQ Tuner v0.1.0 (Tier 1 MVP)
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.
2026-06-08 23:31:29 +01:00

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;
}
},
};