Repository navigation
Expand file tree
/
Copy pathstaleness.js
More file actions
185 lines (168 loc) · 5.78 KB
/
Copy pathstaleness.js
File metadata and controls
185 lines (168 loc) · 5.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
'use strict';
/**
* Episode-check staleness helpers (stage 3).
*
* Tiers:
* news — News category, any country → NEWS_STALE_MS (8h)
* core — on ≥1 core check country, non-News → REGULAR_STALE_MS (24h)
* relaxed — only on relaxed check countries, non-News → RELAXED_STALE_MS (48h)
*
* State flags on show rec: n=1 (news), x=1 (relaxed-only). Core wins over relaxed.
*
* Also decides when stage 3 re-reads its cached candidate list (candidateRefreshReason).
*/
const cfg = require('./config');
const CORE_SET = new Set(cfg.CORE_CHECK_COUNTRIES.map((c) => String(c).toLowerCase()));
const CANDIDATE_CACHE_MAX_AGE_MS = 20 * 60 * 60 * 1000;
function hoursLabel(ms) {
const h = ms / 3_600_000;
return Number.isInteger(h) ? `${h}h` : `${h.toFixed(1)}h`;
}
function policySummary() {
const j = Math.round(cfg.STALENESS_JITTER * 100);
return {
news: hoursLabel(cfg.NEWS_STALE_MS),
core: hoursLabel(cfg.REGULAR_STALE_MS),
relaxed: hoursLabel(cfg.RELAXED_STALE_MS),
jitterPct: j,
label:
`News ${hoursLabel(cfg.NEWS_STALE_MS)} · core countries ${hoursLabel(cfg.REGULAR_STALE_MS)} · ` +
`relaxed countries ${hoursLabel(cfg.RELAXED_STALE_MS)} (±${j}% jitter)`,
coreCountries: [...cfg.CORE_CHECK_COUNTRIES],
relaxedCountries: [...cfg.RELAXED_CHECK_COUNTRIES],
};
}
/**
* @param {string|string[]} countryCsvOrList
* @returns {boolean} true if show is only on relaxed countries (no core)
*/
function isRelaxedOnly(countryCsvOrList) {
const list = Array.isArray(countryCsvOrList)
? countryCsvOrList
: String(countryCsvOrList || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
if (!list.length) return false; // unknown → treat as core (safer / more frequent)
return !list.some((c) => CORE_SET.has(c));
}
/**
* Apply country-driven flags onto a state show record.
* @param {object} rec
* @param {string|string[]} countryCsvOrList
*/
function applyCountryCheckFlag(rec, countryCsvOrList) {
if (isRelaxedOnly(countryCsvOrList)) rec.x = 1;
else delete rec.x;
return rec;
}
/**
* The core-country list the x flags were computed with, stored as the state's `relaxedTagsCore`
* by each candidate refresh.
* @param {string[]} [coreCountries]
*/
function relaxedTagsKey(coreCountries = cfg.CORE_CHECK_COUNTRIES) {
return [...new Set(coreCountries.map((c) => String(c).toLowerCase()))].sort().join(',');
}
/**
* Why stage 3 must re-read its candidates from Turso (a charts pre-aggregate plus a paged podcasts
* read), or null when the cached list will do.
* @param {object} st sync state
* @param {{ now?: number, cadence?: boolean, tagsKey?: string }} [opts] cadence: the check cadence
* is on, so candidates need iTunes IDs
* @returns {string|null}
*/
function candidateRefreshReason(st, { now = Date.now(), cadence = false, tagsKey = relaxedTagsKey() } = {}) {
if (!st.candidateIds) return 'no cached list';
const refreshedAt = st.candidatesRefreshedAt || 0;
if ((st.chartsRefreshedAt || 0) > refreshedAt) return 'charts refreshed since';
if (now - refreshedAt > CANDIDATE_CACHE_MAX_AGE_MS) return 'cache expired';
// Core shows carry no x, so a missing x can't tell "core" from "never tagged"; the key can.
if (st.relaxedTagsCore !== tagsKey) return 'apply relaxed(48h) tags';
if (cadence) {
// The cadence reads Apple's dates by iTunes ID, which only a refresh records.
const missing = st.candidateIds.filter((id) => !st.shows?.[id]?.i).length;
if (missing > Math.max(50, st.candidateIds.length * 0.05)) {
return `record iTunes IDs (${missing.toLocaleString()} shows without one)`;
}
}
return null;
}
/** @returns {'news'|'core'|'relaxed'} */
function checkTier(rec) {
if (rec?.n === 1) return 'news';
if (rec?.x === 1) return 'relaxed';
return 'core';
}
function jitterFactor(id) {
let h = 0;
const s = String(id);
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return 1 + ((h % 1000) / 1000 - 0.5) * 2 * cfg.STALENESS_JITTER;
}
function thresholdMs(rec, id) {
const tier = checkTier(rec);
const base =
tier === 'news' ? cfg.NEWS_STALE_MS
: tier === 'relaxed' ? cfg.RELAXED_STALE_MS
: cfg.REGULAR_STALE_MS;
return base * jitterFactor(id);
}
/**
* @param {string[]} candidateIds
* @param {object} shows state.shows
* @param {number} now
*/
function planDue(candidateIds, shows, now = Date.now()) {
let neverChecked = 0;
let staleNews = 0;
let staleCore = 0;
let staleRelaxed = 0;
let poolNews = 0;
let poolCore = 0;
let poolRelaxed = 0;
const allDue = [];
for (const id of candidateIds || []) {
const rec = shows[id] || {};
const tier = checkTier(rec);
if (tier === 'news') poolNews++;
else if (tier === 'relaxed') poolRelaxed++;
else poolCore++;
if (!rec.c) {
neverChecked++;
allDue.push(id);
continue;
}
if (now - rec.c >= thresholdMs(rec, id)) {
if (tier === 'news') staleNews++;
else if (tier === 'relaxed') staleRelaxed++;
else staleCore++;
allDue.push(id);
}
}
allDue.sort((a, b) => (shows[a]?.c || 0) - (shows[b]?.c || 0));
return {
allDue,
neverChecked,
staleNews,
staleCore,
staleRelaxed,
poolNews,
poolCore,
poolRelaxed,
};
}
module.exports = {
hoursLabel,
policySummary,
isRelaxedOnly,
applyCountryCheckFlag,
relaxedTagsKey,
candidateRefreshReason,
checkTier,
jitterFactor,
thresholdMs,
planDue,
CORE_SET,
CANDIDATE_CACHE_MAX_AGE_MS,
};