// A VIDEO'S RECORDED DATE, for a channel that mirrors another's streams // (release 20 D1). // // A VOD-mirror channel uploads yesterday's stream today, or last year's // streams in a batch, so its videos' `upload_date` is the date of the COPY, // not of the stream it copies. Anything that asks "what was said when" — // coverage above all ("held videos by date, gaps") — reads the wrong day for // every one of them. The date the stream happened is usually in the title // ("Stream 2024-03-05", "VOD | March 5, 2024"), in a shape that is the same // for the whole channel. So the channel names that shape once, in its // config.json (`recordedDate.titlePattern`), and the index derives each // record's `recordedDate` from its title (controller/buildIndex.ts). // // THE PATTERN is a case-insensitive regex SOURCE with three NAMED groups: // year four digits, or two (read as 20YY) // month 1–12, or an English month name, whole or cut to 3+ letters // day 1–31 // e.g. `(?\d{4})-(?\d{2})-(?\d{2})` or // `(?[a-z]+)\.? (?\d{1,2}),? (?\d{4})`. A pattern that does // not compile, or lacks a group, is dropped by the config coercion — the rule // is then absent, never half-applied. // // A DERIVED DATE MUST BE A REAL DAY, AND NO LATER THAN THE UPLOAD: a recording // precedes its copy, so a title date after `upload_date` is a date the title // mentions (a schedule, a sequel), not the day it was recorded, and none is // set. A title the pattern does not match has none either; readers fall back // to the upload date (`coverageDate`). // // Pure, client-safe (channelConfig.ts imports the coercion). import { downloadFilterPatternProblem } from "./downloadFilters"; export type RecordedDateRule = { titlePattern: string; }; export const RECORDED_DATE_RULE_FIELD_DOCS: Record = { titlePattern: "Case-insensitive regex SOURCE (no delimiters, no flags) matched against each video's title, with three named groups: `year` (four digits, or two read as 20YY), `month` (1–12, or an English month name, whole or cut to three or more letters) and `day`. Trimmed; at most 200 characters, no nested quantifier (the download filter's rules). A pattern that breaks any of this drops the whole rule.", }; const GROUPS = ["year", "month", "day"] as const; const MONTHS = [ "january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december", ]; // Why a pattern cannot be a rule, as the tail of a sentence ("… "), // or null. It must compile, name all three groups, and pass the download // filter's safety check (downloadFilterPatternProblem: at most 200 characters, // no nested quantifier) — it runs over every title of the channel on the // server's one thread, at every index build. export function recordedDatePatternProblem(pattern: string): string | null { try { new RegExp(pattern, "i"); } catch (e) { return `is not a valid regular expression: ${(e as Error).message}`; } const missing = GROUPS.filter((g) => !pattern.includes(`(?<${g}>`)); if (missing.length > 0) { return `must name the groups year, month and day (missing: ${missing.join(", ")})`; } return downloadFilterPatternProblem(pattern); } // The rule's regex, or null when the pattern has a problem (above). export function compileRecordedDateRule(rule: RecordedDateRule): RegExp | null { if (recordedDatePatternProblem(rule.titlePattern)) return null; return new RegExp(rule.titlePattern, "i"); } export function coerceRecordedDateRule(v: unknown): RecordedDateRule | undefined { if (!v || typeof v !== "object" || Array.isArray(v)) return undefined; const raw = (v as Record).titlePattern; if (typeof raw !== "string") return undefined; const rule = { titlePattern: raw.trim() }; if (!rule.titlePattern || !compileRecordedDateRule(rule)) return undefined; return rule; } function monthNumber(s: string): number | null { if (/^\d{1,2}$/.test(s)) { const n = Number(s); return n >= 1 && n <= 12 ? n : null; } const word = s.toLowerCase().replace(/\.$/, ""); if (word.length < 3) return null; const i = MONTHS.findIndex((m) => m.startsWith(word)); return i < 0 ? null : i + 1; } const pad = (n: number, w = 2) => String(n).padStart(w, "0"); // The recorded date (`YYYYMMDD`, the shape of `upload_date`) a rule reads off a // title, or undefined: no match, not a real day, or later than `uploadDate` // (when given). export function deriveRecordedDate( rule: RecordedDateRule | RegExp, title: string, uploadDate?: string, ): string | undefined { const re = rule instanceof RegExp ? rule : compileRecordedDateRule(rule); if (!re) return undefined; const g = re.exec(title)?.groups; if (!g || !g.year || !g.month || !g.day) return undefined; if (!/^\d{2}$|^\d{4}$/.test(g.year) || !/^\d{1,2}$/.test(g.day)) return undefined; const year = g.year.length === 2 ? 2000 + Number(g.year) : Number(g.year); const month = monthNumber(g.month); const day = Number(g.day); if (month === null || day < 1) return undefined; // A real day: the Date round trip refuses Feb 30 and friends. const d = new Date(Date.UTC(year, month - 1, day)); if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) { return undefined; } const out = `${pad(year, 4)}${pad(month)}${pad(day)}`; if (uploadDate && /^\d{8}$/.test(uploadDate) && out > uploadDate) return undefined; return out; } // The date a video's content belongs to: its recorded date when its channel's // rule gave one, else its upload date. What coverage reads. export function coverageDate(s: { uploadDate: string; recordedDate?: string }): string { return s.recordedDate ?? s.uploadDate; }