From 4499f4cc6c17d05a90df28e17c216d0c7668ffcf Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Wed, 14 Oct 2026 16:56:06 -0400 Subject: [PATCH 1/2] feat(site): restore root RFC 9728 OAuth PRM and complete Cloudflare Agent Readiness / AEO / GEO suite --- apps/site/src/components/Footer.astro | 23 +++++ apps/site/src/components/PodmatchBadge.astro | 2 +- .../site/src/components/TranscriptModal.astro | 2 +- apps/site/src/layouts/BaseLayout.astro | 13 +-- apps/site/src/lib/html-to-markdown.ts | 14 ++- apps/site/src/lib/sanity/queries/shared.ts | 1 + apps/site/src/lib/structured-data.ts | 4 + .../http-message-signatures-directory.ts | 55 +++++++++++ .../.well-known/oauth-protected-resource.ts | 2 +- apps/site/src/pages/blog/llms.txt.ts | 57 +++++++++++ apps/site/src/pages/developers.astro | 17 +++- apps/site/src/pages/llms.txt.ts | 11 ++- apps/site/src/pages/podcast/[slug].astro | 15 +++ apps/site/src/pages/podcasts/llms.txt.ts | 64 +++++++++++++ apps/site/src/pages/post/[slug].astro | 14 +++ apps/site/tests/tier1-features.test.mjs | 94 ++++++++++++++++++- 16 files changed, 372 insertions(+), 16 deletions(-) create mode 100644 apps/site/src/pages/.well-known/http-message-signatures-directory.ts create mode 100644 apps/site/src/pages/blog/llms.txt.ts create mode 100644 apps/site/src/pages/podcasts/llms.txt.ts diff --git a/apps/site/src/components/Footer.astro b/apps/site/src/components/Footer.astro index 12adc6f82..c7755957f 100644 --- a/apps/site/src/components/Footer.astro +++ b/apps/site/src/components/Footer.astro @@ -14,6 +14,13 @@ const legalLinks = [ { href: "/sponsorships", label: "Sponsorships" }, ]; +const llmResourceLinks = [ + { href: "/llms.txt", label: "llms.txt" }, + { href: "/llms-full.txt", label: "llms-full.txt" }, + { href: "/.well-known/agent-skills/index.json", label: "Agent Skills" }, + { href: "/.well-known/mcp/server-card.json", label: "MCP Card" }, +]; + // Inlined rather than pulled from react-icons — four glyphs do not justify // shipping an icon library (and its React runtime) to every page. const socialLinks = [ @@ -63,6 +70,22 @@ const socialLinks = [ )) } +
{ diff --git a/apps/site/src/components/PodmatchBadge.astro b/apps/site/src/components/PodmatchBadge.astro index 726f81555..febce45db 100644 --- a/apps/site/src/components/PodmatchBadge.astro +++ b/apps/site/src/components/PodmatchBadge.astro @@ -6,7 +6,7 @@ */ --- -
+
+
+ +
+

Real episode content.

+ + + `; + const md = htmlToMarkdown( + sampleHtml, + "https://codingcat.dev/podcast/sample", + ); + assert.ok(md.includes("Real episode content.")); + assert.ok( + !md.includes("Fetching transcript from YouTube"), + "Must strip client-only modal loading skeleton from Markdown", + ); + }); }); }); From 5a5bce93d924c077446d0c309459f989ae9e2489 Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Wed, 14 Oct 2026 17:02:33 -0400 Subject: [PATCH 2/2] feat(podcast): sync Spotify & provider listenLinks and show host logos on site & Agent AJ --- .../workflows/sync-missing-podcast-links.yml | 48 ++ apps/sanity/extract.json | 14 + .../sanity/functions/lib/podcast-providers.ts | 621 ++++++++++++++++++ .../sync-missing-podcast-links/index.ts | 115 ++++ .../functions/sync-podcast-links/index.ts | 104 +++ apps/sanity/schemas/documents/podcast.ts | 10 + apps/site/src/components/ChatModal.astro | 176 ++++- apps/site/src/components/PodcastListen.astro | 232 +++++-- apps/site/src/lib/sanity/queries/search.ts | 4 + apps/site/src/lib/sanity/queries/shared.ts | 3 +- apps/site/src/pages/api/chat.ts | 74 ++- apps/site/src/pages/podcast/[slug].astro | 2 + sanity.blueprint.ts | 38 ++ 13 files changed, 1367 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/sync-missing-podcast-links.yml create mode 100644 apps/sanity/functions/lib/podcast-providers.ts create mode 100644 apps/sanity/functions/sync-missing-podcast-links/index.ts create mode 100644 apps/sanity/functions/sync-podcast-links/index.ts diff --git a/.github/workflows/sync-missing-podcast-links.yml b/.github/workflows/sync-missing-podcast-links.yml new file mode 100644 index 000000000..9af4b768a --- /dev/null +++ b/.github/workflows/sync-missing-podcast-links.yml @@ -0,0 +1,48 @@ +name: Sync Missing Podcast Provider Links + +on: + schedule: + - cron: "0 7 * * *" # Run daily at 7:00 AM UTC + workflow_dispatch: + inputs: + dataset: + description: "Target Sanity Dataset" + required: true + default: "production" + type: choice + options: + - production + - dev + batch_size: + description: "Batch size (number of podcasts to process in this run)" + required: true + default: "250" + type: string + +jobs: + sync-podcast-links: + runs-on: ubuntu-latest + environment: production + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run Missing Podcast Provider Links Sync + run: | + pnpm exec sanity functions test sync-missing-podcast-links --dataset ${{ inputs.dataset || 'production' }} + env: + SANITY_AUTH_TOKEN: ${{ secrets.SANITY_AUTH_TOKEN }} + SANITY_API_WRITE_TOKEN: ${{ secrets.SANITY_API_WRITE_TOKEN }} + SANITY_STUDIO_PROJECT_ID: ${{ vars.SANITY_STUDIO_PROJECT_ID }} + SANITY_STUDIO_DATASET: ${{ inputs.dataset || 'production' }} + SYNC_BATCH_SIZE: ${{ inputs.batch_size || '250' }} diff --git a/apps/sanity/extract.json b/apps/sanity/extract.json index d3376aa8b..c789b873f 100644 --- a/apps/sanity/extract.json +++ b/apps/sanity/extract.json @@ -7743,6 +7743,20 @@ }, "optional": true }, + "castbox": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, + "castro": { + "type": "objectAttribute", + "value": { + "type": "string" + }, + "optional": true + }, "rss": { "type": "objectAttribute", "value": { diff --git a/apps/sanity/functions/lib/podcast-providers.ts b/apps/sanity/functions/lib/podcast-providers.ts new file mode 100644 index 000000000..45cfae26d --- /dev/null +++ b/apps/sanity/functions/lib/podcast-providers.ts @@ -0,0 +1,621 @@ +import crypto from "node:crypto"; + +export const ANCHOR_RSS_URL = "https://anchor.fm/s/115b203c/podcast/rss"; +export const APPLE_PODCAST_ID = "1491655542"; +export const APPLE_SHOW_URL = + "https://podcasts.apple.com/us/podcast/codingcat-dev-podcast/id1491655542"; +export const SPOTIFY_SHOW_URL = + "https://open.spotify.com/show/2kiOI0PCB2jXMU0cdqUy4z"; +export const OVERCAST_SHOW_URL = "https://overcast.fm/itunes1491655542"; +export const POCKET_CASTS_SHOW_UUID = "79f99220-fd32-0137-b5ad-0acc26574db2"; +export const POCKET_CASTS_SHOW_URL = "https://pca.st/aysv9xg5"; +export const CASTBOX_CHANNEL_ID = "2508485"; +export const CASTBOX_SHOW_URL = + "https://castbox.fm/channel/CodingCat.dev-Podcast-id2508485"; +export const CASTRO_SHOW_URL = + "https://castro.fm/podcast/ee563e73-ec44-4460-b253-274ca4236475"; + +export interface PodcastRssEpisodeRecord { + title?: string; + description?: string; + link?: string; + guid?: { + id?: string; + isPermaLink?: boolean; + }; + pubDate?: string; + enclosures?: Array<{ + _key: string; + url: string; + length: number; + type: string; + }>; + itunes?: { + summary?: string; + explicit?: string; + duration?: string; + season?: string; + episode?: string; + episodeType?: string; + image?: { + href?: string; + }; + }; +} + +export interface ListenLinksRecord { + youtube?: string; + spotify?: string; + apple?: string; + overcast?: string; + pocketCasts?: string; + castbox?: string; + castro?: string; + rss?: string; +} + +export interface SanityPodcastDoc { + _id: string; + _type?: string; + title?: string; + slug?: { current?: string } | string; + season?: number; + episode?: number; + youtube?: string; + spotify?: PodcastRssEpisodeRecord | null; + listenLinks?: ListenLinksRecord | null; +} + +export interface ProviderCatalog { + rssItems: PodcastRssEpisodeRecord[]; + spotifyByGuid: Map; + spotifyBySharePath: Map; + spotifyByTitle: Map; + appleByGuid: Map; + appleByTitle: Map; + appleBySeasonEp: Map; + pocketCastsByEnclosure: Map; + pocketCastsBySeasonEp: Map; + pocketCastsByTitle: Map; + castboxByWebsite: Map; + castboxByEnclosure: Map; + castboxByTitle: Map; + castroByTitle: Map; + castroBySeasonEp: Map; +} + +function decodeXml(str: string): string { + return str + .replace(//g, "$1") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/'/g, "'") + .trim(); +} + +export function normalizeTitle(title?: string): string { + if (!title) return ""; + return decodeXml(title) + .toLowerCase() + .replace(/^\s*\d+\s*[.-]\s*\d+\s*[-–—:]?\s*/, "") // strip leading "5.13 - " + .replace(/^s\d+e\d+\s*[-–—:]?\s*/i, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +export function extractSeasonEpisodeFromTitle( + title?: string, +): { season: number; episode: number } | null { + if (!title) return null; + const match = title.match(/^\s*(\d{1,2})\s*[.]\s*(\d{1,3})\b/); + if (match) { + return { + season: parseInt(match[1], 10), + episode: parseInt(match[2], 10), + }; + } + return null; +} + +function getTagValue(xml: string, tag: string): string | undefined { + const regex = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, "i"); + const m = xml.match(regex); + return m ? decodeXml(m[1]) : undefined; +} + +export async function fetchAnchorRssItems(): Promise { + try { + const resp = await fetch(ANCHOR_RSS_URL); + if (!resp.ok) return []; + const xml = await resp.text(); + const itemMatches = [...xml.matchAll(/([\s\S]*?)<\/item>/gi)]; + const items: PodcastRssEpisodeRecord[] = []; + + for (const match of itemMatches) { + const itemXml = match[1]; + const title = getTagValue(itemXml, "title"); + const description = getTagValue(itemXml, "description"); + const link = getTagValue(itemXml, "link"); + const guidId = getTagValue(itemXml, "guid"); + const guidTagMatch = itemXml.match(/]*)>/i); + const isPermaLink = guidTagMatch + ? !/isPermaLink\s*=\s*["']false["']/i.test(guidTagMatch[1]) + : false; + const pubDateRaw = getTagValue(itemXml, "pubDate"); + const pubDate = pubDateRaw + ? new Date(pubDateRaw).toISOString() + : undefined; + + const encMatch = itemXml.match(/]+)\/?>/i); + const enclosures: NonNullable = []; + if (encMatch) { + const attrs = encMatch[1]; + const url = attrs.match(/url=["']([^"']+)["']/i)?.[1]; + const lengthStr = attrs.match(/length=["']([^"']+)["']/i)?.[1]; + const type = attrs.match(/type=["']([^"']+)["']/i)?.[1] || "audio/mpeg"; + if (url) { + enclosures.push({ + _key: crypto + .createHash("md5") + .update(url) + .digest("hex") + .slice(0, 16), + url: decodeXml(url), + length: lengthStr ? parseInt(lengthStr, 10) || 0 : 0, + type, + }); + } + } + + const summary = getTagValue(itemXml, "itunes:summary") || description; + const explicitRaw = getTagValue(itemXml, "itunes:explicit")?.toLowerCase(); + const explicit = + explicitRaw === "yes" || explicitRaw === "true" ? "true" : "false"; + const duration = getTagValue(itemXml, "itunes:duration"); + const season = getTagValue(itemXml, "itunes:season"); + const episode = getTagValue(itemXml, "itunes:episode"); + const episodeType = getTagValue(itemXml, "itunes:episodeType") || "full"; + const imageHref = itemXml.match( + /]*href=["']([^"']+)["']/i, + )?.[1]; + + items.push({ + title, + description: description ? `${description}\n` : undefined, + link, + guid: guidId ? { id: guidId, isPermaLink } : undefined, + pubDate, + enclosures, + itunes: { + summary: summary ? `${summary}\n` : undefined, + explicit, + duration, + season, + episode, + episodeType, + image: imageHref ? { href: decodeXml(imageHref) } : undefined, + }, + }); + } + + return items; + } catch (err) { + console.warn("[Podcast Providers] Failed to fetch Anchor RSS:", err); + return []; + } +} + +function signCastboxQuery(queryStr: string): string { + const permOrder = [ + 24, 13, 4, 19, 6, 0, 8, 21, 25, 7, 28, 1, 15, 31, 10, 9, 17, 18, 22, 11, 27, + 23, 2, 26, 12, 5, 29, 14, 20, 30, 16, 3, + ]; + const params = Object.fromEntries(new URLSearchParams(queryStr)); + params.r = "1"; + const sortedKeys = Object.keys(params).sort(); + const canonical = sortedKeys.map((k) => `${k}=${params[k]}`).join("&"); + const dateKey = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + const md5 = crypto + .createHash("md5") + .update(`${canonical}evst${dateKey}`) + .digest("hex"); + const signature = permOrder.map((idx) => md5[idx]).join(""); + return `${queryStr}&m=${dateKey}&n=${signature}&r=1`; +} + +export async function fetchProviderCatalog(): Promise { + const catalog: ProviderCatalog = { + rssItems: [], + spotifyByGuid: new Map(), + spotifyBySharePath: new Map(), + spotifyByTitle: new Map(), + appleByGuid: new Map(), + appleByTitle: new Map(), + appleBySeasonEp: new Map(), + pocketCastsByEnclosure: new Map(), + pocketCastsBySeasonEp: new Map(), + pocketCastsByTitle: new Map(), + castboxByWebsite: new Map(), + castboxByEnclosure: new Map(), + castboxByTitle: new Map(), + castroByTitle: new Map(), + castroBySeasonEp: new Map(), + }; + + await Promise.all([ + // 1. Anchor RSS + (async () => { + catalog.rssItems = await fetchAnchorRssItems(); + })(), + + // 2. Spotify Creators profile state (contains all 191 episodes + open.spotify.com/episode/... URLs) + (async () => { + try { + const resp = await fetch( + "https://creators.spotify.com/pod/profile/codingcatdev/", + { + headers: { "User-Agent": "Mozilla/5.0" }, + }, + ); + if (!resp.ok) return; + const html = await resp.text(); + const match = html.match( + /window\.__STATE__\s*=\s*(\{[\s\S]*?\});\s*\n/, + ); + if (!match) return; + const state = JSON.parse(match[1]); + const episodes: any[] = state?.episodePreview?.episodes || []; + for (const ep of episodes) { + const spotifyUrl = ep?.spotifyUrl; + if (!spotifyUrl || !spotifyUrl.startsWith("http")) continue; + if (ep.podcastEpisodeUuid) { + catalog.spotifyByGuid.set(ep.podcastEpisodeUuid, spotifyUrl); + } + if (ep.shareLinkPath) { + catalog.spotifyBySharePath.set(ep.shareLinkPath, spotifyUrl); + } + const norm = normalizeTitle(ep.title); + if (norm) { + catalog.spotifyByTitle.set(norm, spotifyUrl); + } + } + } catch (err) { + console.warn( + "[Podcast Providers] Failed to fetch Spotify Creators catalog:", + err, + ); + } + })(), + + // 3. Apple Podcasts iTunes Lookup API + (async () => { + try { + const resp = await fetch( + `https://itunes.apple.com/lookup?id=${APPLE_PODCAST_ID}&entity=podcastEpisode&limit=200`, + ); + if (!resp.ok) return; + const data = (await resp.json()) as any; + const results: any[] = data?.results || []; + for (const item of results) { + if (item.wrapperType !== "podcastEpisode" || !item.trackViewUrl) + continue; + const cleanAppleUrl = item.trackViewUrl.replace(/&uo=4$/, ""); + if (item.episodeGuid) { + catalog.appleByGuid.set(item.episodeGuid, cleanAppleUrl); + } + const norm = normalizeTitle(item.trackName); + if (norm) { + catalog.appleByTitle.set(norm, cleanAppleUrl); + } + const se = extractSeasonEpisodeFromTitle(item.trackName); + if (se) { + catalog.appleBySeasonEp.set( + `${se.season}:${se.episode}`, + cleanAppleUrl, + ); + } + } + } catch (err) { + console.warn("[Podcast Providers] Failed to fetch Apple catalog:", err); + } + })(), + + // 4. Pocket Casts Public API + (async () => { + try { + const resp = await fetch( + `https://podcast-api.pocketcasts.com/podcast/full/${POCKET_CASTS_SHOW_UUID}`, + ); + if (!resp.ok) return; + const data = (await resp.json()) as any; + const episodes: any[] = data?.podcast?.episodes || []; + for (const ep of episodes) { + if (!ep.uuid) continue; + const pcUrl = `https://pca.st/episode/${ep.uuid}`; + if (ep.url) { + catalog.pocketCastsByEnclosure.set(ep.url, pcUrl); + } + if (ep.season && ep.number) { + catalog.pocketCastsBySeasonEp.set( + `${ep.season}:${ep.number}`, + pcUrl, + ); + } + const se = extractSeasonEpisodeFromTitle(ep.title); + if (se) { + catalog.pocketCastsBySeasonEp.set( + `${se.season}:${se.episode}`, + pcUrl, + ); + } + const norm = normalizeTitle(ep.title); + if (norm) { + catalog.pocketCastsByTitle.set(norm, pcUrl); + } + } + } catch (err) { + console.warn( + "[Podcast Providers] Failed to fetch Pocket Casts catalog:", + err, + ); + } + })(), + + // 5. Castbox Public API + (async () => { + try { + const q = `cid=${CASTBOX_CHANNEL_ID}&skip=0&limit=200&ascending=0&web=1`; + const resp = await fetch( + `https://everest.castbox.fm/data/episode_list/v2?${signCastboxQuery(q)}`, + ); + if (!resp.ok) return; + const json = (await resp.json()) as any; + const episodes: any[] = json?.data?.episode_list || []; + for (const ep of episodes) { + if (!ep.eid) continue; + const cbUrl = `https://castbox.fm/episode/id${CASTBOX_CHANNEL_ID}-id${ep.eid}`; + if (ep.website) { + catalog.castboxByWebsite.set(ep.website, cbUrl); + } + if (ep.url) { + catalog.castboxByEnclosure.set(ep.url, cbUrl); + } + const norm = normalizeTitle(ep.title); + if (norm) { + catalog.castboxByTitle.set(norm, cbUrl); + } + // Extract Spotify episode ID from Castbox's podcast_transcript URL if needed + const transcriptUrl: string | undefined = + ep.podcast_transcript?.[0]?.url; + const spMatch = transcriptUrl?.match( + /spotifycdn\.com\/[^/]+\/([a-zA-Z0-9]+)\//, + ); + if (spMatch?.[1] && norm && !catalog.spotifyByTitle.has(norm)) { + catalog.spotifyByTitle.set( + norm, + `https://open.spotify.com/episode/${spMatch[1]}`, + ); + } + } + } catch (err) { + console.warn( + "[Podcast Providers] Failed to fetch Castbox catalog:", + err, + ); + } + })(), + + // 6. Castro Show Page + (async () => { + try { + const resp = await fetch(CASTRO_SHOW_URL, { + headers: { "User-Agent": "Mozilla/5.0" }, + }); + if (!resp.ok) return; + const html = await resp.text(); + const itemRegex = + /\s*([\s\S]*?)\s*<\/a>[\s\S]*?\s*([\s\S]*?)\s*<\/div>/gi; + let match: RegExpExecArray | null; + while ((match = itemRegex.exec(html)) !== null) { + const castroUrl = match[1]; + const rawTitle = decodeXml(match[2]); + const meta = match[3]; + const norm = normalizeTitle(rawTitle); + if (norm) { + catalog.castroByTitle.set(norm, castroUrl); + } + const seTitle = extractSeasonEpisodeFromTitle(rawTitle); + if (seTitle) { + catalog.castroBySeasonEp.set( + `${seTitle.season}:${seTitle.episode}`, + castroUrl, + ); + } + const seMeta = meta.match(/S(\d+)\s*•\s*E(\d+)/i); + if (seMeta) { + catalog.castroBySeasonEp.set( + `${parseInt(seMeta[1], 10)}:${parseInt(seMeta[2], 10)}`, + castroUrl, + ); + } + } + } catch (err) { + console.warn( + "[Podcast Providers] Failed to fetch Castro catalog:", + err, + ); + } + })(), + ]); + + return catalog; +} + +export function matchRssItemForPodcast( + doc: SanityPodcastDoc, + rssItems: PodcastRssEpisodeRecord[], +): PodcastRssEpisodeRecord | null { + const slug = + typeof doc.slug === "string" ? doc.slug : doc.slug?.current || ""; + const normDocTitle = normalizeTitle(doc.title); + + // 1. Match by codingcat.dev/podcast/ inside RSS description + if (slug) { + const bySlug = rssItems.find((item) => + item.description + ?.toLowerCase() + .includes(`/podcast/${slug.toLowerCase()}`), + ); + if (bySlug) return bySlug; + } + + // 2. Match by (season, episode) + if (doc.season && doc.episode) { + const bySeasonEp = rssItems.find((item) => { + const iSeason = item.itunes?.season + ? parseInt(item.itunes.season, 10) + : undefined; + const iEp = item.itunes?.episode + ? parseInt(item.itunes.episode, 10) + : undefined; + if (iSeason === doc.season && iEp === doc.episode) return true; + const parsed = extractSeasonEpisodeFromTitle(item.title); + return parsed?.season === doc.season && parsed?.episode === doc.episode; + }); + if (bySeasonEp) return bySeasonEp; + } + + // 3. Match by normalized title + if (normDocTitle) { + const byTitle = rssItems.find( + (item) => normalizeTitle(item.title) === normDocTitle, + ); + if (byTitle) return byTitle; + } + + return null; +} + +export function resolvePodcastLinks( + doc: SanityPodcastDoc, + catalog: ProviderCatalog, +): { + spotifyRecord: PodcastRssEpisodeRecord | null; + listenLinks: ListenLinksRecord; + changed: boolean; +} { + const existingSpotify = doc.spotify?.guid?.id ? doc.spotify : null; + const matchedRss = + existingSpotify || matchRssItemForPodcast(doc, catalog.rssItems); + + const guid = matchedRss?.guid?.id; + const enclosureUrl = matchedRss?.enclosures?.[0]?.url; + const podcastersLink = matchedRss?.link; + const sharePath = podcastersLink + ? podcastersLink.replace(/^https?:\/\/[^/]+\/pod\/show/, "") + : undefined; + + const normRssTitle = normalizeTitle(matchedRss?.title); + const normDocTitle = normalizeTitle(doc.title); + const seFromRss = extractSeasonEpisodeFromTitle(matchedRss?.title); + const season = + doc.season || + (matchedRss?.itunes?.season + ? parseInt(matchedRss.itunes.season, 10) + : seFromRss?.season); + const episode = + doc.episode || + (matchedRss?.itunes?.episode + ? parseInt(matchedRss.itunes.episode, 10) + : seFromRss?.episode); + const seKey = season && episode ? `${season}:${episode}` : undefined; + + // Resolve Spotify URL + const spotifyEpisodeUrl = + (guid && catalog.spotifyByGuid.get(guid)) || + (sharePath && catalog.spotifyBySharePath.get(sharePath)) || + (normRssTitle && catalog.spotifyByTitle.get(normRssTitle)) || + (normDocTitle && catalog.spotifyByTitle.get(normDocTitle)) || + doc.listenLinks?.spotify || + podcastersLink || + undefined; + + // Resolve Apple Podcasts URL + const appleEpisodeUrl = + (guid && catalog.appleByGuid.get(guid)) || + (seKey && catalog.appleBySeasonEp.get(seKey)) || + (normRssTitle && catalog.appleByTitle.get(normRssTitle)) || + (normDocTitle && catalog.appleByTitle.get(normDocTitle)) || + doc.listenLinks?.apple || + (matchedRss ? APPLE_SHOW_URL : undefined); + + // Resolve Pocket Casts URL + const pocketCastsUrl = + (enclosureUrl && catalog.pocketCastsByEnclosure.get(enclosureUrl)) || + (seKey && catalog.pocketCastsBySeasonEp.get(seKey)) || + (normRssTitle && catalog.pocketCastsByTitle.get(normRssTitle)) || + (normDocTitle && catalog.pocketCastsByTitle.get(normDocTitle)) || + doc.listenLinks?.pocketCasts || + (matchedRss ? POCKET_CASTS_SHOW_URL : undefined); + + // Resolve Castbox URL + const castboxUrl = + (podcastersLink && catalog.castboxByWebsite.get(podcastersLink)) || + (enclosureUrl && catalog.castboxByEnclosure.get(enclosureUrl)) || + (normRssTitle && catalog.castboxByTitle.get(normRssTitle)) || + (normDocTitle && catalog.castboxByTitle.get(normDocTitle)) || + doc.listenLinks?.castbox || + (matchedRss ? CASTBOX_SHOW_URL : undefined); + + // Resolve Castro URL + const castroUrl = + (seKey && catalog.castroBySeasonEp.get(seKey)) || + (normRssTitle && catalog.castroByTitle.get(normRssTitle)) || + (normDocTitle && catalog.castroByTitle.get(normDocTitle)) || + doc.listenLinks?.castro || + (matchedRss ? CASTRO_SHOW_URL : undefined); + + const youtubeUrl = doc.youtube || doc.listenLinks?.youtube || undefined; + const overcastUrl = + doc.listenLinks?.overcast || (matchedRss ? OVERCAST_SHOW_URL : undefined); + const rssUrl = + doc.listenLinks?.rss || (matchedRss ? ANCHOR_RSS_URL : undefined); + + const nextListenLinks: ListenLinksRecord = { + ...(doc.listenLinks || {}), + ...(youtubeUrl ? { youtube: youtubeUrl } : {}), + ...(spotifyEpisodeUrl ? { spotify: spotifyEpisodeUrl } : {}), + ...(appleEpisodeUrl ? { apple: appleEpisodeUrl } : {}), + ...(overcastUrl ? { overcast: overcastUrl } : {}), + ...(pocketCastsUrl ? { pocketCasts: pocketCastsUrl } : {}), + ...(castboxUrl ? { castbox: castboxUrl } : {}), + ...(castroUrl ? { castro: castroUrl } : {}), + ...(rssUrl ? { rss: rssUrl } : {}), + }; + + const spotifyChanged = !existingSpotify && Boolean(matchedRss); + const currentLinks = doc.listenLinks || {}; + const keys: Array = [ + "youtube", + "spotify", + "apple", + "overcast", + "pocketCasts", + "castbox", + "castro", + "rss", + ]; + const linksChanged = keys.some( + (k) => (nextListenLinks[k] || "") !== (currentLinks[k] || ""), + ); + + return { + spotifyRecord: spotifyChanged ? matchedRss : null, + listenLinks: nextListenLinks, + changed: spotifyChanged || linksChanged, + }; +} diff --git a/apps/sanity/functions/sync-missing-podcast-links/index.ts b/apps/sanity/functions/sync-missing-podcast-links/index.ts new file mode 100644 index 000000000..15d484464 --- /dev/null +++ b/apps/sanity/functions/sync-missing-podcast-links/index.ts @@ -0,0 +1,115 @@ +import { scheduledEventHandler } from "@sanity/functions"; +import { createClient } from "@sanity/client"; +import { + fetchProviderCatalog, + resolvePodcastLinks, + type SanityPodcastDoc, +} from "../lib/podcast-providers"; + +export const handler = scheduledEventHandler(async ({ context }) => { + const token = + process.env.SANITY_API_WRITE_TOKEN || + process.env.SANITY_AUTH_TOKEN || + context.clientOptions?.token; + + if (!token) { + console.error( + "[Sync Missing Podcast Links] Missing Sanity write token. Aborting scheduled sync.", + ); + return; + } + + const dataset = + process.env.SANITY_STUDIO_DATASET || + context.clientOptions?.dataset || + "production"; + + const projectId = + context.clientOptions?.projectId || + process.env.SANITY_STUDIO_PROJECT_ID || + "hfh83o0w"; + + const client = createClient({ + projectId, + dataset, + token, + apiVersion: "2025-09-30", + useCdn: false, + }); + + const batchSize = parseInt(process.env.SYNC_BATCH_SIZE || "250", 10); + + console.log( + `[Sync Missing Podcast Links] Checking podcasts with missing provider links in ${projectId}/${dataset} (limit: ${batchSize})...`, + ); + + const docs = await client.fetch( + `*[_type == "podcast" && !(_id in path("drafts.**")) && ( + !defined(spotify) || + !defined(listenLinks.spotify) || + !defined(listenLinks.apple) || + !defined(listenLinks.pocketCasts) || + !defined(listenLinks.castbox) || + !defined(listenLinks.castro) || + !defined(listenLinks.overcast) || + !defined(listenLinks.rss) + )] | order(date desc)[0...$batchSize]{ + _id, + _type, + title, + slug, + season, + episode, + youtube, + spotify, + listenLinks + }`, + { batchSize }, + ); + + if (!docs.length) { + console.log( + "[Sync Missing Podcast Links] All podcast documents have complete provider links.", + ); + return; + } + + console.log( + `[Sync Missing Podcast Links] Found ${docs.length} podcasts needing provider link resolution. Fetching catalogs...`, + ); + + const catalog = await fetchProviderCatalog(); + let updatedCount = 0; + + for (const doc of docs) { + try { + const { spotifyRecord, listenLinks, changed } = resolvePodcastLinks( + doc, + catalog, + ); + if (!changed) continue; + + const patchPayload: Record = { + listenLinks, + }; + if (spotifyRecord) { + patchPayload.spotify = spotifyRecord; + } + + await client.patch(doc._id).set(patchPayload).commit(); + updatedCount++; + console.log( + `[Sync Missing Podcast Links] Patched ${doc._id} (${doc.title || "Untitled"})`, + ); + } catch (err) { + console.error( + `[Sync Missing Podcast Links] Failed to patch ${doc._id}:`, + err, + ); + } + } + + console.log( + `[Sync Missing Podcast Links] Completed run. Updated ${updatedCount}/${docs.length} podcast documents.`, + ); +}); diff --git a/apps/sanity/functions/sync-podcast-links/index.ts b/apps/sanity/functions/sync-podcast-links/index.ts new file mode 100644 index 000000000..a37551dd3 --- /dev/null +++ b/apps/sanity/functions/sync-podcast-links/index.ts @@ -0,0 +1,104 @@ +import { documentEventHandler } from "@sanity/functions"; +import { createClient } from "@sanity/client"; +import { + fetchProviderCatalog, + resolvePodcastLinks, + type SanityPodcastDoc, +} from "../lib/podcast-providers"; + +export const handler = documentEventHandler( + async ({ context, event }) => { + const rawData = event.data; + if (!rawData?._id || rawData._type !== "podcast") { + return; + } + + if (rawData._id.startsWith("drafts.")) { + return; + } + + const token = + context.clientOptions?.token || + process.env.SANITY_API_WRITE_TOKEN || + process.env.SANITY_AUTH_TOKEN; + + if (!token) { + console.error( + "[Sync Podcast Links] Missing Sanity write token. Cannot patch podcast document.", + ); + return; + } + + const client = createClient({ + ...context.clientOptions, + projectId: + context.clientOptions?.projectId || + process.env.SANITY_STUDIO_PROJECT_ID || + "hfh83o0w", + dataset: + context.clientOptions?.dataset || + process.env.SANITY_STUDIO_DATASET || + "production", + token, + apiVersion: "2025-09-30", + useCdn: false, + }); + + const fetchedDoc = await client + .getDocument(rawData._id) + .catch(() => undefined); + const doc: SanityPodcastDoc = fetchedDoc || rawData; + if (!doc?._id) return; + + // Quick check: if spotify and all core provider links are already populated, skip outbound fetches + const links = doc.listenLinks; + const hasAllCoreProviders = Boolean( + doc.spotify?.guid?.id && + links?.spotify && + links?.apple && + links?.pocketCasts && + links?.castbox && + links?.castro && + links?.overcast && + links?.rss && + (!doc.youtube || links?.youtube === doc.youtube), + ); + + if (hasAllCoreProviders) { + console.log( + `[Sync Podcast Links] Document ${doc._id} already has spotify and all listenLinks populated. Skipping.`, + ); + return; + } + + console.log( + `[Sync Podcast Links] Resolving podcast provider links for ${doc._id} (${doc.title || "Untitled"})...`, + ); + + const catalog = await fetchProviderCatalog(); + const { spotifyRecord, listenLinks, changed } = resolvePodcastLinks( + doc, + catalog, + ); + + if (!changed) { + console.log( + `[Sync Podcast Links] No new provider links found for ${doc._id}.`, + ); + return; + } + + const patchPayload: Record = { + listenLinks, + }; + if (spotifyRecord) { + patchPayload.spotify = spotifyRecord; + } + + await client.patch(doc._id).set(patchPayload).commit(); + console.log( + `[Sync Podcast Links] Updated ${doc._id} with provider links:`, + Object.keys(listenLinks), + ); + }, +); diff --git a/apps/sanity/schemas/documents/podcast.ts b/apps/sanity/schemas/documents/podcast.ts index 24739d6fa..7221c92a4 100644 --- a/apps/sanity/schemas/documents/podcast.ts +++ b/apps/sanity/schemas/documents/podcast.ts @@ -239,6 +239,16 @@ export default defineType({ title: "Pocket Casts", type: "string", }), + defineField({ + name: "castbox", + title: "Castbox", + type: "string", + }), + defineField({ + name: "castro", + title: "Castro", + type: "string", + }), defineField({ name: "rss", title: "RSS", diff --git a/apps/site/src/components/ChatModal.astro b/apps/site/src/components/ChatModal.astro index 6cfa46179..005046578 100644 --- a/apps/site/src/components/ChatModal.astro +++ b/apps/site/src/components/ChatModal.astro @@ -221,9 +221,93 @@ .replace(/'/g, "'"); } + const PROVIDER_CONFIG: Array<{ + key: string; + label: string; + colorClass: string; + svg: string; + }> = [ + { + key: "spotify", + label: "Spotify", + colorClass: "text-[#1DB954]", + svg: '', + }, + { + key: "apple", + label: "Apple", + colorClass: "text-[#A555F7]", + svg: '', + }, + { + key: "youtube", + label: "YouTube", + colorClass: "text-[#FF0000]", + svg: '', + }, + { + key: "pocketCasts", + label: "Pocket Casts", + colorClass: "text-[#F43E37]", + svg: '', + }, + { + key: "castbox", + label: "Castbox", + colorClass: "text-[#F55B23]", + svg: '', + }, + { + key: "castro", + label: "Castro", + colorClass: "text-[#00B265]", + svg: '', + }, + { + key: "overcast", + label: "Overcast", + colorClass: "text-[#FC7E0F]", + svg: '', + }, + { + key: "rss", + label: "RSS", + colorClass: "text-[#FFA500]", + svg: '', + }, + ]; + + function stripProviderTrailer(text: string): string { + return text.replace(/\n?|$)/g, ""); + } + function renderRichAssistantContent(container: HTMLElement, rawText: string) { - // Clean up any localhost or malformed url artifacts - let cleanedText = rawText + // Extract podcast provider metadata trailer if present + const providerMap = new Map>(); + const providerItems: Array<{ + title: string; + url: string; + listenLinks: Record; + }> = []; + const trailerMatch = rawText.match( + //, + ); + if (trailerMatch?.[1]) { + try { + const parsed = JSON.parse(trailerMatch[1]); + if (Array.isArray(parsed)) { + for (const item of parsed) { + if (item?.url && item?.listenLinks) { + providerMap.set(item.url, item.listenLinks); + providerItems.push(item); + } + } + } + } catch {} + } + + // Clean up any localhost, trailer, or malformed url artifacts + let cleanedText = stripProviderTrailer(rawText) .replace(/http:\/\/localhost:\d+/g, "") .replace(/\[\s*["']?(\/[a-zA-Z0-9_\-\/]+)["']?\s*\]\((?:http:\/\/localhost:\d+)?(\/[a-zA-Z0-9_\-\/]+)\)/g, "[$1]($2)") .replace(/\[\s*["']?(\/[a-zA-Z0-9_\-\/]+)["']?\s*\]/g, "[$1]($1)"); @@ -251,6 +335,21 @@ } } + // Ensure podcast items from tool results that were mentioned are included in internalLinks + for (const pItem of providerItems) { + if ( + !internalLinks.some((l) => l.url === pItem.url) && + (internalLinks.length === 0 || + cleanedText.toLowerCase().includes(pItem.title.toLowerCase().slice(0, 24))) + ) { + internalLinks.push({ + title: pItem.title, + url: pItem.url, + type: "podcast", + }); + } + } + // Convert markdown formatting to safe HTML let html = escapeHtml(cleanedText); @@ -307,31 +406,64 @@ cardsHtml += `
`; } - // 2. Clickable Episode / Post Cards + // 2. Clickable Episode / Post Cards (+ Podcast Host Provider Logos) if (internalLinks.length > 0) { cardsHtml += `
`; for (const link of internalLinks) { const isPodcast = link.type === "podcast"; + const linksForEp = providerMap.get(link.url); + let providerBadgesHtml = ""; + if (isPodcast && linksForEp) { + const badges = PROVIDER_CONFIG.filter((p) => Boolean(linksForEp[p.key])) + .map( + (p) => ` + + ${p.svg} + ${p.label} + + `, + ) + .join(""); + if (badges) { + providerBadgesHtml = ` +
+ Listen on: + ${badges} +
+ `; + } + } + cardsHtml += ` - -
- `; @@ -412,8 +544,8 @@ if (done) break; const chunk = decoder.decode(value, { stream: true }); accumulatedText += chunk; - // Render progressive text during streaming - assistantContent.textContent = accumulatedText; + // Render progressive text during streaming (without metadata trailer) + assistantContent.textContent = stripProviderTrailer(accumulatedText); messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: "smooth" }); } diff --git a/apps/site/src/components/PodcastListen.astro b/apps/site/src/components/PodcastListen.astro index d4455d203..23f6560eb 100644 --- a/apps/site/src/components/PodcastListen.astro +++ b/apps/site/src/components/PodcastListen.astro @@ -1,86 +1,222 @@ --- import { stegaClean } from "@sanity/client/stega"; +interface ListenLinks { + youtube?: string | null; + spotify?: string | null; + apple?: string | null; + overcast?: string | null; + pocketCasts?: string | null; + castbox?: string | null; + castro?: string | null; + rss?: string | null; +} + interface Props { title: string; audioUrl?: string | null; youtube?: string | null; + spotifyLink?: string | null; + listenLinks?: ListenLinks | null; } -const { title, audioUrl, youtube } = Astro.props; +const { title, audioUrl, youtube, spotifyLink, listenLinks } = Astro.props; const src = stegaClean(audioUrl); -const youtubeUrl = stegaClean(youtube); const cleanTitle = stegaClean(title) ?? ""; +const cleanLinks = stegaClean(listenLinks) ?? {}; +const cleanSpotifyLink = stegaClean(spotifyLink); +const cleanYoutube = stegaClean(youtube); -const spotifyUrl = `https://open.spotify.com/search/codingcat.dev%20${encodeURIComponent(cleanTitle)}/episodes`; +const spotifyUrl = + cleanLinks.spotify || + cleanSpotifyLink || + (src + ? `https://open.spotify.com/search/codingcat.dev%20${encodeURIComponent(cleanTitle)}/episodes` + : null); const appleUrl = - "https://podcasts.apple.com/us/podcast/codingcat-dev-podcast/id1491655542"; + cleanLinks.apple || + (src + ? "https://podcasts.apple.com/us/podcast/codingcat-dev-podcast/id1491655542" + : null); +const youtubeUrl = cleanLinks.youtube || cleanYoutube || null; +const pocketCastsUrl = cleanLinks.pocketCasts || null; +const castboxUrl = cleanLinks.castbox || null; +const castroUrl = cleanLinks.castro || null; +const overcastUrl = cleanLinks.overcast || null; +const rssUrl = + cleanLinks.rss || (src ? "https://anchor.fm/s/115b203c/podcast/rss" : null); + +const hasAnyOption = Boolean( + src || + spotifyUrl || + appleUrl || + youtubeUrl || + pocketCastsUrl || + castboxUrl || + castroUrl || + overcastUrl || + rssUrl, +); const buttonClass = - "inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent hover:text-accent-foreground"; + "inline-flex items-center justify-center gap-1.5 rounded-md border border-border/60 bg-card/60 px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-accent hover:text-accent-foreground"; --- { - src && ( -
+ hasAnyOption && ( +

Listening Options

- {/* - * A native audio element, not the floating persistent player — that - * player is a React island and lands with the rest of the islands. - * This keeps every episode playable in the meantime with no JS. - */} - -
- - - Listen on Spotify - - - - Listen on Apple Podcasts - + {src && ( + + )} +
+ {spotifyUrl && ( + + + Spotify + + )} + {appleUrl && ( + + + Apple + + )} {youtubeUrl && ( - Watch on YouTube + YouTube + + )} + {pocketCastsUrl && ( + + + Pocket Casts + + )} + {castboxUrl && ( + + + Castbox + + )} + {castroUrl && ( + + + Castro + + )} + {overcastUrl && ( + + + Overcast + + )} + {rssUrl && ( + + + RSS )}
diff --git a/apps/site/src/lib/sanity/queries/search.ts b/apps/site/src/lib/sanity/queries/search.ts index 148e51c3b..2e2bd6652 100644 --- a/apps/site/src/lib/sanity/queries/search.ts +++ b/apps/site/src/lib/sanity/queries/search.ts @@ -25,6 +25,8 @@ export const semanticSearchQuery = defineQuery(` "excerpt": coalesce(excerpt, transcript->summary), "transcriptSummary": transcript->summary, "youtube": coalesce(youtube, listenLinks.youtube, transcript->youtubeUrl), + listenLinks, + "spotifyLink": spotify.link, coverImage, date, _score @@ -59,6 +61,8 @@ export const textSearchFallbackQuery = defineQuery(` "excerpt": coalesce(excerpt, transcript->summary), "transcriptSummary": transcript->summary, "youtube": coalesce(youtube, listenLinks.youtube, transcript->youtubeUrl), + listenLinks, + "spotifyLink": spotify.link, coverImage, date, _score diff --git a/apps/site/src/lib/sanity/queries/shared.ts b/apps/site/src/lib/sanity/queries/shared.ts index 9b896898e..450dbaedc 100644 --- a/apps/site/src/lib/sanity/queries/shared.ts +++ b/apps/site/src/lib/sanity/queries/shared.ts @@ -81,7 +81,8 @@ export const podcastFields = ` name, site }, - spotify + spotify, + listenLinks `; export const userFields = ` diff --git a/apps/site/src/pages/api/chat.ts b/apps/site/src/pages/api/chat.ts index 3c953ddc7..3a6e3d316 100644 --- a/apps/site/src/pages/api/chat.ts +++ b/apps/site/src/pages/api/chat.ts @@ -148,6 +148,7 @@ export const POST: APIRoute = async ({ request }) => { date?: string; excerpt?: string; youtube?: string | null; + listenLinks?: Record; }> = []; const normalizeAndCollectItems = (rawItems: any[]) => { @@ -166,6 +167,47 @@ export const POST: APIRoute = async ({ request }) => { (slugStr ? `/${itemType === "podcast" ? "podcast" : itemType === "post" ? "post" : itemType}/${slugStr}` : undefined); + const rawLinks = item.listenLinks || {}; + const spotifyLink = + rawLinks.spotify || item.spotifyLink || item.spotify?.link; + const youtubeLink = item.youtube || rawLinks.youtube || undefined; + const hasAudioOrLinks = Boolean( + spotifyLink || + rawLinks.apple || + rawLinks.pocketCasts || + rawLinks.castbox || + rawLinks.castro, + ); + const listenLinks = + itemType === "podcast" + ? { + ...(youtubeLink ? { youtube: youtubeLink } : {}), + ...(spotifyLink ? { spotify: spotifyLink } : {}), + ...(rawLinks.apple + ? { apple: rawLinks.apple } + : hasAudioOrLinks + ? { + apple: + "https://podcasts.apple.com/us/podcast/codingcat-dev-podcast/id1491655542", + } + : {}), + ...(rawLinks.pocketCasts + ? { pocketCasts: rawLinks.pocketCasts } + : {}), + ...(rawLinks.castbox ? { castbox: rawLinks.castbox } : {}), + ...(rawLinks.castro ? { castro: rawLinks.castro } : {}), + ...(rawLinks.overcast + ? { overcast: rawLinks.overcast } + : hasAudioOrLinks + ? { overcast: "https://overcast.fm/itunes1491655542" } + : {}), + ...(rawLinks.rss + ? { rss: rawLinks.rss } + : hasAudioOrLinks + ? { rss: "https://anchor.fm/s/115b203c/podcast/rss" } + : {}), + } + : undefined; return { title: item.title || item.name || "Untitled", type: itemType, @@ -173,7 +215,11 @@ export const POST: APIRoute = async ({ request }) => { url, date: item.date || item.publishedAt, excerpt: item.excerpt || item.transcriptSummary || "", - youtube: item.youtube || undefined, + youtube: youtubeLink, + listenLinks: + listenLinks && Object.keys(listenLinks).length > 0 + ? listenLinks + : undefined, }; }); for (const entry of normalized) { @@ -293,7 +339,9 @@ export const POST: APIRoute = async ({ request }) => { "slug": slug.current, date, excerpt, - youtube + youtube, + listenLinks, + "spotifyLink": spotify.link }`, { types, limit: count }, ); @@ -406,7 +454,9 @@ export const POST: APIRoute = async ({ request }) => { "slug": slug.current, date, excerpt, - youtube + youtube, + listenLinks, + "spotifyLink": spotify.link }`, { types: targetTypes }, ); @@ -581,6 +631,24 @@ export const POST: APIRoute = async ({ request }) => { ); } } + + const podcastProviders = collectedToolItems + .filter( + (item) => item.type === "podcast" && item.url && item.listenLinks, + ) + .slice(0, 5) + .map((item) => ({ + title: item.title, + url: item.url, + listenLinks: item.listenLinks, + })); + if (podcastProviders.length > 0) { + await writer.write( + encoder.encode( + `\n`, + ), + ); + } } catch (streamErr) { console.error("[StreamText Stream Loop Error]", streamErr); const errMsg = diff --git a/apps/site/src/pages/podcast/[slug].astro b/apps/site/src/pages/podcast/[slug].astro index 473a94c34..146a036fd 100644 --- a/apps/site/src/pages/podcast/[slug].astro +++ b/apps/site/src/pages/podcast/[slug].astro @@ -168,6 +168,8 @@ const faqNode = faqSchema(extractFaqsFromPortableText(podcast.content)); title={podcast.title} audioUrl={audioUrl} youtube={podcast.youtube} + spotifyLink={podcast.spotify?.link} + listenLinks={podcast.listenLinks} /> { diff --git a/sanity.blueprint.ts b/sanity.blueprint.ts index 06784acb9..ca44156e3 100644 --- a/sanity.blueprint.ts +++ b/sanity.blueprint.ts @@ -95,5 +95,43 @@ export default defineBlueprint({ process.env.SANITY_STUDIO_DATASET || "production", }, }), + defineDocumentFunction({ + name: "sync-podcast-links", + displayName: "Sync Podcast Spotify & Provider Listen Links", + project: process.env.SANITY_STUDIO_PROJECT_ID || "hfh83o0w", + event: { + on: ["create", "update"], + filter: '_type == "podcast" && !(_id in path("drafts.**"))', + }, + src: "./apps/sanity/functions/sync-podcast-links", + runtime: "nodejs22.x", + timeout: 60, + env: { + SANITY_AUTH_TOKEN: process.env.SANITY_AUTH_TOKEN || "", + SANITY_API_WRITE_TOKEN: process.env.SANITY_API_WRITE_TOKEN || "", + SANITY_STUDIO_PROJECT_ID: + process.env.SANITY_STUDIO_PROJECT_ID || "hfh83o0w", + SANITY_STUDIO_DATASET: + process.env.SANITY_STUDIO_DATASET || "production", + }, + }), + defineScheduledFunction({ + name: "sync-missing-podcast-links", + displayName: "Sync Missing Podcast Spotify & Provider Listen Links", + src: "./apps/sanity/functions/sync-missing-podcast-links", + event: { + expression: "0 7 * * *", // 7:00 AM UTC daily + }, + runtime: "nodejs22.x", + timeout: 300, + env: { + SANITY_AUTH_TOKEN: process.env.SANITY_AUTH_TOKEN || "", + SANITY_API_WRITE_TOKEN: process.env.SANITY_API_WRITE_TOKEN || "", + SANITY_STUDIO_PROJECT_ID: + process.env.SANITY_STUDIO_PROJECT_ID || "hfh83o0w", + SANITY_STUDIO_DATASET: + process.env.SANITY_STUDIO_DATASET || "production", + }, + }), ], });