Back to Funclet
SOURCE / PINNED RELEASE

Made of little things.

Funclet

Release
0b497cfc2f66…
Author-recorded commit
1e531b24f490…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6b7a37cac1d

Archive hash verified: 71d97a89ed8c40c2…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/domain.ts
/** Wavefunc interchange formats. Keep parsing independent from host authority. */
export const STATION_KIND = 31237;
export const FAVOURITES_KIND = 30078;
export const FAVOURITES_LABEL = 'wavefunc_user_favourite_list';
export const WAVEFUNC_RELAY = 'wss://relay.wavefunc.live';
export const WAVEFUNC_SERVER = {
  pubkey: 'bb0707242a17a4be881919b3dcfea63f42aacedc3ff898a66be30af195ff32b2',
  relays: [WAVEFUNC_RELAY, 'wss://relay2.contextvm.org/'],
};

export interface EventRecord {
  id: string;
  pubkey: string;
  kind: number;
  created_at: number;
  tags: string[][];
  content: string;
}
export interface Stream { url: string; format: string; bitrate?: number; primary: boolean }
export interface Station {
  id: string;
  address: string;
  name: string;
  description: string;
  location: string;
  genres: string[];
  thumbnail: string;
  streams: Stream[];
  createdAt: number;
}
export interface FavouriteList {
  id: string;
  identifier: string;
  name: string;
  description: string;
  addresses: string[];
  createdAt: number;
}
export interface MusicResult { id: string; title: string; detail: string; kind: string }
export interface NowPlaying { title: string; artist: string; source: string }
export type SearchMode = 'stations' | 'recordings' | 'artists' | 'releases';

