diff --git a/.github/workflows/new-episode-check.yml b/.github/workflows/new-episode-check.yml index c8d5b0368..e999337e8 100644 --- a/.github/workflows/new-episode-check.yml +++ b/.github/workflows/new-episode-check.yml @@ -5,6 +5,11 @@ on: # Run every 30 minutes - cron: '*/30 * * * *' workflow_dispatch: # Allow manual triggering + inputs: + dry_run: + description: 'Rehearsal: decide and log, send nothing, save nothing' + type: boolean + default: false concurrency: group: tracked-podcast-rtdb-maintenance @@ -51,8 +56,13 @@ jobs: env: PODCAST_INDEX_API_KEY: ${{ secrets.PODCAST_INDEX_API_KEY }} PODCAST_INDEX_API_SECRET: ${{ secrets.PODCAST_INDEX_API_SECRET }} + # Working feed addresses and served episode IDs from the boxlore API (optional). + BOXLORE_SVC_NOTIFIER_KEY: ${{ secrets.BOXLORE_SVC_NOTIFIER_KEY }} + NOTIFIER_DRY_RUN: ${{ inputs.dry_run && '1' || '' }} + # Runs even when the check failed part-way, so pushes already sent are never sent again. - name: Commit & Push State Updates + if: always() env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | diff --git a/scripts/README.md b/scripts/README.md index 19b5c0f99..d090ba180 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -110,6 +110,14 @@ Release selection and recovery: - The Action never creates negative episode IDs. Unmatched feed-only releases omit `episodeId` and open the podcast page. The phone persists the raw GUID/enclosure hint before hydration and resolves the release in its publisher-feed Room catalog. - Visible alerts request high Android FCM priority. Foreground discovery recovers missed episodes by default. Background auto-download discovery is off by default and requires separate consent; the phone has no periodic notification worker. +Working addresses and episode IDs (with the `BOXLORE_SVC_NOTIFIER_KEY` secret; without it the checker behaves as before): + +- At the start of a run the checker asks the boxlore API (`POST /internal/notifier/shows`) for each catalog show's working feed address. A confirmed address (the registered one is dead and a working one was verified) replaces it for the RSS check and in the push; Podcast Index fallback pushes now carry `feedUrl` too. The first read of a different address quietly seeds a baseline (`rss-address-changed`, with `lastFeedUrl` in the state), because a moved feed can relabel old episodes. +- Before sending, it asks `POST /internal/notifier/episodes` for the episode's served ID, the ID that never changes, and puts it in `episodeId` and `route`. With none (a feed-only episode nobody has been served yet) or when the API fails, the push opens the show instead. +- Every state change is saved, including `unchanged` decisions that moved a key forward. The state file is written even when a run stops part-way, the commit step always runs, and a failed push leaves that show's state alone so it is retried (the run then shows as failed). +- New-episode pushes only go to `new_ep_` and `new_ep_rss_…` topics; broadcast topics are refused. +- A manual run with `dry_run` decides and logs (never feed addresses) without sending or saving anything. + ### Public RSS notification registrations The checker supports explicitly opted-in public `rss:` subscriptions from RTDB. Topics use `new_ep_rss__` and payloads retain `rss:`. Pure RSS failures or disabled URLs preserve the last-good state for retry and never fall back to PI. Each new URL scope quietly seeds a baseline; repeated keys remain quiet and FCM failures do not advance it. diff --git a/scripts/check-new-episodes-lib.js b/scripts/check-new-episodes-lib.js index bad193ca0..7057eea06 100644 --- a/scripts/check-new-episodes-lib.js +++ b/scripts/check-new-episodes-lib.js @@ -48,9 +48,53 @@ function applyPureRssCheck({ existing, item, now = Date.now() }) { return decision; } -async function resolveTrackedRelease({ podcastId, podcastData, existing, fetchRssNewest, fetchPiLatest, now }) { +/** + * The feed address the job reads and puts in pushes (own-episode-IDs plan P2, R6). A confirmed + * registry address (the old one is dead and a working one was verified) wins over the address the + * phone registered; with no registered address, the registry's view (Podcast Index's) is used. + * @param {{ feedUrl?: string, feedSource?: string }|null} registryEntry from /internal/notifier/shows + */ +function workingFeedUrl(podcastData, registryEntry) { + const registered = usableFeedUrl(podcastData && podcastData.feedUrl); + const known = registryEntry ? usableFeedUrl(registryEntry.feedUrl) : null; + if (known && String(registryEntry.feedSource || '').startsWith('registry:')) return known; + return registered || known || null; +} + +/** + * Records which address the RSS state came from when it isn't the registered one, and treats the + * first read of a different address as a quiet baseline: a moved feed can relabel old episodes, and + * that must never look like a new release. + */ +function withFeedAddress(decision, { existing, feedUrl, registered, newest, now }) { + const previous = (existing && existing.lastFeedUrl) || registered; + if (existing && existing.lastRssKey && feedUrl && previous && feedUrl !== previous) { + decision = { + notify: false, + reason: 'rss-address-changed', + nextState: { ...existing, lastRssKey: newest.key, lastEpisodeTitle: newest.title, lastCheckedAt: now }, + }; + } + const nextState = { ...decision.nextState }; + if (feedUrl && feedUrl !== registered) nextState.lastFeedUrl = feedUrl; + else delete nextState.lastFeedUrl; + return { ...decision, nextState }; +} + +/** True when the state for a show changed and must be saved (even when the decision is "unchanged"). */ +function stateChanged(previous, next) { + return JSON.stringify(previous ?? null) !== JSON.stringify(next ?? null); +} + +async function resolveTrackedRelease({ podcastId, podcastData, existing, fetchRssNewest, fetchPiLatest, now, registryEntry = null }) { const pureRss = String(podcastId).startsWith('rss:'); - const feedUrl = usableFeedUrl(podcastData.feedUrl); + const registered = usableFeedUrl(podcastData.feedUrl); + // Without the registry this run (API down, no key), keep the address the state last read, so + // an outage never flips a show back to a dead address and back again. + const remembered = usableFeedUrl(existing && existing.lastFeedUrl); + const feedUrl = pureRss + ? registered + : (registryEntry ? workingFeedUrl(podcastData, registryEntry) : (remembered || registered || null)); let rssItem = null; if (feedUrl) { try { @@ -67,13 +111,51 @@ async function resolveTrackedRelease({ podcastId, podcastData, existing, fetchRs let piEpisode = null; try { piEpisode = await fetchPiLatest(podcastId); } catch (_) { /* RSS owns the release. */ } const matched = rssMatchesPi(rssItem, piEpisode); - const decision = applyCheck({ existing, source: 'rss', newest: { key: rssItemKey(rssItem), title: rssItem.title || 'New Episode', piEpisodeId: matched ? String(piEpisode.id) : undefined }, now }); - return { decision, data: buildRssFcmData({ podcastId, podcastTitle: podcastData.title || 'Podcast', imageUrl: podcastData.imageUrl, rssItem, piEpisode: matched ? piEpisode : null, feedUrl }) }; + const newest = { key: rssItemKey(rssItem), title: rssItem.title || 'New Episode', piEpisodeId: matched ? String(piEpisode.id) : undefined }; + const decision = withFeedAddress(applyCheck({ existing, source: 'rss', newest, now }), { existing, feedUrl, registered, newest, now }); + return { + decision, + data: buildRssFcmData({ podcastId, podcastTitle: podcastData.title || 'Podcast', imageUrl: podcastData.imageUrl, rssItem, piEpisode: matched ? piEpisode : null, feedUrl }), + identity: { piId: matched ? piEpisode.id : null, guid: rssItem.guid || null, enclosureUrl: rssItem.enclosureUrl || null }, + }; } const piEpisode = await fetchPiLatest(podcastId); if (!piEpisode) return null; const decision = applyCheck({ existing, source: 'pi', newest: { piEpisodeId: String(piEpisode.id), title: piEpisode.title || 'New Episode', rssKey: rssItemKey(piEpisode) }, now }); - return { decision, data: buildPiFcmData({ podcastId, podcastTitle: podcastData.title || 'Podcast', imageUrl: podcastData.imageUrl, piEpisode }) }; + return { + decision, + data: buildPiFcmData({ podcastId, podcastTitle: podcastData.title || 'Podcast', imageUrl: podcastData.imageUrl, piEpisode, feedUrl }), + identity: { piId: piEpisode.id, guid: piEpisode.guid || null, enclosureUrl: piEpisode.enclosureUrl || null }, + }; +} + +/** + * Puts the episode's served ID in a push (own-episode-IDs plan P2): a Podcast Index ID can change + * or belong to a renumbered episode, the served ID never does. Without one (an episode only the feed + * lists that no one has been served yet, or the lookup failed) the push opens the show instead, so + * a push never carries an ID that could point at the wrong episode. + */ +function applyServedId(data, podcastId, servedId) { + const out = { ...data }; + if (servedId != null && /^[1-9][0-9]{0,15}$/.test(String(servedId))) { + out.episodeId = String(servedId); + out.route = `boxlore://episode/${servedId}?autoplay=false`; + } else { + delete out.episodeId; + out.route = `boxlore://podcast/${canonicalPodcastId(podcastId)}`; + } + return out; +} + +/** Broadcast topics reach every install: a new-episode push must never go there (0.0.25 hydrates and notifies for any). */ +const BROADCAST_TOPICS = new Set(['all_users', 'prod_users', 'play_users', 'debug_users', 'beta_users']); +const NEW_EPISODE_TOPIC = /^new_ep_([0-9]{1,16}|rss_[A-Za-z0-9._~%-]{1,800})$/; + +function assertNewEpisodeTopic(topic) { + if (typeof topic !== 'string' || BROADCAST_TOPICS.has(topic) || !NEW_EPISODE_TOPIC.test(topic)) { + throw new Error(`refusing to send a new-episode push to topic ${JSON.stringify(topic)}`); + } + return topic; } function decodeXml(value) { @@ -417,7 +499,7 @@ function buildRssFcmData({ return omitEmpty(data); } -function buildPiFcmData({ podcastId, podcastTitle, imageUrl, piEpisode }) { +function buildPiFcmData({ podcastId, podcastTitle, imageUrl, piEpisode, feedUrl }) { const episodeId = String(piEpisode.id); return omitEmpty({ type: 'new_episode', @@ -427,6 +509,8 @@ function buildPiFcmData({ podcastId, podcastTitle, imageUrl, piEpisode }) { episodeId, duration: durationMinutes(piEpisode.duration), image: String(piEpisode.image || piEpisode.feedImage || imageUrl || ''), + // R6: the working address rides along, as it does in RSS pushes. + feedUrl: feedUrl || '', route: `boxlore://episode/${episodeId}?autoplay=false`, }); } @@ -451,7 +535,13 @@ module.exports = { MAX_FEED_BYTES, rssDownloadDecision, fetchRssText, + workingFeedUrl, + withFeedAddress, + stateChanged, + applyServedId, + assertNewEpisodeTopic, + BROADCAST_TOPICS, }; // These topics are opted-in visible release alerts, qualifying for prompt Android delivery. -module.exports.newEpisodeFcmMessage = (topic, data) => ({ topic, data, android: { priority: 'high' } }); +module.exports.newEpisodeFcmMessage = (topic, data) => ({ topic: assertNewEpisodeTopic(topic), data, android: { priority: 'high' } }); diff --git a/scripts/check-new-episodes-lib.test.js b/scripts/check-new-episodes-lib.test.js index 34e00461b..6cc7854c6 100644 --- a/scripts/check-new-episodes-lib.test.js +++ b/scripts/check-new-episodes-lib.test.js @@ -505,3 +505,89 @@ describe('accepted RSS URL isolation', () => { assert.equal(lib.applyPureRssCheck({ existing: kept[newScope], item: { guid: 'new' } }).notify, false); }); }); + +describe('working addresses, served IDs and safe topics (own-episode-IDs P2)', () => { + const repaired = { feedUrl: 'https://new.example/rss', feedSource: 'registry:apple' }; + const dumpOnly = { feedUrl: 'https://pi.example/rss', feedSource: 'pidump' }; + + it('a confirmed registry address wins; the dump fills in only when nothing is registered', () => { + assert.equal(lib.workingFeedUrl({ feedUrl: 'https://old.example/rss' }, repaired), 'https://new.example/rss'); + assert.equal(lib.workingFeedUrl({ feedUrl: 'https://old.example/rss' }, dumpOnly), 'https://old.example/rss'); + assert.equal(lib.workingFeedUrl({}, dumpOnly), 'https://pi.example/rss'); + assert.equal(lib.workingFeedUrl({ feedUrl: 'https://old.example/rss' }, null), 'https://old.example/rss'); + }); + + it('reads the working address, starts it quietly, and remembers it', async () => { + const seen = []; + const existing = { lastRssKey: 'guid-old', lastEpisodeTitle: 'Old', lastEpisodeId: '61', lastCheckedAt: 1 }; + const release = await lib.resolveTrackedRelease({ + podcastId: '74467', podcastData: { title: 'Show', feedUrl: 'https://old.example/rss' }, existing, + registryEntry: repaired, + fetchRssNewest: async (url) => { seen.push(url); return { title: 'Relabelled old episode', guid: 'moved-guid', enclosureUrl: 'https://cdn/a.mp3' }; }, + fetchPiLatest: async () => null, now: 5, + }); + assert.deepEqual(seen, ['https://new.example/rss']); + assert.equal(release.decision.notify, false, 'a moved feed relabelling old episodes is not a release'); + assert.equal(release.decision.reason, 'rss-address-changed'); + assert.equal(release.decision.nextState.lastFeedUrl, 'https://new.example/rss'); + assert.equal(release.data.feedUrl, 'https://new.example/rss', 'the push carries the working address'); + assert.deepEqual(release.identity, { piId: null, guid: 'moved-guid', enclosureUrl: 'https://cdn/a.mp3' }); + + const next = await lib.resolveTrackedRelease({ + podcastId: '74467', podcastData: { title: 'Show', feedUrl: 'https://old.example/rss' }, existing: release.decision.nextState, + registryEntry: repaired, + fetchRssNewest: async () => ({ title: 'Truly new', guid: 'brand-new', enclosureUrl: 'https://cdn/b.mp3' }), + fetchPiLatest: async () => null, now: 6, + }); + assert.equal(next.decision.notify, true, 'after the switch, real releases notify again'); + }); + + it('a registry outage keeps the remembered working address instead of flipping back', async () => { + const seen = []; + const existing = { lastRssKey: 'k1', lastEpisodeTitle: 'A', lastFeedUrl: 'https://new.example/rss', lastCheckedAt: 1 }; + const release = await lib.resolveTrackedRelease({ + podcastId: '74467', podcastData: { title: 'Show', feedUrl: 'https://old.example/rss' }, existing, registryEntry: null, + fetchRssNewest: async (url) => { seen.push(url); return { title: 'B', guid: 'k2', enclosureUrl: 'https://cdn/b.mp3' }; }, + fetchPiLatest: async () => null, now: 2, + }); + assert.deepEqual(seen, ['https://new.example/rss']); + assert.equal(release.decision.notify, true, 'a real release during the outage still notifies'); + assert.equal(release.decision.nextState.lastFeedUrl, 'https://new.example/rss'); + }); + + it('the Podcast Index fallback push carries the working address too', async () => { + const release = await lib.resolveTrackedRelease({ + podcastId: '74467', podcastData: { title: 'Show' }, existing: { lastEpisodeId: '1' }, registryEntry: repaired, + fetchRssNewest: async () => { throw new Error('down'); }, + fetchPiLatest: async () => ({ id: 2, title: 'New', guid: 'g2', enclosureUrl: 'https://cdn/2.mp3' }), now: 1, + }); + assert.equal(release.data.feedUrl, 'https://new.example/rss'); + assert.deepEqual(release.identity, { piId: 2, guid: 'g2', enclosureUrl: 'https://cdn/2.mp3' }); + }); + + it('state changes are saved even when the decision says unchanged', () => { + const existing = { lastRssKey: 'a', lastEpisodeId: '61' }; + const decision = lib.applyCheck({ existing, source: 'rss', newest: { key: 'b', title: 'T', piEpisodeId: '61' }, now: 9 }); + assert.equal(decision.reason, 'unchanged'); + assert.equal(lib.stateChanged(existing, decision.nextState), true, 'the new RSS key must be kept'); + assert.equal(lib.stateChanged(existing, { ...existing }), false); + }); + + it('pushes carry the served ID, or open the show when there is none', () => { + const data = { type: 'new_episode', podcastId: '74467', episodeId: '61', route: 'boxlore://episode/61?autoplay=false' }; + assert.deepEqual(lib.applyServedId(data, '74467', 4503599627370777), { ...data, episodeId: '4503599627370777', route: 'boxlore://episode/4503599627370777?autoplay=false' }); + const none = lib.applyServedId(data, '74467', null); + assert.equal(none.episodeId, undefined); + assert.equal(none.route, 'boxlore://podcast/74467'); + assert.equal(lib.applyServedId(data, '74467', '-5').route, 'boxlore://podcast/74467', 'never a negative or malformed ID'); + }); + + it('never sends a new-episode push to a broadcast topic', () => { + for (const topic of ['all_users', 'prod_users', 'play_users', 'new_ep_', 'new_ep_1 ', 'news', '']) { + assert.throws(() => lib.newEpisodeFcmMessage(topic, {}), /refusing to send/, topic); + } + assert.equal(lib.newEpisodeFcmMessage('new_ep_74467', {}).topic, 'new_ep_74467'); + const rssTopic = lib.notificationTopic('rss:abc', 'https://feeds.example/x'); + assert.equal(lib.newEpisodeFcmMessage(rssTopic, {}).topic, rssTopic); + }); +}); diff --git a/scripts/check-new-episodes.js b/scripts/check-new-episodes.js index 24630c40c..d8113dcd5 100644 --- a/scripts/check-new-episodes.js +++ b/scripts/check-new-episodes.js @@ -61,9 +61,71 @@ async function fetchRssNewest(feedUrl) { return lib.newestRssItem(lib.parseFeedItems(xml)); } +// Our API (own-episode-IDs plan P2): working feed addresses and served episode IDs. Optional: without +// the key, or when the API fails, the job runs as before and pushes open the show instead of an episode. +const API_BASE = (process.env.BOXLORE_API_BASE || 'https://api.aswin.cx').replace(/\/+$/, ''); +const NOTIFIER_KEY = process.env.BOXLORE_SVC_NOTIFIER_KEY || ''; +// Rehearsal: decide and log everything, send nothing, save nothing. +const DRY_RUN = process.env.NOTIFIER_DRY_RUN === '1'; + +async function notifierApi(route, body) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 15000); + try { + const res = await fetch(`${API_BASE}/internal/notifier/${route}`, { + method: 'POST', + headers: { Authorization: `Svc ${NOTIFIER_KEY}`, 'Content-Type': 'application/json', 'User-Agent': 'BoxLore-Notifier/1.0' }, + body: JSON.stringify(body), + signal: ac.signal, + }); + if (!res.ok) throw new Error(`notifier API ${route} returned ${res.status}`); + return await res.json(); + } finally { + clearTimeout(timer); + } +} + +/** Working addresses for numeric show IDs: Map id -> { feedUrl, feedSource }. Empty when unavailable. */ +async function fetchRegistry(ids) { + const out = new Map(); + if (!NOTIFIER_KEY || !ids.length) return out; + try { + for (let i = 0; i < ids.length; i += 500) { + const body = await notifierApi('shows', { ids: ids.slice(i, i + 500).map(Number) }); + for (const r of body.results || []) out.set(String(r.id), r); + } + const repaired = [...out.values()].filter((r) => String(r.feedSource || '').startsWith('registry:')).length; + console.log(`Registry: ${out.size} shows, ${repaired} with a confirmed working address.`); + } catch (e) { + console.warn(`Registry unavailable (${e.message}); using registered addresses.`); + out.clear(); + } + return out; +} + +/** The served ID for this release, or null (then the push opens the show). */ +async function servedEpisodeId(podcastId, identity) { + if (!NOTIFIER_KEY || !identity || String(podcastId).startsWith('rss:')) return null; + if (identity.piId == null && !identity.guid && !identity.enclosureUrl) return null; + try { + const body = await notifierApi('episodes', { items: [{ sid: Number(podcastId), piId: identity.piId ?? undefined, guid: identity.guid || undefined, enclosureUrl: identity.enclosureUrl || undefined }] }); + return body.results?.[0]?.servedId ?? null; + } catch (e) { + console.warn(`Served ID lookup failed for ${podcastId} (${e.message}); the push opens the show.`); + return null; + } +} + async function sendFcm(podcastId, data) { const topic = lib.notificationTopic(podcastId, data.feedUrl); - const messageId = await admin.messaging().send(lib.newEpisodeFcmMessage(topic, data)); + const message = lib.newEpisodeFcmMessage(topic, data); + if (DRY_RUN) { + // Never log feed addresses: private RSS feeds can carry credentials, and run logs can be public. + const safe = String(podcastId).startsWith('rss:') ? 'rss show' : `show ${podcastId}`; + console.log(`[dry run] would send: ${safe}, episode ${data.episodeId || '-'}, route ${data.route}, address ${data.feedUrl ? 'yes' : 'no'}`); + return; + } + const messageId = await admin.messaging().send(message); console.log(`Sent notification ${messageId} to topic: ${topic}`); } @@ -104,45 +166,74 @@ async function run() { state.lastRun = new Date().toISOString(); let changeCount = 0; + let sendFailures = 0; const activePodcasts = lib.groupTrackedPodcasts(trackedPodcasts); state.podcasts = lib.activeEpisodeState(state.podcasts, activePodcasts); + const registry = await fetchRegistry(Object.keys(activePodcasts).filter((id) => /^[1-9][0-9]{0,15}$/.test(id))); - // 4. Poll each tracked podcast for new episodes - for (const [stateId, podcastData] of Object.entries(activePodcasts)) { - const podcastId = lib.canonicalPodcastId(stateId); - if (!podcastData || typeof podcastData !== 'object') { - continue; + // 4. Poll each tracked podcast for new episodes. Every show's state is saved as soon as it is + // decided, and the file is written even if the run stops early, so a retry never resends. + try { + for (const [stateId, podcastData] of Object.entries(activePodcasts)) { + const podcastId = lib.canonicalPodcastId(stateId); + if (!podcastData || typeof podcastData !== 'object') { + continue; + } + const podcastTitle = podcastData.title || "Podcast"; + const existingState = state.podcasts[stateId]; + let release = null; + try { + release = await lib.resolveTrackedRelease({ + podcastId, podcastData, existing: existingState, fetchRssNewest, fetchPiLatest, + registryEntry: registry.get(String(podcastId)) || null, + }); + if (!release) continue; + const { decision } = release; + if (decision.notify) { + const servedId = await servedEpisodeId(podcastId, release.identity); + await sendFcm(podcastId, lib.applyServedId(release.data, podcastId, servedId)); + } + // Save every change, including "unchanged" decisions that moved a key forward. + if (lib.stateChanged(existingState, decision.nextState)) { + state.podcasts[stateId] = decision.nextState; + changeCount++; + } + console.log(`Checked ${podcastId}: ${decision.reason}`); + } catch (podcastError) { + if (podcastError && /refusing to send/.test(podcastError.message)) { + // A broadcast topic would be a bug that reaches every install: stop the run. + if (lib.BROADCAST_TOPICS.has(lib.notificationTopic(podcastId, release?.data?.feedUrl))) throw podcastError; + sendFailures++; + console.error(`Refused the topic for ${String(podcastId).startsWith('rss:') ? 'an rss show' : podcastId}; skipping it.`); + continue; + } + if (podcastError && podcastError.code && String(podcastError.code).startsWith('messaging/')) sendFailures++; + // Never print private feed URLs or request errors for pure RSS registrations. + if (String(podcastId).startsWith('rss:')) console.warn(`RSS check failed for ${podcastId}; retaining baseline for retry`); + else console.error(`Error checking podcast ${podcastTitle} (${podcastId}):`, podcastError); + } } - const podcastTitle = podcastData.title || "Podcast"; - const existingState = state.podcasts[stateId]; - try { - const release = await lib.resolveTrackedRelease({ - podcastId, podcastData, existing: existingState, fetchRssNewest, fetchPiLatest, - }); - if (!release) continue; - const { decision, data } = release; - if (decision.notify) await sendFcm(podcastId, data); - if (decision.reason !== 'unchanged') { - state.podcasts[stateId] = decision.nextState; - changeCount++; + } finally { + // 5. Write updated state file back (never in a dry run). + if (DRY_RUN) { + console.log(`[dry run] ${changeCount} state changes not saved.`); + } else { + try { + fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8'); + console.log(`State file updated successfully. ${changeCount} changes recorded.`); + } catch (writeError) { + console.error("Failed to write state file:", writeError); + process.exitCode = 1; } - console.log(`Checked ${podcastId}: ${decision.reason}`); - } catch (podcastError) { - // Never print private feed URLs or request errors for pure RSS registrations. - if (String(podcastId).startsWith('rss:')) console.warn(`RSS check failed for ${podcastId}; retaining baseline for retry`); - else console.error(`Error checking podcast ${podcastTitle} (${podcastId}):`, podcastError); } } - // 5. Write updated state file back - try { - fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf8'); - console.log(`State file updated successfully. ${changeCount} changes recorded.`); - } catch (writeError) { - console.error("Failed to write state file:", writeError); + // A failed push is retried next run (its state wasn't saved); the run shows as failed so it's noticed. + if (sendFailures) { + console.error(`${sendFailures} notification(s) failed to send; they will be retried.`); + process.exit(1); } - - process.exit(0); + process.exit(process.exitCode || 0); } run();