mirror of
https://github.com/hoornet/vega.git
synced 2026-05-09 21:59:12 -07:00
Add podcast playback, Fountain.fm cards, V4V streaming, fix notifications
Podcast feature: - Podcast discovery via Podcast Index API (trending + search) - Persistent player bar with play/pause, seek, speed (1x/1.5x/2x), volume - Audio persists across view navigation, resumes from saved position - Fountain.fm URL detection in feed with rich playable cards - "Play in Wrystr" button on inline audio blocks - V4V streaming sats via NWC (LNURL-pay, 5min accumulation, split payments) - Share what you're listening to (publish note with confirm) - Space key toggles play/pause globally Notification fixes: - Per-notification read tracking (click to mark read) instead of mark-all-on-open - Read notifications persist at 50% opacity, unread get accent border - Always fetches last 7 days, keeps 15 most recent - Filter out own replies from notifications - Sidebar badge shows only unread count
This commit is contained in:
@@ -9,11 +9,12 @@ const YOUTUBE_REGEX = /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be
|
||||
const TIDAL_REGEX = /tidal\.com\/(?:browse\/)?(?:track|album|playlist)\/([a-zA-Z0-9-]+)/;
|
||||
const SPOTIFY_REGEX = /open\.spotify\.com\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/;
|
||||
const VIMEO_REGEX = /vimeo\.com\/(\d+)/;
|
||||
const FOUNTAIN_REGEX = /fountain\.fm\/(episode|show)\/([a-zA-Z0-9-]+)/;
|
||||
const NOSTR_MENTION_REGEX = /nostr:(npub1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|nprofile1[a-z0-9]+|naddr1[a-z0-9]+)/g;
|
||||
const HASHTAG_REGEX = /(?<=\s|^)#(\w{2,})/g;
|
||||
|
||||
export interface ContentSegment {
|
||||
type: "text" | "link" | "image" | "video" | "audio" | "youtube" | "vimeo" | "spotify" | "tidal" | "mention" | "hashtag" | "quote";
|
||||
type: "text" | "link" | "image" | "video" | "audio" | "youtube" | "vimeo" | "spotify" | "tidal" | "fountain" | "mention" | "hashtag" | "quote";
|
||||
value: string; // for "quote": the hex event ID
|
||||
display?: string;
|
||||
mediaId?: string; // video/embed ID for youtube/vimeo
|
||||
@@ -84,6 +85,13 @@ export function parseContent(content: string): ContentSegment[] {
|
||||
length: cleaned.length,
|
||||
segment: { type: "tidal", value: cleaned, mediaType: tidalTypeMatch?.[1] ?? "track", mediaId: tidalMatch[1] },
|
||||
});
|
||||
} else if (FOUNTAIN_REGEX.test(cleaned)) {
|
||||
const fmMatch = cleaned.match(FOUNTAIN_REGEX);
|
||||
allMatches.push({
|
||||
index: match.index,
|
||||
length: cleaned.length,
|
||||
segment: { type: "fountain", value: cleaned, mediaType: fmMatch?.[1] ?? "episode", mediaId: fmMatch?.[2] },
|
||||
});
|
||||
} else {
|
||||
// Shorten display URL
|
||||
let display = cleaned;
|
||||
|
||||
67
src/lib/podcast/fountainFm.ts
Normal file
67
src/lib/podcast/fountainFm.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { PodcastEpisode } from "../../types/podcast";
|
||||
|
||||
export const FOUNTAIN_REGEX = /fountain\.fm\/(episode|show)\/([a-zA-Z0-9-]+)/;
|
||||
|
||||
const CACHE_KEY = "wrystr_fountain_cache";
|
||||
|
||||
function loadCache(): Record<string, PodcastEpisode> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(CACHE_KEY) ?? "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveCache(cache: Record<string, PodcastEpisode>) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export async function resolveFountainEpisode(url: string): Promise<PodcastEpisode | null> {
|
||||
const cache = loadCache();
|
||||
if (cache[url]) return cache[url];
|
||||
|
||||
try {
|
||||
// Fetch the Fountain.fm page and extract og: meta tags
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const html = await res.text();
|
||||
|
||||
const getMetaContent = (property: string): string => {
|
||||
const regex = new RegExp(`<meta[^>]+property=["']${property}["'][^>]+content=["']([^"']+)["']`, "i");
|
||||
const altRegex = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']${property}["']`, "i");
|
||||
const match = html.match(regex) || html.match(altRegex);
|
||||
return match?.[1] ?? "";
|
||||
};
|
||||
|
||||
const title = getMetaContent("og:title");
|
||||
const description = getMetaContent("og:description");
|
||||
const artwork = getMetaContent("og:image");
|
||||
|
||||
// Look for an audio URL in the page (meta or direct link)
|
||||
const audioMatch = html.match(/<meta[^>]+content=["'](https?:\/\/[^"']+\.(mp3|m4a|ogg|opus)[^"']*?)["']/i)
|
||||
|| html.match(/["'](https?:\/\/[^"'\s]+\.(mp3|m4a|ogg|opus)[^"'\s]*?)["']/i);
|
||||
const enclosureUrl = audioMatch?.[1] ?? "";
|
||||
|
||||
if (!title) return null;
|
||||
|
||||
const episode: PodcastEpisode = {
|
||||
guid: `fountain:${url}`,
|
||||
title,
|
||||
enclosureUrl,
|
||||
pubDate: 0,
|
||||
duration: 0,
|
||||
description,
|
||||
artworkUrl: artwork || undefined,
|
||||
showTitle: "",
|
||||
showArtworkUrl: artwork,
|
||||
};
|
||||
|
||||
cache[url] = episode;
|
||||
saveCache(cache);
|
||||
return episode;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
2
src/lib/podcast/index.ts
Normal file
2
src/lib/podcast/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { searchPodcasts, getEpisodes, getTrending, getPodcastByFeedUrl } from "./podcastIndex";
|
||||
export { resolveFountainEpisode, FOUNTAIN_REGEX } from "./fountainFm";
|
||||
101
src/lib/podcast/podcastIndex.ts
Normal file
101
src/lib/podcast/podcastIndex.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { PodcastShow, PodcastEpisode, V4VRecipient } from "../../types/podcast";
|
||||
|
||||
// Free-tier Podcast Index API credentials
|
||||
const API_KEY = "VKWWTGY25NVCKYJWHSNY";
|
||||
const API_SECRET = "ves3#2YKqSvp7ZdRSuRhSgdnCLtFP4tEbzFGxAtW";
|
||||
const API_BASE = "https://api.podcastindex.org/api/1.0";
|
||||
|
||||
async function sha1(message: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(message);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-1", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function apiHeaders(): Promise<Record<string, string>> {
|
||||
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": "Wrystr/1.0",
|
||||
};
|
||||
}
|
||||
|
||||
function mapShow(item: Record<string, unknown>): PodcastShow {
|
||||
return {
|
||||
feedUrl: (item.url as string) ?? "",
|
||||
title: (item.title as string) ?? "",
|
||||
author: (item.author as string) ?? "",
|
||||
artworkUrl: (item.artwork as string) || (item.image as string) || "",
|
||||
description: (item.description as string) ?? "",
|
||||
podcastIndexId: item.id as number,
|
||||
};
|
||||
}
|
||||
|
||||
function extractV4V(value: Record<string, unknown> | undefined): V4VRecipient[] {
|
||||
if (!value) return [];
|
||||
const destinations = value.destinations as Record<string, unknown>[] | 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,
|
||||
}));
|
||||
}
|
||||
|
||||
function mapEpisode(item: Record<string, unknown>, show?: PodcastShow): PodcastEpisode {
|
||||
return {
|
||||
guid: (item.guid as string) || String(item.id ?? ""),
|
||||
title: (item.title as string) ?? "",
|
||||
enclosureUrl: (item.enclosureUrl as string) ?? "",
|
||||
pubDate: (item.datePublished as number) ?? 0,
|
||||
duration: (item.duration as number) ?? 0,
|
||||
description: (item.description as string) ?? "",
|
||||
artworkUrl: (item.feedImage as string) || (item.image as string) || undefined,
|
||||
showTitle: show?.title ?? (item.feedTitle as string) ?? "",
|
||||
showArtworkUrl: show?.artworkUrl ?? (item.feedImage as string) ?? "",
|
||||
podcastIndexId: item.feedId as number | undefined,
|
||||
value: extractV4V(item.value as Record<string, unknown> | undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchPodcasts(query: string): Promise<PodcastShow[]> {
|
||||
const headers = await apiHeaders();
|
||||
const res = await fetch(`${API_BASE}/search/byterm?q=${encodeURIComponent(query)}`, { headers });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return ((data.feeds as Record<string, unknown>[]) ?? []).map(mapShow);
|
||||
}
|
||||
|
||||
export async function getEpisodes(feedId: number): Promise<PodcastEpisode[]> {
|
||||
const headers = await apiHeaders();
|
||||
const res = await fetch(`${API_BASE}/episodes/byfeedid?id=${feedId}&max=50`, { headers });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return ((data.items as Record<string, unknown>[]) ?? []).map((item) => mapEpisode(item));
|
||||
}
|
||||
|
||||
export async function getTrending(): Promise<PodcastShow[]> {
|
||||
const headers = await apiHeaders();
|
||||
const res = await fetch(`${API_BASE}/podcasts/trending?max=20&lang=en`, { headers });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return ((data.feeds as Record<string, unknown>[]) ?? []).map(mapShow);
|
||||
}
|
||||
|
||||
export async function getPodcastByFeedUrl(feedUrl: string): Promise<PodcastShow | null> {
|
||||
const headers = await apiHeaders();
|
||||
const res = await fetch(`${API_BASE}/podcasts/byfeedurl?url=${encodeURIComponent(feedUrl)}`, { headers });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data.feed) return null;
|
||||
return mapShow(data.feed);
|
||||
}
|
||||
132
src/lib/podcast/v4v.ts
Normal file
132
src/lib/podcast/v4v.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { PodcastEpisode, V4VRecipient } from "../../types/podcast";
|
||||
import { payInvoiceViaNWC } from "../lightning/nwc";
|
||||
|
||||
const LNURL_CACHE: Record<string, string> = {};
|
||||
|
||||
async function fetchLnurlPayInvoice(lud16: string, amountMsats: number): Promise<string | null> {
|
||||
try {
|
||||
const [name, domain] = lud16.split("@");
|
||||
if (!name || !domain) return null;
|
||||
|
||||
// Fetch LNURL-pay endpoint
|
||||
const wellKnownUrl = `https://${domain}/.well-known/lnurlp/${name}`;
|
||||
const cacheKey = wellKnownUrl;
|
||||
|
||||
let callbackUrl = LNURL_CACHE[cacheKey];
|
||||
if (!callbackUrl) {
|
||||
const res = await fetch(wellKnownUrl);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data.callback) return null;
|
||||
callbackUrl = data.callback;
|
||||
LNURL_CACHE[cacheKey] = callbackUrl;
|
||||
}
|
||||
|
||||
// Request invoice
|
||||
const separator = callbackUrl.includes("?") ? "&" : "?";
|
||||
const invoiceRes = await fetch(`${callbackUrl}${separator}amount=${amountMsats}`);
|
||||
if (!invoiceRes.ok) return null;
|
||||
const invoiceData = await invoiceRes.json();
|
||||
return invoiceData.pr ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRecipients(episode: PodcastEpisode): V4VRecipient[] {
|
||||
if (episode.value && episode.value.length > 0) return episode.value;
|
||||
return [];
|
||||
}
|
||||
|
||||
let streamingInterval: number | null = null;
|
||||
let accumulatedSats = 0;
|
||||
let accumulatedMinutes = 0;
|
||||
|
||||
export function startStreaming(
|
||||
episode: PodcastEpisode,
|
||||
satsPerMinute: number,
|
||||
nwcUri: string,
|
||||
onPayment: (amount: number) => void,
|
||||
): number {
|
||||
stopStreaming();
|
||||
|
||||
accumulatedSats = 0;
|
||||
accumulatedMinutes = 0;
|
||||
const recipients = getRecipients(episode);
|
||||
|
||||
if (recipients.length === 0) return -1;
|
||||
|
||||
// Normalize splits to sum to 100
|
||||
const totalSplit = recipients.reduce((sum, r) => sum + r.split, 0);
|
||||
|
||||
streamingInterval = window.setInterval(async () => {
|
||||
accumulatedMinutes += 1;
|
||||
accumulatedSats += satsPerMinute;
|
||||
|
||||
// Accumulate for 5 minutes before paying to avoid rate limits
|
||||
if (accumulatedMinutes < 5 && accumulatedMinutes > 0) return;
|
||||
|
||||
const satsToSend = accumulatedSats;
|
||||
accumulatedSats = 0;
|
||||
accumulatedMinutes = 0;
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (!recipient.address || !recipient.address.includes("@")) continue;
|
||||
|
||||
const share = totalSplit > 0 ? recipient.split / totalSplit : 1 / recipients.length;
|
||||
const recipientSats = Math.max(1, Math.round(satsToSend * share));
|
||||
const amountMsats = recipientSats * 1000;
|
||||
|
||||
try {
|
||||
const invoice = await fetchLnurlPayInvoice(recipient.address, amountMsats);
|
||||
if (!invoice) continue;
|
||||
await payInvoiceViaNWC(nwcUri, invoice);
|
||||
onPayment(recipientSats);
|
||||
} catch {
|
||||
// Payment failed — silently continue
|
||||
}
|
||||
}
|
||||
}, 60000); // Every 60 seconds
|
||||
|
||||
return streamingInterval;
|
||||
}
|
||||
|
||||
export function stopStreaming() {
|
||||
if (streamingInterval !== null) {
|
||||
clearInterval(streamingInterval);
|
||||
streamingInterval = null;
|
||||
}
|
||||
accumulatedSats = 0;
|
||||
accumulatedMinutes = 0;
|
||||
}
|
||||
|
||||
export async function boost(
|
||||
episode: PodcastEpisode,
|
||||
totalSats: number,
|
||||
nwcUri: string,
|
||||
): Promise<number> {
|
||||
const recipients = getRecipients(episode);
|
||||
if (recipients.length === 0) return 0;
|
||||
|
||||
const totalSplit = recipients.reduce((sum, r) => sum + r.split, 0);
|
||||
let paid = 0;
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (!recipient.address || !recipient.address.includes("@")) continue;
|
||||
|
||||
const share = totalSplit > 0 ? recipient.split / totalSplit : 1 / recipients.length;
|
||||
const recipientSats = Math.max(1, Math.round(totalSats * share));
|
||||
const amountMsats = recipientSats * 1000;
|
||||
|
||||
try {
|
||||
const invoice = await fetchLnurlPayInvoice(recipient.address, amountMsats);
|
||||
if (!invoice) continue;
|
||||
await payInvoiceViaNWC(nwcUri, invoice);
|
||||
paid += recipientSats;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
return paid;
|
||||
}
|
||||
Reference in New Issue
Block a user