Video sources: check the house path too, and re-check the live ones
Two holes left by videoCheck, which only ever ran when a campaign was saved. The house-ad route never called it at all. It guarded /api/my/campaigns and not /api/admin/campaigns, so a bad source pasted into a house video would hand every viewer a black player exactly as #146 did. And nothing re-checked anything after it went live. #146 pointed at https://yourdomain.com/ from 16 September; the check shipped on the 22nd and the campaign sat live for another two days, burning a daily video slot for everyone who drew it, until Marty hit it himself and asked why nothing played. A save-time check can never catch a campaign that predates it, or a link that rots later. videosweep re-checks every live video source daily. A source that fails is PAUSED, never deleted: the advertiser keeps every unspent credit, because the reservation is computed rather than deducted, and can restart it once the link is fixed. It reports what it paused to the admin channel rather than doing it quietly, since a guard nobody hears from is how the first one went unnoticed. POST /api/admin/videosweep runs it on demand, with { dry: true } to look first. QA green, 0 bugs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ const adminMember = require('./adminmember');
|
|||||||
const syndicate = require('./syndicate');
|
const syndicate = require('./syndicate');
|
||||||
const releases = require('./releases');
|
const releases = require('./releases');
|
||||||
const ledger = require('./ledger');
|
const ledger = require('./ledger');
|
||||||
|
const videosweep = require('./videosweep');
|
||||||
const updates = require('./updates');
|
const updates = require('./updates');
|
||||||
const audit = require('./audit'); // counter audit: views vs delivery logs, charges vs shows (Marty, 2026-09-15) // member update emails from Admin > Releases (Marty, 2026-09-14)
|
const audit = require('./audit'); // counter audit: views vs delivery logs, charges vs shows (Marty, 2026-09-15) // member update emails from Admin > Releases (Marty, 2026-09-14)
|
||||||
const leaderboard = require('./leaderboard');
|
const leaderboard = require('./leaderboard');
|
||||||
@@ -479,6 +480,16 @@ async function boot() {
|
|||||||
syndicate.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR, uploadsDir: UPLOADS_DIR });
|
syndicate.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR, uploadsDir: UPLOADS_DIR });
|
||||||
releases.init({ dataDir: DATA_DIR });
|
releases.init({ dataDir: DATA_DIR });
|
||||||
ledger.init({ dataDir: DATA_DIR });
|
ledger.init({ dataDir: DATA_DIR });
|
||||||
|
// re-check live video sources on a schedule: videoCheck only ever runs at save time, so a
|
||||||
|
// campaign created before it existed, or a link that rots later, is otherwise invisible
|
||||||
|
videosweep.init({ dataDir: DATA_DIR, fs, path, db, videoCheck,
|
||||||
|
alert: async text => {
|
||||||
|
const sc = siteConfig();
|
||||||
|
if (sc.telegramBotToken && sc.telegramAdminChatId) return telegramSend(sc.telegramAdminChatId, text);
|
||||||
|
if (sc.telegramBotToken && sc.telegramEchoChatId) return telegramSend(sc.telegramEchoChatId, text, sc.telegramEchoTopicId);
|
||||||
|
if (ADMIN_EMAIL && mailer.hasKey()) return mailer.send(ADMIN_EMAIL, 'InstantAdPay: broken video sources', text.replace(/<[^>]+>/g, ''));
|
||||||
|
} });
|
||||||
|
videosweep.start();
|
||||||
toolkit.init({ dataDir: DATA_DIR, ads, accounts, siteConfig, coach, messages, promos, videomaker, chain });
|
toolkit.init({ dataDir: DATA_DIR, ads, accounts, siteConfig, coach, messages, promos, videomaker, chain });
|
||||||
updates.init({ dataDir: DATA_DIR, accounts, releases, mailer, drip, sendy, adminEmail: ADMIN_EMAIL });
|
updates.init({ dataDir: DATA_DIR, accounts, releases, mailer, drip, sendy, adminEmail: ADMIN_EMAIL });
|
||||||
audit.init({ dataDir: DATA_DIR, notify: text => { const sc = siteConfig(); if (sc.telegramBotToken && sc.telegramAdminChatId) telegramSend(sc.telegramAdminChatId, text).catch(() => {}); else if (ADMIN_EMAIL && mailer.hasKey()) mailer.send(ADMIN_EMAIL, 'InstantAdPay: counter audit', text).catch(() => {}); } });
|
audit.init({ dataDir: DATA_DIR, notify: text => { const sc = siteConfig(); if (sc.telegramBotToken && sc.telegramAdminChatId) telegramSend(sc.telegramAdminChatId, text).catch(() => {}); else if (ADMIN_EMAIL && mailer.hasKey()) mailer.send(ADMIN_EMAIL, 'InstantAdPay: counter audit', text).catch(() => {}); } });
|
||||||
@@ -3066,6 +3077,9 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (p === '/api/admin/campaigns' && req.method === 'POST') { // free house ad
|
if (p === '/api/admin/campaigns' && req.method === 'POST') { // free house ad
|
||||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
|
// A house video was never checked: videoCheck guarded the member path only, so a bad
|
||||||
|
// source pasted here would hand every viewer a black player exactly as #146 did.
|
||||||
|
if (String(b.type) === 'video') { const vc = await videoCheck(b.videoUrl); if (!vc.ok) return json(res, 400, { error: vc.reason }); }
|
||||||
if (!['login', 'solo', 'video', 'featured'].includes(String(b.type || ''))) {
|
if (!['login', 'solo', 'video', 'featured'].includes(String(b.type || ''))) {
|
||||||
if (b.type === 'banner' || (b.type === 'login' && b.imageUrl)) { const ic = await imageCheck(b.imageUrl); if (!ic.ok) return json(res, 400, { error: ic.reason }); }
|
if (b.type === 'banner' || (b.type === 'login' && b.imageUrl)) { const ic = await imageCheck(b.imageUrl); if (!ic.ok) return json(res, 400, { error: ic.reason }); }
|
||||||
const fc = await frameCheck(b.targetUrl);
|
const fc = await frameCheck(b.targetUrl);
|
||||||
@@ -3225,6 +3239,11 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const r = ledger.remove(String(b.id || ''));
|
const r = ledger.remove(String(b.id || ''));
|
||||||
return json(res, r.error ? 400 : 200, r);
|
return json(res, r.error ? 400 : 200, r);
|
||||||
}
|
}
|
||||||
|
if (p === '/api/admin/videosweep' && req.method === 'POST') {
|
||||||
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
|
const b = await readBody(req);
|
||||||
|
return json(res, 200, await videosweep.run({ dry: !!b.dry }));
|
||||||
|
}
|
||||||
if (p === '/api/admin/burner' && req.method === 'GET') {
|
if (p === '/api/admin/burner' && req.method === 'GET') {
|
||||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
return json(res, 200, burner.status());
|
return json(res, 200, burner.status());
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Re-check the video sources that are actually live (Marty, 2026-09-24).
|
||||||
|
//
|
||||||
|
// videoCheck runs when a campaign is SAVED, which leaves two holes it can never cover:
|
||||||
|
//
|
||||||
|
// 1. anything created before the check existed. Campaign #146 pointed at a placeholder,
|
||||||
|
// https://yourdomain.com/..., from 16 September. The check shipped on the 22nd. It sat
|
||||||
|
// live for another two days handing every viewer a black player and burning one of their
|
||||||
|
// daily video slots, until Marty hit it himself and asked why nothing played.
|
||||||
|
// 2. link rot. A source that was fine on the day can 404 a week later, and nothing notices.
|
||||||
|
//
|
||||||
|
// So the live ones get re-checked on a schedule. A source that fails is PAUSED, never deleted:
|
||||||
|
// the advertiser keeps every unspent credit (reservation is computed, not deducted) and can fix
|
||||||
|
// the link and start it again. Failing is reported, not silent, because a check that quietly
|
||||||
|
// does nothing is how the first one went unnoticed.
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let X = null;
|
||||||
|
const STATE = () => X.path.join(X.dataDir, 'videosweep.json');
|
||||||
|
function load() { try { return JSON.parse(X.fs.readFileSync(STATE(), 'utf8')); } catch (e) { return { checked: {}, at: 0 }; } }
|
||||||
|
function save(d) { try { X.fs.writeFileSync(STATE(), JSON.stringify(d)); } catch (e) {} }
|
||||||
|
|
||||||
|
function init(deps) { X = deps; }
|
||||||
|
|
||||||
|
// One pass. Returns what it found so the admin route and the scheduler can both report it.
|
||||||
|
async function run(opts) {
|
||||||
|
const dry = !!(opts && opts.dry);
|
||||||
|
if (!X || !X.db || !X.db.enabled()) return { error: 'no database' };
|
||||||
|
const rows = await X.db.q(
|
||||||
|
"SELECT id, owner_email, name, image_url, status FROM campaigns WHERE type='video' AND status IN ('active','paused')");
|
||||||
|
const out = { checked: 0, ok: 0, broken: [], paused: [], skipped: 0 };
|
||||||
|
for (const c of rows) {
|
||||||
|
const url = String(c.image_url || '').trim();
|
||||||
|
if (!url) { out.skipped++; continue; }
|
||||||
|
if (/^\/uploads\//.test(url)) { out.skipped++; continue; } // on our own disk
|
||||||
|
out.checked++;
|
||||||
|
let v;
|
||||||
|
try { v = await X.videoCheck(url); } catch (e) { v = { ok: false, reason: 'check threw: ' + e.message }; }
|
||||||
|
if (v.ok) { out.ok++; continue; }
|
||||||
|
out.broken.push({ id: c.id, name: c.name, owner: c.owner_email, url, reason: v.reason, status: c.status });
|
||||||
|
// only an ACTIVE one needs stopping; a paused one is already out of rotation
|
||||||
|
if (c.status === 'active' && !dry) {
|
||||||
|
try {
|
||||||
|
await X.db.q("UPDATE campaigns SET status='paused' WHERE id=?", [c.id]);
|
||||||
|
out.paused.push(c.id);
|
||||||
|
} catch (e) { /* reported below either way */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!dry) {
|
||||||
|
const st = load();
|
||||||
|
st.at = Date.now();
|
||||||
|
st.lastBroken = out.broken.map(b => b.id);
|
||||||
|
save(st);
|
||||||
|
if (out.paused.length) {
|
||||||
|
const lines = out.broken.filter(b => out.paused.includes(b.id))
|
||||||
|
.map(b => '#' + b.id + ' "' + b.name + '" (' + b.owner + ')\n ' + b.url);
|
||||||
|
console.log('videosweep paused ' + out.paused.length + ' broken video campaign(s): ' + out.paused.join(', '));
|
||||||
|
if (X.alert) {
|
||||||
|
X.alert('\u{1F6D1} <b>Video sources that no longer load</b>\n\n'
|
||||||
|
+ lines.join('\n') + '\n\nPaused so members stop losing a daily video slot on a black player. '
|
||||||
|
+ 'Every unspent credit stays with the advertiser and the campaign can be restarted once the link is fixed.').catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily is plenty: a source that dies is a slow problem, and HEADing every live video more often
|
||||||
|
// than that is noise for the advertisers' servers.
|
||||||
|
function start() {
|
||||||
|
const tick = () => { run({}).catch(e => console.error('videosweep', e.message)); };
|
||||||
|
setTimeout(tick, 90000); // once, shortly after boot
|
||||||
|
setInterval(tick, 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { init, run, start };
|
||||||
Reference in New Issue
Block a user