export function record(value: unknown): Record<string, unknown> {
  return value !== null && typeof value === 'object' && !Array.isArray(value)
    ? value as Record<string, unknown> : {};
}
export function text(value: unknown, limit = 500): string {
  return typeof value === 'string' ? value.trim().slice(0, limit) : '';
}
export function json(value: string): Record<string, unknown> {
  try { return record(JSON.parse(value)); } catch { return {}; }
}
function tag(event: EventRecord, name: string): string {
  return text(event.tags.find(t => t[0] === name)?.[1]);
}
export function httpsUrl(value: unknown): string {
  try {
    const url = new URL(text(value, 4096).replace(/^`|`$/g, ''));
    return url.protocol === 'https:' && !url.username && !url.password ? url.href : '';
  } catch { return ''; }
}
export function stationReference(address: string): { kind: number; author: string; identifier: string } | null {
  const match = /^31237:([a-f0-9]{64}):(.+)$/i.exec(address);
  return match ? { kind: STATION_KIND, author: match[1].toLowerCase(), identifier: match[2] } : null;
}
function stream(value: unknown): Stream | null {
  const item = record(value);
  const url = text(item.url, 4096).replace(/^`|`$/g, '');
  if (!url) return null;
  const bitrate = record(item.quality).bitrate;
  return { url, format: (text(item.format) || text(record(item.quality).codec)).toLowerCase(), primary: item.primary === true,
    bitrate: typeof bitrate === 'number' && Number.isFinite(bitrate) ? bitrate : undefined };
}
export function playableStreams(station: Station): Stream[] {
  return station.streams.filter(s => httpsUrl(s.url) &&
    /^(?:mp3|mpeg|ogg|opus|vorbis|wav|wave|audio\/(?:mpeg|mp3|ogg|opus|vorbis|wav|wave|x-wav))$/i.test(s.format.split(';')[0].trim()) &&
    !/\.(m3u8?|pls|mpd)(?:\?|$)/i.test(s.url))
    .sort((a, b) => Number(b.primary === true) - Number(a.primary === true));
}
/** HTTP is an external-link fallback only, never a MEDIA source. */
export function httpStreams(station: Station): Stream[] {
  return station.streams.flatMap(stream => {
    try {
      const url = new URL(stream.url);
      return url.protocol === 'http:' && !url.username && !url.password ? [{ ...stream, url: url.href }] : [];
    } catch { return []; }
  }).sort((a, b) => Number(b.primary === true) - Number(a.primary === true));
}
/** Wavefunc's station route accepts a raw event ID as well as naddr. */
export function wavefuncStationUrl(station: Station): string {
  return /^[a-f0-9]{64}$/i.test(station.id) ? `https://wavefunc.live/station/${station.id.toLowerCase()}` : '';
}
export function parseStation(event: EventRecord): Station | null {
  const identifier = tag(event, 'd');
  const name = tag(event, 'name');
  if (event.kind !== STATION_KIND || !identifier || !name || !/^[a-f0-9]{64}$/i.test(event.pubkey)) return null;
  const content = json(event.content);
  const tagged = event.tags.filter(t => t[0] === 'stream').map(t =>
    stream({ url: t[1], format: t[2], quality: json(t[3] ?? ''), primary: t.includes('primary') }))
    .filter((s): s is Stream => s !== null);
  const streams = tagged.length ? tagged : (Array.isArray(content.streams) ? content.streams : [])
    .map(stream).filter((s): s is Stream => s !== null);
  // Other applications also use kind 31237 for album catalogues. Validate the
  // Wavefunc schema instead of turning every addressable record into a station.
  if (!streams.length) return null;
  return {
    id: event.id, address: `${STATION_KIND}:${event.pubkey}:${identifier}`,
    name,
    description: tag(event, 'description') || text(content.description, 2000),
    location: [tag(event, 'location'), tag(event, 'country')].filter(Boolean).join(' · '),
    genres: event.tags.filter(t => t[0] === 'c' && t[2] === 'genre').map(t => text(t[1])).filter(Boolean).slice(0, 5),
    thumbnail: httpsUrl(event.tags.find(t => t[0] === 'thumbnail')?.[1]), streams, createdAt: event.created_at,
  };
}
export function parseFavouriteList(event: EventRecord): FavouriteList | null {
  const identifier = tag(event, 'd');
  if (event.kind !== FAVOURITES_KIND || tag(event, 'l') !== FAVOURITES_LABEL || !identifier) return null;
  const content = json(event.content);
  return { id: event.id, identifier, name: tag(event, 'name') || text(content.name) || 'Favourite stations',
    description: tag(event, 'description') || text(content.description), createdAt: event.created_at,
    addresses: [...new Set(event.tags.filter(t => t[0] === 'a').map(t => t[1]).filter(a => stationReference(a)))],
  };
}
export function latestBy<T extends { createdAt: number; id: string }>(items: T[], key: (item: T) => string): T[] {
  const latest = new Map<string, T>();
  for (const item of items) {
    const previous = latest.get(key(item));
    if (!previous || item.createdAt > previous.createdAt ||
        (item.createdAt === previous.createdAt && item.id < previous.id)) latest.set(key(item), item);
  }
  return [...latest.values()];
}
/** MCP servers may return structured content or JSON text. Never display raw HTML. */
export function toolPayload(result: unknown): unknown {
  const response = record(result);
  if (response.isError) throw new Error('Wavefunc could not complete this request. Try again.');
  let payload = response.structuredContent;
  if (!payload && Array.isArray(response.content)) {
    const block = response.content.find(b => record(b).type === 'text');
    try { payload = JSON.parse(text(record(block).text, 500_000)); }
    catch { throw new Error('Wavefunc returned an unreadable response.'); }
  }
  const data = record(payload);
  if (data.error) throw new Error(text(data.error));
  if (!('result' in data)) throw new Error('Wavefunc returned an unexpected response.');
  return data.result;
}
export function parseMusic(payload: unknown, mode: SearchMode): MusicResult[] {
  if (!Array.isArray(payload)) throw new Error('Wavefunc returned an unexpected search response.');
  return payload.slice(0, 30).flatMap(item => {
    const row = record(item);
    const title = text(row.title) || text(row.name);
    if (!title) return [];
    return [{ id: text(row.id), title, kind: mode,
      detail: [text(row.artist), text(row.release), text(row.country), text(row.disambiguation), text(row.date)].filter(Boolean).join(' · ') }];
  });
}
export function parseNowPlaying(payload: unknown): NowPlaying | null {
  const data = record(payload);
  if (data.source === 'UNKNOWN' || data.method === 'none') return null;
  const enriched = record(data.enriched);
  const useEnriched = enriched.confidence === 'high' || enriched.confidence === 'medium';
  const title = (useEnriched ? text(enriched.title) : '') || text(data.song) || text(data.title);
  if (!title) return null;
  return { title, artist: (useEnriched ? text(enriched.artist) : '') || text(data.artist), source: text(data.source) };
}