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.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
// 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user