Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/new-episode-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand Down
8 changes: 8 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<id>` 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_<id>_<sha256-of-trimmed-feed-url>` and payloads retain `rss:<id>`. 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.
Expand Down
104 changes: 97 additions & 7 deletions scripts/check-new-episodes-lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

/** 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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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',
Expand All @@ -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`,
});
}
Expand All @@ -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' } });
86 changes: 86 additions & 0 deletions scripts/check-new-episodes-lib.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading