diff --git a/server.js b/server.js index c8e55c7..a80e3f5 100644 --- a/server.js +++ b/server.js @@ -35,6 +35,7 @@ const adminMember = require('./adminmember'); const syndicate = require('./syndicate'); const releases = require('./releases'); const ledger = require('./ledger'); +const videosweep = require('./videosweep'); 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 leaderboard = require('./leaderboard'); @@ -479,6 +480,16 @@ async function boot() { syndicate.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR, uploadsDir: UPLOADS_DIR }); releases.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 }); 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(() => {}); } }); @@ -3066,6 +3077,9 @@ const server = http.createServer(async (req, res) => { if (p === '/api/admin/campaigns' && req.method === 'POST') { // free house ad if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); 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 (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); @@ -3225,6 +3239,11 @@ const server = http.createServer(async (req, res) => { const r = ledger.remove(String(b.id || '')); 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 (!isAdmin(req)) return json(res, 401, { error: 'auth' }); return json(res, 200, burner.status()); diff --git a/videosweep.js b/videosweep.js new file mode 100644 index 0000000..444cdd4 --- /dev/null +++ b/videosweep.js @@ -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} Video sources that no longer load\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 };