From c83afeabc4e69d07aef3cbfca8045f44586684e1 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:20:30 +0200 Subject: [PATCH] Enrich Fountain.fm episodes with V4V recipients from Podcast Index Fountain-resolved episodes now get V4V payment splits by looking up the show on Podcast Index and matching the episode. Enables streaming sats to podcast creators directly from the player bar. --- src/lib/podcast/fountainFm.ts | 8 ++- src/lib/podcast/podcastIndexV4V.ts | 96 ++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 src/lib/podcast/podcastIndexV4V.ts diff --git a/src/lib/podcast/fountainFm.ts b/src/lib/podcast/fountainFm.ts index 8d8e63b..8f9d71d 100644 --- a/src/lib/podcast/fountainFm.ts +++ b/src/lib/podcast/fountainFm.ts @@ -1,5 +1,6 @@ import { fetch } from "@tauri-apps/plugin-http"; import type { PodcastEpisode } from "../../types/podcast"; +import { enrichWithV4V } from "./podcastIndexV4V"; export const FOUNTAIN_REGEX = /fountain\.fm\/(episode|show)\/([a-zA-Z0-9-]+)/; @@ -67,9 +68,12 @@ export async function resolveFountainEpisode(url: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(message); + const hashBuffer = await crypto.subtle.digest("SHA-1", data); + return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function apiHeaders(): Promise> { + const apiHeaderTime = Math.floor(Date.now() / 1000).toString(); + const hash = await sha1(API_KEY + API_SECRET + apiHeaderTime); + return { + "X-Auth-Key": API_KEY, + "X-Auth-Date": apiHeaderTime, + "Authorization": hash, + "User-Agent": "Vega/1.0", + }; +} + +function extractV4V(value: Record | undefined): V4VRecipient[] { + if (!value) return []; + const destinations = value.destinations as Record[] | undefined; + if (!Array.isArray(destinations)) return []; + return destinations + .filter((d) => d.address) + .map((d) => ({ + name: d.name as string | undefined, + type: (d.type as string) ?? "wallet", + address: d.address as string, + split: Number(d.split) || 0, + customKey: d.customKey as string | undefined, + customValue: d.customValue as string | undefined, + })); +} + +/** + * Enrich a Fountain-resolved episode with V4V recipients from Podcast Index. + * Searches by show title, then matches the episode by title within that show. + * Returns the original episode unchanged if lookup fails. + */ +export async function enrichWithV4V(episode: PodcastEpisode): Promise { + if (episode.value && episode.value.length > 0) return episode; + if (!episode.showTitle) return episode; + + try { + const headers = await apiHeaders(); + + // Search for the show by title + const searchRes = await fetch( + `${API_BASE}/search/byterm?q=${encodeURIComponent(episode.showTitle)}`, + { headers }, + ); + if (!searchRes.ok) return episode; + const searchData = await searchRes.json(); + + const feeds = searchData.feeds as Record[] | undefined; + if (!feeds || feeds.length === 0) return episode; + + // Find the best matching feed + const showLower = episode.showTitle.toLowerCase(); + const feed = feeds.find((f) => ((f.title as string) ?? "").toLowerCase() === showLower) || feeds[0]; + const feedId = feed.id as number; + if (!feedId) return episode; + + // Get episodes from that feed + const epRes = await fetch(`${API_BASE}/episodes/byfeedid?id=${feedId}&max=20`, { headers }); + if (!epRes.ok) return episode; + const epData = await epRes.json(); + + const items = epData.items as Record[] | undefined; + if (!items || items.length === 0) return episode; + + // Match by episode title (fuzzy: check if PI title contains our title or vice versa) + const epLower = episode.title.toLowerCase(); + const match = items.find((item) => { + const piTitle = ((item.title as string) ?? "").toLowerCase(); + return piTitle === epLower || piTitle.includes(epLower) || epLower.includes(piTitle); + }); + + // Use matched episode's value, or fall back to any episode's value (show-level V4V) + const valueSource = match || items.find((item) => item.value); + if (!valueSource) return episode; + + const value = extractV4V(valueSource.value as Record | undefined); + if (value.length === 0) return episode; + + return { ...episode, value }; + } catch { + return episode; + } +}