feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
CI / build-and-test (push) Successful in 14m43s
CI / build-and-test (push) Successful in 14m43s
7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
This commit is contained in:
+632
-16
@@ -21,9 +21,45 @@ if (!fs.existsSync(dbDir)) {
|
||||
}
|
||||
|
||||
// Create DB and enable FKs
|
||||
|
||||
/**
|
||||
* Garde-fou d'isolation (Phase 5).
|
||||
*
|
||||
* Tous les tests de `server/tests/` sont nommés `*.test.mjs` et DOIVENT
|
||||
* travailler sur une base temporaire via `NEWTUBE_DB_FILE`. Sans ce garde-fou,
|
||||
* une faute de frappe sur le nom de la variable (`NEW_TUBE_DB_PATH` au lieu de
|
||||
* `NEWTUBE_DB_FILE`) fait ouvrir la base de developpement en lecture-ecriture :
|
||||
* les fixtures du test atterrissent alors chez l'utilisateur, silencieusement.
|
||||
* C'est deja arrive (lignes de cache factices dans `db/newtube.db`).
|
||||
*
|
||||
* On refuse donc explicitement, avec un message qui donne la marche a suivre,
|
||||
* plutot que de laisser la corruption se produire.
|
||||
*/
|
||||
if (!overrideDbFile && process.argv.some((a) => /\.test\.mjs$/.test(a))) {
|
||||
throw new Error(
|
||||
`[db] Refus d'ouvrir la base de developpement (${dbFile}) depuis un test. `
|
||||
+ 'Definissez process.env.NEWTUBE_DB_FILE sur un chemin temporaire AVANT '
|
||||
+ "l'import de db.mjs (le nom exact est NEWTUBE_DB_FILE, sans \"NEW_TUBE_\").",
|
||||
);
|
||||
}
|
||||
|
||||
const db = new Database(dbFile);
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
/**
|
||||
* Chemin reel du fichier ouvert.
|
||||
*
|
||||
* Expose pour que les tests puissent AFFIRMER leur isolation au lieu de la
|
||||
* supposer : un test qui se trompe de nom de variable d'environnement
|
||||
* (`NEW_TUBE_DB_PATH` au lieu de `NEWTUBE_DB_FILE`) ouvre silencieusement la
|
||||
* base de dev et y ecrit ses fixtures. Cela a deja pollue `db/newtube.db` avec
|
||||
* des lignes de cache factices. Un `ok(getDbFile().includes('test'))` en tete de
|
||||
* fichier echoue bruyamment au lieu de corrompre la base de l'utilisateur.
|
||||
*/
|
||||
export function getDbFile() {
|
||||
return dbFile;
|
||||
}
|
||||
|
||||
// Run schema if present (first boot)
|
||||
if (schemaFile && fs.existsSync(schemaFile)) {
|
||||
try {
|
||||
@@ -76,6 +112,16 @@ if (schemaFile && fs.existsSync(schemaFile)) {
|
||||
// Idempotent migrations (IF NOT EXISTS / duplicate columns) can fail on
|
||||
// databases already up to date — tolerate and continue.
|
||||
console.warn(`[db] Migration ${file} reported an error:`, e?.message || e);
|
||||
} finally {
|
||||
// Une migration `BEGIN … COMMIT` qui échoue AU MILIEU (ex. un ALTER sur une
|
||||
// colonne déjà présente) laisse la transaction ouverte dans better-sqlite3.
|
||||
// Toutes les migrations SUIVANTES s'y exécutent alors « avec succès » puis
|
||||
// sont annulées à la fermeture du process : leurs tables disparaissent
|
||||
// sans trace alors que le journal affiche « applied ». On referme donc la
|
||||
// transaction résiduelle avant de poursuivre.
|
||||
if (db.inTransaction) {
|
||||
try { db.exec('ROLLBACK'); } catch {}
|
||||
}
|
||||
}
|
||||
// Record as applied even on tolerated errors so we don't re-run/re-warn each boot.
|
||||
try {
|
||||
@@ -102,6 +148,17 @@ if (schemaFile && fs.existsSync(schemaFile)) {
|
||||
const have2 = new Set(colsItems.map(c => c.name));
|
||||
if (!have2.has('thumbnail')) db.exec(`ALTER TABLE playlist_items ADD COLUMN thumbnail TEXT`);
|
||||
} catch {}
|
||||
// Phase 7.7 : bannière + description de chaîne. Ajoutés en JS plutôt que dans
|
||||
// un fichier de migration car `db/schema.sql` est réappliqué à CHAQUE boot :
|
||||
// un `ALTER TABLE … ADD COLUMN` en migration échouerait (colonne déjà
|
||||
// présente) sur une base neuve, et ce fichier échouant laissait la
|
||||
// transaction ouverte, annulant silencieusement les migrations suivantes.
|
||||
try {
|
||||
const colsChannels = db.prepare(`PRAGMA table_info(channels)`).all();
|
||||
const haveCh = new Set(colsChannels.map(c => c.name));
|
||||
if (!haveCh.has('banner_url')) db.exec(`ALTER TABLE channels ADD COLUMN banner_url TEXT`);
|
||||
if (!haveCh.has('description')) db.exec(`ALTER TABLE channels ADD COLUMN description TEXT`);
|
||||
} catch {}
|
||||
try {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS playlist_metrics (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -422,6 +479,154 @@ export function normalizeHistoryProvider(raw) {
|
||||
export function escapeLikePattern(raw) {
|
||||
return String(raw ?? '').replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
||||
}
|
||||
|
||||
// -------------------- Phase 5 : catalogue `videos` --------------------
|
||||
/**
|
||||
* `videos` est la **seule** table qui porte les metadonnees d'une video.
|
||||
* `watch_history` et `playlist_items` les denormalisent (phase 5.4) : cette
|
||||
* duplication est le bug qui fait qu'un like sans visionnage anterieur
|
||||
* s'affiche sans titre.
|
||||
*
|
||||
* Provider : la cle est toujours le **nom long** (`normalizeHistoryProvider`).
|
||||
* `video_tags.provider` est en revanche stocke tel quel par `likeVideo` — donc
|
||||
* court ou long selon ce qu'envoie le front. C'est exactement pour cela que
|
||||
* `listLikedVideos` normalise les deux cotes de la jointure.
|
||||
*/
|
||||
function ensureVideosTable() {
|
||||
try {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS videos (
|
||||
provider TEXT NOT NULL, video_id TEXT NOT NULL,
|
||||
title TEXT, thumbnail TEXT, duration_seconds INTEGER, views INTEGER,
|
||||
published_at TEXT, url TEXT, kind TEXT,
|
||||
channel_external_id TEXT, channel_name TEXT, channel_avatar_url TEXT,
|
||||
width INTEGER, height INTEGER,
|
||||
raw_json TEXT, captured_at TEXT NOT NULL, created_at TEXT,
|
||||
PRIMARY KEY (provider, video_id)
|
||||
);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_videos_captured ON videos(captured_at DESC);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_videos_channel ON videos(provider, channel_external_id);`);
|
||||
} catch {}
|
||||
}
|
||||
ensureVideosTable();
|
||||
|
||||
/** Expression SQL normalisant une colonne provider vers le nom long. */
|
||||
function providerLongExpr(col) {
|
||||
const whens = Object.entries(HISTORY_SHORT_TO_LONG).map(([s, l]) => `WHEN '${s}' THEN '${l}'`).join(' ');
|
||||
return `(CASE LOWER(${col}) ${whens} ELSE LOWER(${col}) END)`;
|
||||
}
|
||||
|
||||
const positiveInt = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.round(v) : null);
|
||||
const nonEmptyStr = (v) => (typeof v === 'string' && v.trim().length > 0 ? v.trim() : null);
|
||||
|
||||
/**
|
||||
* Ecrit (ou met a jour) une ligne `videos`.
|
||||
*
|
||||
* **Best-effort par contrat** : une erreur ici ne doit jamais faire echouer
|
||||
* l'ecriture fonctionnelle appelante (historique, like, playlist). D'ou le
|
||||
* try/catch qui englobe tout et retourne `false`.
|
||||
*
|
||||
* Deux invariants :
|
||||
* - `captured_at` est rafraichi a chaque re-observation (c'est l'indicateur de
|
||||
* fraicheur de la phase 5.1) ;
|
||||
* - les colonnes absentes ne sont **pas** ecrasees (`COALESCE`) : un appel
|
||||
* minimal (un like ne fournit que titre + vignette) ne doit pas effacer les
|
||||
* metadonnees vues lors d'une session de lecture complete.
|
||||
*
|
||||
* @returns {boolean} true si la ligne a ete ecrite
|
||||
*/
|
||||
export function upsertVideoRow(dto) {
|
||||
try {
|
||||
const provider = normalizeHistoryProvider(dto?.provider)
|
||||
|| String(dto?.provider || '').trim().toLowerCase();
|
||||
const videoId = String(dto?.videoId ?? dto?.video_id ?? '').trim();
|
||||
if (!provider || !videoId) return false;
|
||||
ensureVideosTable();
|
||||
const now = nowIso();
|
||||
// `raw_json` est un garde-fou de debug : tronque pour ne pas faire exploser
|
||||
// la base avec le payload d'une recherche.
|
||||
let rawJson = null;
|
||||
if (dto?.raw && typeof dto.raw === 'object') {
|
||||
try { rawJson = JSON.stringify(dto.raw).slice(0, 4096); } catch { rawJson = null; }
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT INTO videos (provider, video_id, title, thumbnail, duration_seconds, views,
|
||||
published_at, url, kind, channel_external_id, channel_name,
|
||||
channel_avatar_url, width, height, raw_json, captured_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider, video_id) DO UPDATE SET
|
||||
title = COALESCE(excluded.title, videos.title),
|
||||
thumbnail = COALESCE(excluded.thumbnail, videos.thumbnail),
|
||||
duration_seconds = COALESCE(excluded.duration_seconds, videos.duration_seconds),
|
||||
views = COALESCE(excluded.views, videos.views),
|
||||
published_at = COALESCE(excluded.published_at, videos.published_at),
|
||||
url = COALESCE(excluded.url, videos.url),
|
||||
kind = COALESCE(excluded.kind, videos.kind),
|
||||
channel_external_id = COALESCE(excluded.channel_external_id, videos.channel_external_id),
|
||||
channel_name = COALESCE(excluded.channel_name, videos.channel_name),
|
||||
channel_avatar_url = COALESCE(excluded.channel_avatar_url, videos.channel_avatar_url),
|
||||
width = COALESCE(excluded.width, videos.width),
|
||||
height = COALESCE(excluded.height, videos.height),
|
||||
raw_json = COALESCE(excluded.raw_json, videos.raw_json),
|
||||
captured_at = excluded.captured_at`,
|
||||
).run(
|
||||
provider, videoId,
|
||||
nonEmptyStr(dto?.title), nonEmptyStr(dto?.thumbnail),
|
||||
positiveInt(dto?.durationSeconds ?? dto?.duration), positiveInt(dto?.views),
|
||||
nonEmptyStr(dto?.publishedAt), nonEmptyStr(dto?.url), nonEmptyStr(dto?.kind),
|
||||
nonEmptyStr(dto?.channelExternalId ?? dto?.channelId), nonEmptyStr(dto?.channelName),
|
||||
nonEmptyStr(dto?.channelAvatarUrl),
|
||||
positiveInt(dto?.width), positiveInt(dto?.height),
|
||||
rawJson, now, now,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false; // best-effort : voir le contrat ci-dessus
|
||||
}
|
||||
}
|
||||
|
||||
/** Ligne `videos` pour un couple (provider, videoId), provider court ou long. */
|
||||
export function getVideoRow(provider, videoId) {
|
||||
try {
|
||||
const p = normalizeHistoryProvider(provider) || String(provider || '').trim().toLowerCase();
|
||||
ensureVideosTable();
|
||||
return db.prepare(`SELECT * FROM videos WHERE provider = ? AND video_id = ?`).get(p, String(videoId || '')) || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function countVideos() {
|
||||
try { ensureVideosTable(); return db.prepare(`SELECT COUNT(1) AS n FROM videos`).get()?.n || 0; } catch { return 0; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 5.3 — backfill depuis les tables denormalisees, **rejouable** (la
|
||||
* migration SQL le fait une fois au deploiement ; ceci permet de rattraper des
|
||||
* lignes ecrites entre le deploiement et l'arrivee du nouveau code).
|
||||
* `INSERT OR IGNORE` : n'ecrase jamais une ligne deja observee, qui est plus
|
||||
* riche que la source de backfill.
|
||||
*/
|
||||
export function backfillVideosFromLegacy() {
|
||||
const report = { watchHistory: 0, playlistItems: 0 };
|
||||
try {
|
||||
ensureVideosTable();
|
||||
// `provider` est selectionne puis NORMALISE : `playlist_items` et
|
||||
// `watch_history` conservent la forme recue (courte ou longue) selon l'appelant,
|
||||
// alors que `videos` est cle par le nom long. Sans cette normalisation, un
|
||||
// couple ('yt', 'id') et ('youtube', 'id') creeraient deux lignes et les
|
||||
// lectures normalisees enVerifieraient toujours une des deux.
|
||||
report.watchHistory = db.prepare(
|
||||
`INSERT OR IGNORE INTO videos (provider, video_id, title, thumbnail, captured_at, created_at)
|
||||
SELECT ${providerLongExpr('provider')}, video_id, title, thumbnail, last_watched_at, last_watched_at
|
||||
FROM watch_history WHERE video_id IS NOT NULL AND title IS NOT NULL`,
|
||||
).run().changes || 0;
|
||||
report.playlistItems = db.prepare(
|
||||
`INSERT OR IGNORE INTO videos (provider, video_id, title, thumbnail, captured_at, created_at)
|
||||
SELECT ${providerLongExpr('provider')}, video_id, title, thumbnail, added_at, added_at
|
||||
FROM playlist_items WHERE video_id IS NOT NULL AND title IS NOT NULL`,
|
||||
).run().changes || 0;
|
||||
} catch {}
|
||||
return report;
|
||||
}
|
||||
|
||||
export function insertSearchHistory({ userId, query, filters }) {
|
||||
const id = cryptoRandomId();
|
||||
const created_at = nowIso();
|
||||
@@ -492,6 +697,9 @@ export function upsertWatchHistory({ userId, provider, videoId, title, thumbnail
|
||||
last_watched_at=excluded.last_watched_at`).run(
|
||||
cryptoRandomId(), userId, normProvider, videoId, title || null, thumbnail || null, watched_at, progressSeconds, durationSeconds, (typeof lastPositionSeconds === 'number' ? lastPositionSeconds : null), now
|
||||
);
|
||||
// Phase 5.2 : alimente la table `videos` (best-effort, ne doit jamais faire
|
||||
// echouer l'ecriture de l'historique).
|
||||
upsertVideoRow({ provider: normProvider, videoId, title, thumbnail, durationSeconds });
|
||||
// Return the row id
|
||||
const row = db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND provider = ? AND video_id = ?`).get(userId, normProvider, videoId);
|
||||
return row;
|
||||
@@ -701,6 +909,11 @@ export function likeVideo({ userId, provider, videoId, title, thumbnail }) {
|
||||
db.prepare(`INSERT OR IGNORE INTO video_tags (user_id, provider, video_id, tag_id, created_at) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(userId, provider, videoId, tagId, nowIso());
|
||||
|
||||
// Phase 5.2 : on alimente `videos` meme quand aucun titre/vignette n'est fourni.
|
||||
// C'est le cas qui produisait le trou des likes : la ligne `video_tags`
|
||||
// existait mais rien d'autre ne portait les metadonnees.
|
||||
upsertVideoRow({ provider, videoId, title, thumbnail });
|
||||
|
||||
// Also update the watch_history table with the title and thumbnail
|
||||
if (title || thumbnail) {
|
||||
upsertWatchHistory({ userId, provider, videoId, title, thumbnail });
|
||||
@@ -764,22 +977,47 @@ export function listLikedVideos({ userId, limit = 100, q }) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Récupérer les vidéos aimées avec les métadonnées de l'historique
|
||||
// Note: La colonne thumbnail n'existe pas dans la table watch_history
|
||||
// Phase 5.3 : les metadonnees viennent de `videos` (catalogue partage), avec
|
||||
// repli `watch_history` puis `playlist_items` pour les lignes anterieures a
|
||||
// la migration.
|
||||
//
|
||||
// Les deux cotes sont normalises : `video_tags.provider` est stocke tel quel
|
||||
// par `likeVideo` (court ou long selon le front) alors que `videos` est
|
||||
// cle par le nom long. Sans cette normalisation, un like emis avec `yt`
|
||||
// ne trouvait aucune ligne `youtube` — le bug d'origine.
|
||||
const hasQ = typeof q === 'string' && q.trim().length > 0;
|
||||
const like = `%${(q || '').trim()}%`;
|
||||
const vtProvider = providerLongExpr('vt.provider');
|
||||
const vProvider = providerLongExpr('v.provider');
|
||||
const whProvider = providerLongExpr('wh.provider');
|
||||
// Repli playlist en sous-requete correlee et NON en jointure : `playlist_items`
|
||||
// n'est unique que par (playlist_id, provider, video_id), donc une video
|
||||
// presente dans 3 playlists y apparaitrait 3 fois dans le resultat.
|
||||
const pliTitle = `(SELECT pi.title FROM playlist_items pi
|
||||
WHERE ${providerLongExpr('pi.provider')} = ${vtProvider}
|
||||
AND pi.video_id = vt.video_id AND pi.title IS NOT NULL
|
||||
ORDER BY pi.added_at ASC LIMIT 1)`;
|
||||
const pliThumb = `(SELECT pi.thumbnail FROM playlist_items pi
|
||||
WHERE ${providerLongExpr('pi.provider')} = ${vtProvider}
|
||||
AND pi.video_id = vt.video_id AND pi.thumbnail IS NOT NULL
|
||||
ORDER BY pi.added_at ASC LIMIT 1)`;
|
||||
const titleExpr = `COALESCE(v.title, wh.title, ${pliTitle}, '')`;
|
||||
const thumbExpr = `COALESCE(v.thumbnail, wh.thumbnail, ${pliThumb}, '')`;
|
||||
const base = `
|
||||
SELECT
|
||||
vt.provider,
|
||||
SELECT
|
||||
vt.provider,
|
||||
vt.video_id,
|
||||
vt.created_at,
|
||||
COALESCE(wh.title, '') AS title,
|
||||
COALESCE(wh.thumbnail, '') AS thumbnail,
|
||||
wh.last_watched_at AS last_watched_at
|
||||
${titleExpr} AS title,
|
||||
${thumbExpr} AS thumbnail,
|
||||
wh.last_watched_at AS last_watched_at,
|
||||
v.captured_at AS captured_at
|
||||
FROM video_tags vt
|
||||
LEFT JOIN videos v
|
||||
ON v.provider = ${vProvider} AND v.video_id = vt.video_id
|
||||
LEFT JOIN watch_history wh
|
||||
ON wh.user_id = vt.user_id
|
||||
AND wh.provider = vt.provider
|
||||
ON wh.user_id = vt.user_id
|
||||
AND ${whProvider} = ${vtProvider}
|
||||
AND wh.video_id = vt.video_id
|
||||
WHERE vt.user_id = ? AND vt.tag_id = ?
|
||||
`;
|
||||
@@ -788,12 +1026,14 @@ export function listLikedVideos({ userId, limit = 100, q }) {
|
||||
LIMIT ?
|
||||
`;
|
||||
const query = hasQ
|
||||
? `${base} AND (COALESCE(wh.title,'') LIKE ? OR vt.provider LIKE ? OR vt.video_id LIKE ?)
|
||||
? `${base} AND (${titleExpr} LIKE ? OR vt.provider LIKE ? OR vt.video_id LIKE ?)
|
||||
${orderLimit}`
|
||||
: `${base} ${orderLimit}`;
|
||||
|
||||
console.log('[listLikedVideos] Exécution de la requête:', query.replace(/\s+/g, ' ').trim());
|
||||
|
||||
|
||||
// La requete est devenue longue (normalisation provider x 3, sous-requetes
|
||||
// de repli) : la logger en entier poluait la sortie a chaque appel.
|
||||
console.log(`[listLikedVideos] ${hasQ ? 'recherche' : 'liste'} pour ${userId} (tag ${tag.id})`);
|
||||
|
||||
const rows = hasQ
|
||||
? db.prepare(query).all(userId, tag.id, like, like, like, limit)
|
||||
: db.prepare(query).all(userId, tag.id, limit);
|
||||
@@ -933,6 +1173,8 @@ export function addPlaylistVideo({ userId, playlistId, provider, videoId, title,
|
||||
const id = cryptoRandomId();
|
||||
db.prepare(`INSERT OR IGNORE INTO playlist_items (id, playlist_id, provider, video_id, title, thumbnail, added_at, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, playlistId, provider, videoId, title || null, thumbnail || null, now, position);
|
||||
// Phase 5.2 : un ajout a une playlist est une observation de la video.
|
||||
upsertVideoRow({ provider, videoId, title, thumbnail });
|
||||
recordPlaylistMetric({ userId, playlistId, action: 'add_video', meta: { provider, videoId } });
|
||||
// Return the row (if it existed we need to fetch by key)
|
||||
const row = db.prepare(`SELECT id, playlist_id AS playlistId, provider, video_id AS videoId, title, thumbnail, added_at AS addedAt, position
|
||||
@@ -1083,6 +1325,8 @@ export function channelRowToMeta(row) {
|
||||
title: row.title || null,
|
||||
handle: row.handle || null,
|
||||
avatarUrl: row.avatar_url || null,
|
||||
bannerUrl: row.banner_url || null,
|
||||
description: row.description || null,
|
||||
url: row.url || null,
|
||||
subsCount: typeof row.subs_count === 'number' ? row.subs_count : undefined,
|
||||
verified: row.verified == null ? undefined : Boolean(row.verified),
|
||||
@@ -1090,6 +1334,13 @@ export function channelRowToMeta(row) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Double défense : l'assainissement des URLs vit dans channel-registry, mais une
|
||||
* bannière stockée en base ne doit jamais pouvoir survivre à un futur appelant
|
||||
* qui oublierait `safeMeta`. Règle locale : http(s) ou rien. */
|
||||
function sanitizeBannerUrl(value) {
|
||||
return typeof value === 'string' && /^https?:\/\//i.test(value.trim()) ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function upsertChannelRow(meta) {
|
||||
if (!meta || !meta.provider || !meta.externalId) {
|
||||
throw new Error('invalid_channel_meta');
|
||||
@@ -1101,17 +1352,21 @@ export function upsertChannelRow(meta) {
|
||||
title: meta.title || null,
|
||||
handle: meta.handle || null,
|
||||
avatar_url: meta.avatarUrl || null,
|
||||
banner_url: sanitizeBannerUrl(meta.bannerUrl),
|
||||
description: typeof meta.description === 'string' && meta.description.trim() ? meta.description.trim() : null,
|
||||
url: meta.url || null,
|
||||
subs_count: typeof meta.subsCount === 'number' ? meta.subsCount : null,
|
||||
verified: meta.verified === undefined ? null : (meta.verified ? 1 : 0),
|
||||
last_refreshed_at: meta.lastRefreshedAt ? Number(meta.lastRefreshedAt) : now,
|
||||
};
|
||||
db.prepare(`INSERT INTO channels (provider, external_id, title, handle, avatar_url, url, subs_count, verified, last_refreshed_at)
|
||||
VALUES (@provider, @external_id, @title, @handle, @avatar_url, @url, @subs_count, @verified, @last_refreshed_at)
|
||||
db.prepare(`INSERT INTO channels (provider, external_id, title, handle, avatar_url, banner_url, description, url, subs_count, verified, last_refreshed_at)
|
||||
VALUES (@provider, @external_id, @title, @handle, @avatar_url, @banner_url, @description, @url, @subs_count, @verified, @last_refreshed_at)
|
||||
ON CONFLICT(provider, external_id) DO UPDATE SET
|
||||
title=excluded.title,
|
||||
handle=excluded.handle,
|
||||
avatar_url=excluded.avatar_url,
|
||||
banner_url=excluded.banner_url,
|
||||
description=excluded.description,
|
||||
url=excluded.url,
|
||||
subs_count=excluded.subs_count,
|
||||
verified=COALESCE(excluded.verified, channels.verified),
|
||||
@@ -1136,6 +1391,8 @@ export async function ensureChannelFresh(provider, externalId, fetcher, opts = {
|
||||
title: data?.title,
|
||||
handle: data?.handle,
|
||||
avatarUrl: data?.avatarUrl,
|
||||
bannerUrl: data?.bannerUrl,
|
||||
description: data?.description,
|
||||
url: data?.url,
|
||||
subsCount: data?.subsCount,
|
||||
verified: data?.verified,
|
||||
@@ -1383,7 +1640,47 @@ function ensureYoutubeCacheTables() {
|
||||
}
|
||||
ensureYoutubeCacheTables();
|
||||
|
||||
/**
|
||||
* Task 4.2 — migration de `youtube_search_cache` vers `search_cache`.
|
||||
*
|
||||
* La clé est identique (`hashSearchKey` + préfixe `yt|`) : on copie donc les
|
||||
* lignes telles quelles, sans retransformation. La table d'origine **n'est pas
|
||||
* supprimée** : elle reste lue en repli pendant une version, ce qui rend la
|
||||
* migration réversible (`DROP TABLE search_cache` suffit à revenir en arrière).
|
||||
*
|
||||
* `INSERT OR IGNORE` : ne jamais écraser une entrée plus fraîche déjà présente
|
||||
* dans `search_cache` (une base peut avoir servi les deux tables).
|
||||
*/
|
||||
export function migrateYoutubeCacheToSearchCache() {
|
||||
try {
|
||||
ensureYoutubeCacheTables();
|
||||
ensureSearchCacheTables();
|
||||
const hasOld = db.prepare(`SELECT COUNT(1) AS n FROM youtube_search_cache`).get()?.n || 0;
|
||||
if (hasOld === 0) return { migrated: 0, remaining: 0, legacyReadable: false };
|
||||
const res = db.prepare(
|
||||
`INSERT OR IGNORE INTO search_cache (cache_key, provider, q, payload_json, item_count, source, hit_count, created_at, expires_at)
|
||||
SELECT q_hash, 'yt', q, payload_json,
|
||||
CASE WHEN json_valid(payload_json) THEN json_array_length(payload_json) ELSE 0 END,
|
||||
source, 0, created_at, expires_at
|
||||
FROM youtube_search_cache WHERE expires_at > ?`,
|
||||
).run(Date.now());
|
||||
const migrated = res.changes || 0;
|
||||
const remaining = db.prepare(`SELECT COUNT(1) AS n FROM youtube_search_cache WHERE expires_at > ?`).get(Date.now())?.n || 0;
|
||||
return { migrated, remaining, legacyReadable: true };
|
||||
} catch { return { migrated: 0, remaining: 0, legacyReadable: false, error: true }; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lecture YouTube. Essaie `search_cache` (table générique, phase 4) puis
|
||||
* **replie** sur `youtube_search_cache` : une base upgradeée depuis une version
|
||||
* antérieure peut n'avoir que des lignes dans l'ancienne table tant que la
|
||||
* migration n'a pas tourné.
|
||||
*/
|
||||
export function getCachedYoutubeSearch(qHash) {
|
||||
try {
|
||||
const generic = getCachedSearch('yt', qHash);
|
||||
if (generic) return { items: generic.items, source: generic.source };
|
||||
} catch {}
|
||||
try {
|
||||
ensureYoutubeCacheTables();
|
||||
const row = db.prepare(`SELECT payload_json AS payload, source, expires_at AS exp FROM youtube_search_cache WHERE q_hash = ?`).get(qHash);
|
||||
@@ -1396,7 +1693,10 @@ export function getCachedYoutubeSearch(qHash) {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Écrit YouTube : table générique en primaire, ancienne table en repli. */
|
||||
export function setCachedYoutubeSearch(qHash, q, items, source, ttlMs) {
|
||||
const written = setCachedSearch('yt', qHash, q, items, source, ttlMs);
|
||||
if (written) return;
|
||||
try {
|
||||
ensureYoutubeCacheTables();
|
||||
const now = Date.now();
|
||||
@@ -1411,7 +1711,8 @@ export function setCachedYoutubeSearch(qHash, q, items, source, ttlMs) {
|
||||
}
|
||||
|
||||
export function pruneYoutubeCache() {
|
||||
try { db.prepare(`DELETE FROM youtube_search_cache WHERE expires_at <= ?`).run(Date.now()); } catch {}
|
||||
// Phase 4.5 : la purge ne délaisse plus l'ancienne table derrière.
|
||||
return pruneSearchCache();
|
||||
}
|
||||
|
||||
export function incYoutubeMetrics({ scrapeCalls = 0, apiCalls = 0, quotaUnits = 0 } = {}) {
|
||||
@@ -1438,6 +1739,321 @@ export function countYoutubeCacheRows() {
|
||||
try { return db.prepare(`SELECT COUNT(1) AS n FROM youtube_search_cache`).get()?.n || 0; } catch { return 0; }
|
||||
}
|
||||
|
||||
// -------------------- Phase 4.1 : cache de recherche générique --------------------
|
||||
/**
|
||||
* Cache de recherche **générique**, multi-fournisseur.
|
||||
*
|
||||
* Remplace `youtube_search_cache` (task 4.2) sans le supprimer : les anciennes
|
||||
* fonctions YouTube restent des surcouches de compatibilité et lisent encore
|
||||
* l'ancienne table en repli.
|
||||
*
|
||||
* Choix de conception (cf. `docs/plan-phases-catalogue-classification.md`) :
|
||||
* - la clé reprend **exactement** le format YouTube `hashSearchKey`
|
||||
* (`<provider>|<sha256(q|perPage|page|sort|filtersCacheKey)>`) : une ligne
|
||||
* existante peut être migrée telle quelle, et la migration est réversible ;
|
||||
* - `hit_count` est incrémenté **à chaque lecture** (y compris les lectures
|
||||
* expirées) pour mesurer le ratio cache/total via `/api/providers/metrics` ;
|
||||
* - un résultat **vide n'est jamais persisté** : une page vide transitoire
|
||||
* (continuation expirée, raté réseau partiel) ne doit pas empoisonner le
|
||||
* cache pendant 5-30 min.
|
||||
*/
|
||||
const SEARCH_CACHE_MAX_ROWS = 2000;
|
||||
|
||||
/** TTL par fournisseur. YT garde 30 min (quota Data API), les autres 5 min. */
|
||||
export function searchCacheTtlMs(provider) {
|
||||
const p = String(provider || '').toLowerCase();
|
||||
if (p === 'yt') {
|
||||
const override = Number(process.env.SEARCH_CACHE_TTL_MS_YT);
|
||||
if (Number.isFinite(override) && override > 0) return override;
|
||||
return 30 * 60 * 1000;
|
||||
}
|
||||
const perProvider = Number(process.env[`SEARCH_CACHE_TTL_MS_${p.toUpperCase()}`]);
|
||||
if (Number.isFinite(perProvider) && perProvider > 0) return perProvider;
|
||||
const general = Number(process.env.SEARCH_CACHE_TTL_MS_DEFAULT);
|
||||
if (Number.isFinite(general) && general > 0) return general;
|
||||
return 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
function ensureSearchCacheTables() {
|
||||
try {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS search_cache (
|
||||
cache_key TEXT NOT NULL, provider TEXT NOT NULL, q TEXT NOT NULL DEFAULT '',
|
||||
payload_json TEXT NOT NULL, item_count INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'api', hit_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (cache_key, provider)
|
||||
);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_search_cache_exp ON search_cache(expires_at);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_search_cache_prov ON search_cache(provider, expires_at);`);
|
||||
} catch {}
|
||||
}
|
||||
ensureSearchCacheTables();
|
||||
|
||||
/**
|
||||
* Lecture cache + bump du `hit_count`.
|
||||
* @returns {{items: any[], source: string, hitCount: number}|null}
|
||||
*/
|
||||
export function getCachedSearch(provider, cacheKey) {
|
||||
const p = String(provider || '').toLowerCase();
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
const row = db.prepare(
|
||||
`SELECT payload_json AS payload, source, expires_at AS exp, hit_count AS hits, created_at AS createdAt
|
||||
FROM search_cache WHERE cache_key = ? AND provider = ?`,
|
||||
).get(String(cacheKey), p);
|
||||
if (!row) return null;
|
||||
const hits = Number(row.hits || 0) + 1;
|
||||
try { db.prepare(`UPDATE search_cache SET hit_count = ? WHERE cache_key = ? AND provider = ?`).run(hits, String(cacheKey), p); } catch {}
|
||||
if (Date.now() >= Number(row.exp || 0)) {
|
||||
// Expiration paresseuse : la ligne est comptabilisée puis purgée.
|
||||
try { db.prepare(`DELETE FROM search_cache WHERE cache_key = ? AND provider = ?`).run(String(cacheKey), p); } catch {}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const items = JSON.parse(String(row.payload || '[]'));
|
||||
if (!Array.isArray(items)) return null;
|
||||
// `createdAt` = moment où l'amont a réellement été interrogé. C'est la
|
||||
// SEULE date de capture honnête pour un hit de cache : `Date.now()`
|
||||
// mentirait en annonçant « à l'instant » une réponse vieille de 4 min.
|
||||
return { items, source: row.source, hitCount: hits, createdAt: Number(row.createdAt || 0) || null };
|
||||
} catch { return null; }
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Écriture. Un tableau vide est ignoré (voir en-tête de section). */
|
||||
export function setCachedSearch(provider, cacheKey, q, items, source, ttlMs) {
|
||||
const p = String(provider || '').toLowerCase();
|
||||
if (!Array.isArray(items) || items.length === 0) return false;
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
const now = Date.now();
|
||||
// Un TTL explicitement fourni est respecte tel quel (y compris 0 ou
|
||||
// negatif = « deja expire », utile pour vider un cache en test) ; seule
|
||||
// l'absence de valeur (undefined/NaN) retombe sur le TTL du provider.
|
||||
const ttl = Number.isFinite(ttlMs) ? ttlMs : searchCacheTtlMs(p);
|
||||
db.prepare(
|
||||
`INSERT INTO search_cache (cache_key, provider, q, payload_json, item_count, source, hit_count, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
ON CONFLICT(cache_key, provider) DO UPDATE SET q=excluded.q, payload_json=excluded.payload_json,
|
||||
item_count=excluded.item_count, source=excluded.source, created_at=excluded.created_at,
|
||||
expires_at=excluded.expires_at`,
|
||||
) .run(String(cacheKey), p, String(q || '').slice(0, 300), JSON.stringify(items), items.length, String(source || 'api'), now, now + ttl);
|
||||
// Uniquement le plafond : la purge des lignes expirees est un travail de
|
||||
// fond (intervalle 10 min, cf. 4.5) et ne doit pas coutir un DELETE par
|
||||
// ecriture.
|
||||
enforceSearchCacheCap(p, SEARCH_CACHE_MAX_ROWS);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3.12 — persistance des `pageToken` de contenu de chaîne.
|
||||
*
|
||||
* L'API YouTube pagine avec des jetons opaques, pas des numéros de page : pour
|
||||
* atteindre la page N il faut le jeton produit en chargeant la page N-1. Ces
|
||||
* jetons vivaient dans une Map **process-local**, ce qui avait deux conséquences
|
||||
* réelles :
|
||||
* 1. un redémarrage ou un passage derrière un load-balancer repart de la page 1 ;
|
||||
* 2. au-delà de 500 clés, la Map faisait `clear()` — donc la pagination était
|
||||
* réinitialisée SANS AUCUN SIGNE, silencieusement.
|
||||
*
|
||||
* On garde la Map comme cache L1 (rapide, et évite un accès SQLite par lecture)
|
||||
* et on déporte la persistance dans `search_cache` (L2), ce qui couvre le
|
||||
* redémarrage et le scale horizontal. Un éviction du L1 ne perd plus rien.
|
||||
*
|
||||
*.Namespace de provider distinct (`yt_tokens`) : ces lignes ne sont pas des
|
||||
* résultats de recherche, et les mélanger aux lignes `yt` fausserait le plafond
|
||||
* par fournisseur et les statistiques de cache.
|
||||
*/
|
||||
const PAGE_TOKEN_PROVIDER = 'yt_tokens';
|
||||
const PAGE_TOKEN_TTL_MS = 5 * 60e3;
|
||||
|
||||
export function getCachedPageTokens(cacheKey) {
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
const row = db.prepare(
|
||||
`SELECT payload_json AS payload, expires_at AS exp FROM search_cache WHERE cache_key = ? AND provider = ?`,
|
||||
).get(String(cacheKey), PAGE_TOKEN_PROVIDER);
|
||||
if (!row) return null;
|
||||
if (Number(row.exp || 0) <= Date.now()) {
|
||||
// Expiré : on ne le sert pas, et on le retire pour ne pas le relire.
|
||||
try { db.prepare(`DELETE FROM search_cache WHERE cache_key = ? AND provider = ?`).run(String(cacheKey), PAGE_TOKEN_PROVIDER); } catch {}
|
||||
return null;
|
||||
}
|
||||
const tokens = JSON.parse(row.payload);
|
||||
return Array.isArray(tokens) ? tokens : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function setCachedPageTokens(cacheKey, tokens, ttlMs = PAGE_TOKEN_TTL_MS) {
|
||||
if (!Array.isArray(tokens) || tokens.length === 0) return false;
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
const now = Date.now();
|
||||
const ttl = Number.isFinite(ttlMs) ? ttlMs : PAGE_TOKEN_TTL_MS;
|
||||
// Normalisation ICI, à la frontière de persistance, et pas chez l'appelant :
|
||||
// `JSON.stringify` transforme un trou (`undefined`) en `null`, et un `filter`
|
||||
// décalerait les index — le jeton de la page 2 se retrouverait à l'index 0
|
||||
// et la page 3 renverrait celui de la page 4. Une pagination silencieusement
|
||||
// décalée est indétectable en production ; on garantit donc l'indexation à
|
||||
// l'écriture, pour tout appelant présent ou futur. `''` reste falsy, donc
|
||||
// la lecture la traite comme un jeton absent.
|
||||
const dense = Array.from({ length: tokens.length }, (_, i) => {
|
||||
const t = tokens[i];
|
||||
return typeof t === 'string' && t.length > 0 ? t : '';
|
||||
});
|
||||
db.prepare(
|
||||
`INSERT INTO search_cache (cache_key, provider, q, payload_json, item_count, source, hit_count, created_at, expires_at)
|
||||
VALUES (?, ?, '', ?, ?, 'page_token', 0, ?, ?)
|
||||
ON CONFLICT(cache_key, provider) DO UPDATE SET payload_json=excluded.payload_json,
|
||||
item_count=excluded.item_count, created_at=excluded.created_at, expires_at=excluded.expires_at`,
|
||||
).run(String(cacheKey), PAGE_TOKEN_PROVIDER, JSON.stringify(dense), dense.length, now, now + ttl);
|
||||
enforceSearchCacheCap(PAGE_TOKEN_PROVIDER, 500);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Vide les jetons persistés (tests, et invalidation manuelle). */
|
||||
export function clearCachedPageTokens() {
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
db.prepare(`DELETE FROM search_cache WHERE provider = ?`).run(PAGE_TOKEN_PROVIDER);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Plafond par fournisseur : purge par `expires_at DESC`, donc les plus
|
||||
* fraîches survivent (identique au plafond historique de `youtube_search_cache`).
|
||||
*/
|
||||
function enforceSearchCacheCap(provider, cap = SEARCH_CACHE_MAX_ROWS) {
|
||||
try {
|
||||
db.prepare(
|
||||
`DELETE FROM search_cache WHERE provider = ? AND cache_key NOT IN (
|
||||
SELECT cache_key FROM search_cache WHERE provider = ? ORDER BY expires_at DESC LIMIT ?)`,
|
||||
).run(String(provider), String(provider), Math.max(1, cap));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge de fond : supprime les lignes expirees, puis applique le plafond.
|
||||
* Appelee par l'intervalle de 10 min au boot (task 4.5).
|
||||
*/
|
||||
export function pruneSearchCache({ cap = SEARCH_CACHE_MAX_ROWS } = {}) {
|
||||
let purged = 0;
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
purged = db.prepare(`DELETE FROM search_cache WHERE expires_at <= ?`).run(Date.now()).changes || 0;
|
||||
} catch {}
|
||||
try {
|
||||
for (const r of db.prepare(`SELECT DISTINCT provider FROM search_cache`).all()) {
|
||||
enforceSearchCacheCap(r.provider, cap);
|
||||
}
|
||||
} catch {}
|
||||
return purged;
|
||||
}
|
||||
|
||||
export function countSearchCacheRows(provider) {
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
const p = provider ? String(provider).toLowerCase() : null;
|
||||
const row = p
|
||||
? db.prepare(`SELECT COUNT(1) AS n FROM search_cache WHERE provider = ?`).get(p)
|
||||
: db.prepare(`SELECT COUNT(1) AS n FROM search_cache`).get();
|
||||
return row?.n || 0;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
/** Ratio hit/total par fournisseur — alimenta `/api/providers/metrics`. */
|
||||
export function searchCacheStats() {
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
return db.prepare(
|
||||
`SELECT provider, COUNT(1) AS entries, COALESCE(SUM(hit_count), 0) AS hits,
|
||||
COALESCE(SUM(item_count), 0) AS items
|
||||
FROM search_cache GROUP BY provider`,
|
||||
).all().map((r) => ({ ...r, hitRate: r.hits + r.entries > 0 ? r.hits / (r.hits + r.entries) : 0 }));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// -------------------- Phase 4.3 : metriques fournisseurs --------------------
|
||||
/**
|
||||
* Compteurs par fenetre horaire et par fournisseur. Generalise `youtube_metrics`
|
||||
* (qui reste en place pour le quota Data API, granularite jour).
|
||||
*
|
||||
* Volontairement **fin** : une ligne par (heure, provider), purgee au-dela de
|
||||
* `PROVIDER_METRICS_RETENTION_H` (defaut 24 h). La bascule automatique (4.4) lit
|
||||
* la fenetre d'1 h ; le cache lit `hit_count` de `search_cache`.
|
||||
*/
|
||||
function ensureProviderMetricsTable() {
|
||||
try {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS provider_metrics (
|
||||
hour TEXT NOT NULL, provider TEXT NOT NULL,
|
||||
calls INTEGER NOT NULL DEFAULT 0, ok_calls INTEGER NOT NULL DEFAULT 0,
|
||||
errors INTEGER NOT NULL DEFAULT 0, fallback_calls INTEGER NOT NULL DEFAULT 0,
|
||||
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT, updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (hour, provider)
|
||||
);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_provider_metrics_hour ON provider_metrics(hour);`);
|
||||
} catch {}
|
||||
}
|
||||
ensureProviderMetricsTable();
|
||||
|
||||
/** Incrémente les compteurs d'un appel fournisseur terminé. */
|
||||
export function incProviderMetrics(provider, { ok = true, latencyMs = 0, fallback = false, error = null } = {}) {
|
||||
try {
|
||||
ensureProviderMetricsTable();
|
||||
const p = String(provider || 'unknown').toLowerCase();
|
||||
const now = new Date();
|
||||
const hour = now.toISOString().slice(0, 13); // AAAA-MM-JJTHH
|
||||
const lat = Number.isFinite(latencyMs) && latencyMs > 0 ? Math.min(Math.round(latencyMs), 600000) : 0;
|
||||
db.prepare(
|
||||
`INSERT INTO provider_metrics (hour, provider, calls, ok_calls, errors, fallback_calls, total_latency_ms, last_error, updated_at)
|
||||
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(hour, provider) DO UPDATE SET
|
||||
calls = calls + 1,
|
||||
ok_calls = ok_calls + excluded.ok_calls,
|
||||
errors = errors + excluded.errors,
|
||||
fallback_calls = fallback_calls + excluded.fallback_calls,
|
||||
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
|
||||
last_error = COALESCE(excluded.last_error, provider_metrics.last_error),
|
||||
updated_at = excluded.updated_at`,
|
||||
).run(hour, p, ok ? 1 : 0, ok ? 0 : 1, fallback ? 1 : 0, lat, error ? String(error).slice(0, 500) : null, now.toISOString());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Instantané par fournisseur sur la fenetre demandee (defaut 1 h). */
|
||||
export function providerMetricsSnapshot({ hours = 1 } = {}) {
|
||||
try {
|
||||
ensureProviderMetricsTable();
|
||||
const since = new Date(Date.now() - Math.max(1, hours) * 3600e3).toISOString().slice(0, 13);
|
||||
const rows = db.prepare(
|
||||
`SELECT provider, COALESCE(SUM(calls),0) AS calls, COALESCE(SUM(ok_calls),0) AS okCalls,
|
||||
COALESCE(SUM(errors),0) AS errors, COALESCE(SUM(fallback_calls),0) AS fallbacks,
|
||||
COALESCE(SUM(total_latency_ms),0) AS totalLatencyMs
|
||||
FROM provider_metrics WHERE hour >= ? GROUP BY provider`,
|
||||
).all(since);
|
||||
return rows.map((r) => ({
|
||||
provider: r.provider,
|
||||
calls: r.calls,
|
||||
ok: r.okCalls,
|
||||
errors: r.errors,
|
||||
fallbacks: r.fallbacks,
|
||||
avgLatencyMs: r.calls > 0 ? Math.round(r.totalLatencyMs / r.calls) : 0,
|
||||
errorRate: r.calls > 0 ? r.errors / r.calls : 0,
|
||||
}));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export function purgeProviderMetrics() {
|
||||
try {
|
||||
const hours = Math.max(1, Number(process.env.PROVIDER_METRICS_RETENTION_H || 24));
|
||||
const before = new Date(Date.now() - hours * 3600e3).toISOString().slice(0, 13);
|
||||
return db.prepare(`DELETE FROM provider_metrics WHERE hour < ?`).run(before).changes || 0;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
// -------------------- OAuth : connexions Google / Twitch (import favoris/abos) --------------------
|
||||
function ensureOAuthTables() {
|
||||
try {
|
||||
|
||||
+232
-34
@@ -15,8 +15,10 @@ import ffmpegPath from 'ffmpeg-static';
|
||||
import * as cheerio from 'cheerio';
|
||||
import axios from 'axios';
|
||||
import rumbleRouter from './rumble.mjs';
|
||||
import { providerRegistry, validateProviders } from './providers/registry.mjs';
|
||||
import { providerRegistry, getProviderAdapter, validateProviders, SUGGESTION_CONTRACT_VERSION } from './providers/registry.mjs';
|
||||
import { applyProviderFlags, providerFlag } from './providers/feature-flags.mjs';
|
||||
import { dedupeSuggestGroups } from './suggest.mjs';
|
||||
import { buildDebugPayload, APPLICATION_NDJSON } from './search-transport.mjs';
|
||||
import { parseSearchFilters, activeFilters as activeSearchFilters, applySearchFilters, filtersCacheKey } from './search-filters.mjs';
|
||||
import { fetchWebSuggest, fetchOdyseeLighthouseSuggest } from './suggest-web.mjs';
|
||||
import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError, ensureFmtParam, isEmptyTimedTextBody, isBlobTranscript, mergeTranscriptCandidates } from './transcript.mjs';
|
||||
@@ -102,7 +104,7 @@ import {
|
||||
deleteTranscriptHistoryById,
|
||||
deleteAllTranscriptHistory,
|
||||
} from './db.mjs';
|
||||
import { getChannelAdapter, setTwitchTokenProvider } from './providers/channel-registry.mjs';
|
||||
import { setTwitchTokenProvider } from './providers/channel-registry.mjs';
|
||||
import { fetchChannelContent } from './providers/channel-content.mjs';
|
||||
import {
|
||||
oauthStatus, buildAuthUrl, createOAuthState, consumeOAuthState, exchangeCode,
|
||||
@@ -275,11 +277,11 @@ function requireProviderId(value) {
|
||||
}
|
||||
|
||||
async function resolveChannel(provider, externalId, { forceRefresh = false } = {}) {
|
||||
const adapterEntry = getChannelAdapter(provider) || providerRegistry[provider];
|
||||
if (!adapterEntry || typeof adapterEntry.fetchChannelById !== 'function') {
|
||||
const adapterEntry = getProviderAdapter(provider);
|
||||
if (!adapterEntry || typeof adapterEntry.channelMeta !== 'function') {
|
||||
throw Object.assign(new Error('provider_not_supported'), { status: 501 });
|
||||
}
|
||||
return ensureChannelFresh(provider, externalId, () => adapterEntry.fetchChannelById(externalId), { force: forceRefresh });
|
||||
return ensureChannelFresh(provider, externalId, () => adapterEntry.channelMeta(externalId), { force: forceRefresh });
|
||||
}
|
||||
|
||||
r.post('/channels/resolve', authMiddlewareCookieAware, channelsLimiter, async (req, res) => {
|
||||
@@ -325,8 +327,12 @@ r.get('/channels/:provider/:externalId/content', channelsLimiter, async (req, re
|
||||
const limit = Math.min(50, Math.max(1, Number(req.query.limit || 24)));
|
||||
const sort = req.query.sort === 'popular' ? 'popular' : req.query.sort === 'relevance' ? 'relevance' : 'recent';
|
||||
const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 200) : '';
|
||||
const data = await fetchChannelContent(provider, externalId, { type, page, limit, sort, q }, { searchRegistry: providerRegistry });
|
||||
return res.json({ ...data, page, limit, sort, type });
|
||||
// Phase 3.1 - curseur opaque pour les API paginées par curseur (Twitch Helix).
|
||||
// Sans lui, `page=2` re-demandait la page 1 et la pagination bouclait à
|
||||
// l'infini. Le front le renvoie tel quel dans `?cursor=`.
|
||||
const cursor = typeof req.query.cursor === 'string' ? req.query.cursor.slice(0, 512) : '';
|
||||
const data = await getProviderAdapter(provider).channelContent(externalId, { type, page, limit, sort, q, cursor }, { searchRegistry: providerRegistry });
|
||||
return res.json({ ...data, page, limit, sort, type, ...(data?.nextCursor ? { nextCursor: data.nextCursor } : {}) });
|
||||
} catch (error) {
|
||||
const status = error?.status || 500;
|
||||
return res.status(status).json({ error: error?.message || 'channel_content_failed', items: [], nextPage: null });
|
||||
@@ -1761,9 +1767,9 @@ r.get('/details/:provider/:videoId', async (req, res) => {
|
||||
if (!uploaderAvatar && channelExternalId) {
|
||||
const shortToRegistry = { youtube: 'yt', dailymotion: 'dm', twitch: 'tw', peertube: 'pt', odysee: 'od', rumble: 'ru' };
|
||||
const regProvider = shortToRegistry[String(provider)] || String(provider);
|
||||
const adapter = (typeof getChannelAdapter === 'function') ? getChannelAdapter(regProvider) : null;
|
||||
if (adapter && typeof adapter.fetchChannelById === 'function') {
|
||||
const ch = await adapter.fetchChannelById(String(channelExternalId));
|
||||
const adapter = getProviderAdapter(regProvider);
|
||||
if (adapter && typeof adapter.channelMeta === 'function') {
|
||||
const ch = await adapter.channelMeta(String(channelExternalId));
|
||||
if (ch?.avatarUrl) uploaderAvatar = ch.avatarUrl;
|
||||
if (typeof ch?.subsCount === 'number' && !subscribers) subscribers = ch.subsCount;
|
||||
}
|
||||
@@ -2873,13 +2879,124 @@ r.get('/img/odysee', async (req, res) => {
|
||||
});
|
||||
|
||||
// Mount API router (prod) and alias for dev proxy
|
||||
// Phase 4.5 : purge de fond du cache de recherche (toutes les 10 min).
|
||||
// `unref()` est indispensable : sans lui le timer empeche le process de
|
||||
// s'arreter, y compris dans les tests qui demarrent puis ferment le serveur.
|
||||
const SEARCH_CACHE_JANITOR_MS = Math.max(60e3, Number(process.env.SEARCH_CACHE_PRUNE_MS || 10 * 60 * 1000));
|
||||
let searchCacheJanitorStarted = false;
|
||||
function startSearchCacheJanitor() {
|
||||
if (searchCacheJanitorStarted) return;
|
||||
searchCacheJanitorStarted = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const { pruneSearchCache, purgeProviderMetrics } = await import('./db.mjs');
|
||||
const purged = pruneSearchCache();
|
||||
const metrics = purgeProviderMetrics();
|
||||
if (purged || metrics) console.log(`[cache] purge: ${purged} entrées search_cache, ${metrics} lignes provider_metrics`);
|
||||
} catch (e) {
|
||||
console.warn('[cache] purge échouée:', e?.message || e);
|
||||
}
|
||||
};
|
||||
const timer = setInterval(tick, SEARCH_CACHE_JANITOR_MS);
|
||||
timer.unref?.();
|
||||
// Une purge immédiate au boot : une base qui a tourné des semaines hors ligne
|
||||
// peut accumuler des lignes expirees en masse.
|
||||
setTimeout(tick, 2000).unref?.();
|
||||
}
|
||||
|
||||
// Phase 4.2 : migration `youtube_search_cache` -> `search_cache`, une fois au boot.
|
||||
// Idempotente et non destructive : l'ancienne table reste lue en repli.
|
||||
(async () => {
|
||||
try {
|
||||
const { migrateYoutubeCacheToSearchCache } = await import('./db.mjs');
|
||||
const r = migrateYoutubeCacheToSearchCache();
|
||||
if (r?.migrated > 0) console.log(`[cache] migration youtube_search_cache -> search_cache: ${r.migrated} ligne(s) copiée(s)`);
|
||||
} catch (e) {
|
||||
console.warn('[cache] migration ignorée:', e?.message || e);
|
||||
}
|
||||
})();
|
||||
|
||||
// Phase 4.3 : observabilité fournisseurs. Aucun secret exposé.
|
||||
const PROVIDER_IDS = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
|
||||
// Sonde par provider : une recherche `limit=1`, résultat mis en cache 60 s
|
||||
// (sans ce garde-fou, `/providers/health` créerait l'inverse du problème qu'il
|
||||
// mesure en martelant les upstreams).
|
||||
const healthProbeCache = new Map();
|
||||
const HEALTH_PROBE_TTL_MS = Math.max(5e3, Number(process.env.PROVIDER_HEALTH_TTL_MS || 60e3));
|
||||
async function probeProvider(pid) {
|
||||
const cached = healthProbeCache.get(pid);
|
||||
if (cached && Date.now() - cached.at < HEALTH_PROBE_TTL_MS) return cached.value;
|
||||
const t0 = Date.now();
|
||||
let value;
|
||||
try {
|
||||
const { providerRegistry } = await import('./providers/registry.mjs');
|
||||
const adapter = providerRegistry[pid];
|
||||
if (!adapter || typeof adapter.search !== 'function') throw new Error('adapter_absent');
|
||||
const items = await adapter.search('test', { limit: 1, page: 1, sort: 'relevance' });
|
||||
value = {
|
||||
ok: true, latencyMs: Date.now() - t0, itemCount: Array.isArray(items) ? items.length : 0,
|
||||
lastSuccessAt: new Date().toISOString(), lastError: null,
|
||||
};
|
||||
} catch (e) {
|
||||
value = { ok: false, latencyMs: Date.now() - t0, itemCount: 0, lastSuccessAt: null, lastError: String(e?.message || e).slice(0, 300) };
|
||||
}
|
||||
healthProbeCache.set(pid, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
|
||||
r.get('/providers/health', async (req, res) => {
|
||||
try {
|
||||
const only = String(req.query.provider || '').trim();
|
||||
const ids = only ? (PROVIDER_IDS.includes(only) ? [only] : []) : PROVIDER_IDS;
|
||||
if (!ids.length) return res.status(400).json({ error: 'unknown_provider' });
|
||||
const snapshot = (await import('./db.mjs')).providerMetricsSnapshot({ hours: 1 });
|
||||
const entries = await Promise.all(ids.map(async (pid) => {
|
||||
// Phase 8.3 : on ne sonde PAS un provider désactivé par feature flag.
|
||||
// Sonder un upstream volontairement éteint renverrait `ok: false`, alors
|
||||
// qu'il n'est pas en panne : `disabled` distingue « éteint » de « cassé ».
|
||||
const flag = providerFlag(pid);
|
||||
if (!flag.enabled) {
|
||||
return [pid, {
|
||||
ok: false, disabled: true, flag: flag.flag, flagValue: flag.raw,
|
||||
latencyMs: 0, itemCount: 0, lastSuccessAt: null, lastError: 'disabled_by_ff',
|
||||
consecutiveFailures: 0, errorRate: 0,
|
||||
}];
|
||||
}
|
||||
const probe = await probeProvider(pid);
|
||||
const row = snapshot.find((x) => x.provider === pid);
|
||||
return [pid, {
|
||||
...probe,
|
||||
disabled: false,
|
||||
flag: flag.flag, flagValue: flag.raw,
|
||||
consecutiveFailures: row?.errors || 0, errorRate: row?.errorRate || 0,
|
||||
}];
|
||||
}));
|
||||
res.json({ providers: Object.fromEntries(entries) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
r.get('/providers/metrics', async (_req, res) => {
|
||||
try {
|
||||
const dbm = await import('./db.mjs');
|
||||
res.json({
|
||||
cache: dbm.searchCacheStats?.() || [],
|
||||
providers: dbm.providerMetricsSnapshot?.({ hours: 1 }) || [],
|
||||
youtube: { today: dbm.getYoutubeMetricsToday?.() || null },
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api', r);
|
||||
// Health endpoint for container checks
|
||||
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
|
||||
// Step 17 : observabilité YouTube (mode, yt-dlp, cache, quota). Aucun secret exposé.
|
||||
app.get(['/healthz', '/api/healthz'], async (_req, res) => {
|
||||
try {
|
||||
const [{ getYoutubeMetricsToday, countYoutubeCacheRows }, common] =
|
||||
const [{ getYoutubeMetricsToday, countYoutubeCacheRows, searchCacheStats, providerMetricsSnapshot, countSearchCacheRows }, common] =
|
||||
await Promise.all([import('./db.mjs'), import('./providers/youtube-common.mjs')]);
|
||||
let ytdlpVersion = null;
|
||||
let resolvedBin = null;
|
||||
@@ -2895,6 +3012,9 @@ app.get(['/healthz', '/api/healthz'], async (_req, res) => {
|
||||
status: 'ok',
|
||||
youtube: {
|
||||
mode: getSearchMode(),
|
||||
// Phase 4.4 : le mode reel peut differer de l'intention (bascule auto).
|
||||
effectiveMode: common.getEffectiveSearchMode?.() || getSearchMode(),
|
||||
failover: common.getFailoverState?.() || null,
|
||||
ytdlp: { bin: resolvedBin || getYtDlpBin(), version: ytdlpVersion, info: ytDlpInfo, binOk: Boolean(ytdlpVersion) },
|
||||
antiban: {
|
||||
cookiesFile: hasCookiesFile(),
|
||||
@@ -2905,6 +3025,12 @@ app.get(['/healthz', '/api/healthz'], async (_req, res) => {
|
||||
metrics: { ...metricsSnapshot(), today: getYoutubeMetricsToday() },
|
||||
keys: { count: keys.length, banned },
|
||||
},
|
||||
// Phase 4.3 : résumé multi-fournisseurs. Volontairement compact — le
|
||||
// détail est sur `/api/providers/metrics`.
|
||||
providers: {
|
||||
cache: { rows: countSearchCacheRows?.() || 0, byProvider: searchCacheStats?.() || [] },
|
||||
lastHour: providerMetricsSnapshot?.({ hours: 1 }) || [],
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ status: 'error', error: String(e?.message || e) });
|
||||
@@ -3117,6 +3243,14 @@ app.all('/proxy/twitch-api/*', (req, res) => forwardJson(req, res, 'https://api.
|
||||
app.all('/proxy/twitch-auth/*', (req, res) => forwardJson(req, res, 'https://id.twitch.tv'));
|
||||
|
||||
// -------------------- Unified search endpoint (GET) --------------------
|
||||
// Phase 7.3 : le payload `?debug=1` (provenance + `raw` tronque et redige) est
|
||||
// construit par `search-transport.mjs` : la troncature et la redaction sont une
|
||||
// barriere de securite, elles meritent un module et des tests dedies.
|
||||
// Phase 7.6 : deux TRANSPORTS pour un SEUL fan-out. `Accept: application/x-ndjson`
|
||||
// (ou `?stream=1`) fait écrire une ligne JSON dès qu'un provider répond ; sinon
|
||||
// la réponse atomique historique est renvoyée à l'identique. Les deux modes
|
||||
// partagent le même code d'agrégation : impossible qu'ils divergent sur le
|
||||
// filtrage ou la forme des erreurs.
|
||||
app.get('/api/search', async (req, res) => {
|
||||
try {
|
||||
console.log('[SEARCH] Requête reçue - Query:', req.query);
|
||||
@@ -3140,46 +3274,102 @@ app.get('/api/search', async (req, res) => {
|
||||
sort = filters.sort;
|
||||
// Validate and normalize providers list (default to all supported when none/invalid)
|
||||
const requested = typeof providers === 'string' ? String(providers) : '';
|
||||
const validProviders = validateProviders(requested);
|
||||
// Phase 8.3 : on retire les providers désactivés par feature flag AVANT le
|
||||
// fan-out (inutile d'appeler un upstream qu'on a choisi d'éteindre), et on
|
||||
// le signale dans `errors` pour que la colonne vide soit explicable.
|
||||
const { providerIds: validProviders, errors: flagErrors } = applyProviderFlags(validateProviders(requested));
|
||||
for (const [pid, err] of Object.entries(flagErrors)) {
|
||||
console.warn(`[search] provider ${pid} désactivé (${err.code})`);
|
||||
}
|
||||
|
||||
// Execute search for each provider in parallel
|
||||
const results = await Promise.allSettled(
|
||||
validProviders.map((providerId) => {
|
||||
const mod = providerRegistry[/** @type {any} */(providerId)];
|
||||
if (!mod || typeof mod.search !== 'function') return Promise.resolve([]);
|
||||
// Basic options include pagination, sort hints and the search filters
|
||||
return Promise.resolve().then(() => mod.search(q, { limit: pageSize, page: pageNum, sort, filters }));
|
||||
})
|
||||
);
|
||||
// Phase 7.6 : transport incrémental. `Accept: text/event-stream` n'est pas
|
||||
// utilisé volontairement — NDJSON se parse ligne à ligne sans parseur SSE, et
|
||||
// reste rejouable / testable avec un simple client HTTP.
|
||||
const wantsStream = String(req.headers.accept || '').includes(APPLICATION_NDJSON)
|
||||
|| req.query.stream === '1';
|
||||
let streamStarted = false;
|
||||
if (wantsStream) {
|
||||
// `X-Accel-Buffering: no` : derrière nginx (configuré par défaut), un
|
||||
// buffer de réponse différerait TOUT le NDJSON jusqu'à la fin — on
|
||||
// obtiendrait un streaming qui streame en un seul coup, c'est-à-dire rien.
|
||||
res.setHeader('Content-Type', `${APPLICATION_NDJSON}; charset=utf-8`);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders?.();
|
||||
streamStarted = true;
|
||||
}
|
||||
/** Écrit une ligne du flux, si le client a demandé le streaming. */
|
||||
const emit = (line) => {
|
||||
if (!streamStarted) return;
|
||||
// `res.writableEnded` : le client a pu annuler (nouvelle frappe) pendant
|
||||
// qu'un provider répondait ; écrire dans le vide lèverait une erreur.
|
||||
if (res.writableEnded || res.destroyed) return;
|
||||
res.write(`${JSON.stringify(line)}\n`);
|
||||
};
|
||||
|
||||
// Group results by provider id (+ per-provider errors for diagnosable UI)
|
||||
const groups = /** @type {Record<string, any[]>} */ ({});
|
||||
const errors = /** @type {Record<string, { message: string, status?: number, code?: string }>} */ ({});
|
||||
results.forEach((result, index) => {
|
||||
const providerId = validProviders[index];
|
||||
if (result.status === 'fulfilled') {
|
||||
// Les providers désactivés par FF ont bien un groupe vide : le front
|
||||
// affiche une pastille « aucun résultat » cohérente avec le reste.
|
||||
for (const pid of Object.keys(flagErrors)) groups[pid] = [];
|
||||
const errors = /** @type {Record<string, { message: string, status?: number, code?: string }>} */ ({ ...flagErrors });
|
||||
|
||||
// Exécution en parallèle, avec émission IMMÉDIATE de chaque groupe dès
|
||||
// qu'il est résolu. L'agrégation est écrite UNE fois : le mode atomique et
|
||||
// le mode NDJSON consomment exactement les mêmes objets.
|
||||
await Promise.all(validProviders.map(async (providerId) => {
|
||||
try {
|
||||
const mod = providerRegistry[/** @type {any} */(providerId)];
|
||||
const raw = (!mod || typeof mod.search !== 'function')
|
||||
? []
|
||||
: await mod.search(q, { limit: pageSize, page: pageNum, sort, filters });
|
||||
// Filtres non supportés nativement par le provider -> affinage ici.
|
||||
groups[providerId] = applySearchFilters(providerId, Array.isArray(result.value) ? result.value : [], filters);
|
||||
} else {
|
||||
console.warn(`Search failed for provider ${providerId}:`, result.reason?.message || result.reason);
|
||||
const items = applySearchFilters(providerId, Array.isArray(raw) ? raw : [], filters);
|
||||
groups[providerId] = items;
|
||||
emit({ type: 'provider', provider: providerId, ok: true, items });
|
||||
} catch (r) {
|
||||
console.warn(`Search failed for provider ${providerId}:`, r?.message || r);
|
||||
groups[providerId] = [];
|
||||
try {
|
||||
const r = /** @type {any} */ (result.reason);
|
||||
const rr = /** @type {any} */ (r);
|
||||
errors[providerId] = {
|
||||
message: String(r?.message || r || 'search_failed'),
|
||||
...(typeof r?.ytStatus === 'number' ? { status: r.ytStatus } : {}),
|
||||
...(r?.code ? { code: String(r.code) } : {}),
|
||||
message: String(rr?.message || rr || 'search_failed'),
|
||||
...(typeof rr?.ytStatus === 'number' ? { status: rr.ytStatus } : {}),
|
||||
...(rr?.code ? { code: String(rr.code) } : {}),
|
||||
};
|
||||
} catch {}
|
||||
emit({ type: 'provider', provider: providerId, ok: false, error: errors[providerId] || { message: 'search_failed' } });
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
const debug = req.query.debug === '1' || req.query.debug === 'true';
|
||||
if (streamStarted) {
|
||||
// Ligne de clôture : tout le contrat (version, pagination, filtres), puis
|
||||
// `end`. Le front peut donc valider l'arrivée complète sans deviner quand
|
||||
// le flux se termine.
|
||||
emit({ type: 'done', v: SUGGESTION_CONTRACT_VERSION, q, providers: validProviders, page: pageNum, pageSize, sort, filters: activeSearchFilters(filters) });
|
||||
if (!res.writableEnded) res.end();
|
||||
return undefined;
|
||||
}
|
||||
return res.json({
|
||||
// Phase 0.4 - le contrat Suggestion est versionne : le front peut
|
||||
// detecter un serveur ancien et degrader proprement au lieu de lire des
|
||||
// champs `undefined` sans le savoir. 2 = champs optionnels (views,
|
||||
// publishedAt, avatars, channelId, …). 1 = contrat d'origine.
|
||||
v: SUGGESTION_CONTRACT_VERSION,
|
||||
q, providers: validProviders, groups, errors,
|
||||
page: pageNum, pageSize, sort,
|
||||
filters: activeSearchFilters(filters),
|
||||
// Phase 7.3 - mode debug : on expose le `raw` provider tronque. Volontairement
|
||||
// HORS du contrat normal : un payload brut de 6 providers ferait exploser la
|
||||
// reponse et peut contenir des cles d'API internes, donc uniquement si
|
||||
// demande explicitement.
|
||||
...(debug ? { debug: buildDebugPayload(groups, errors) } : {}),
|
||||
});
|
||||
} catch (e) {
|
||||
// Une coupure du client ne doit pas logger une trace d'erreur serveur : en
|
||||
// flux incrémental, abandonner la requête est le comportement NORMAL.
|
||||
if (streamStarted && (res.writableEnded || res.destroyed)) return undefined;
|
||||
return res.status(500).json({ error: 'search_failed', details: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
@@ -3230,7 +3420,10 @@ r.get('/search/suggest', suggestLimiter, async (req, res) => {
|
||||
}
|
||||
const limit = Math.min(20, Math.max(1, Number(req.query.limit || 10)));
|
||||
const requested = typeof req.query.providers === 'string' ? String(req.query.providers) : '';
|
||||
const validProviders = validateProviders(requested);
|
||||
// Phase 8.3 : flags appliqués AVANT la clé de cache, sinon un résultat
|
||||
// calculé pendant que le provider était éteint serait resservi après
|
||||
// réactivation (et l'inverse). Inclus dans la clé de toute façon.
|
||||
const { providerIds: validProviders, errors: flagErrors } = applyProviderFlags(validateProviders(requested));
|
||||
const cacheKey = `suggest:${validProviders.join(',')}:${q.toLowerCase()}:${limit}`;
|
||||
const cached = suggestCacheGet(cacheKey);
|
||||
if (cached) return res.json(cached);
|
||||
@@ -3250,6 +3443,9 @@ r.get('/search/suggest', suggestLimiter, async (req, res) => {
|
||||
);
|
||||
const [webRes, lightRes] = await Promise.allSettled([webPromise, lighthousePromise]);
|
||||
const groups = {};
|
||||
// Cohérence avec /api/search : un provider éteint a un groupe vide ET une
|
||||
// raison, sinon le front ne peut pas distinguer « éteint » de « muet ».
|
||||
for (const pid of Object.keys(flagErrors)) groups[pid] = [];
|
||||
results.forEach((result, index) => {
|
||||
const providerId = validProviders[index];
|
||||
if (result.status === 'fulfilled' && Array.isArray(result.value)) {
|
||||
@@ -3269,6 +3465,7 @@ r.get('/search/suggest', suggestLimiter, async (req, res) => {
|
||||
}
|
||||
// La même occurrence n'est renvoyée qu'une fois (providers d'abord, web en dernier).
|
||||
const data = { q, groups: dedupeSuggestGroups(groups, [...validProviders, 'web']) };
|
||||
if (Object.keys(flagErrors).length > 0) data.errors = flagErrors;
|
||||
suggestCacheSet(cacheKey, data);
|
||||
return res.json(data);
|
||||
} catch (e) {
|
||||
@@ -3760,6 +3957,7 @@ app.listen(PORT, () => {
|
||||
console.log(`[newtube-api] distRoot=${distRoot} exists=${hasDistRoot}`);
|
||||
console.log(`[newtube-api] distBrowser=${distBrowser} exists=${hasDistBrowser}`);
|
||||
console.log(`[newtube-api] staticDir=${staticDir} indexExists=${hasIndex}`);
|
||||
startSearchCacheJanitor();
|
||||
});
|
||||
|
||||
// --- Playlists ---
|
||||
|
||||
@@ -120,24 +120,74 @@ function ytSuggestion(item, details, channelId) {
|
||||
}
|
||||
|
||||
// L'API YouTube pagine avec des pageTokens opaques, pas des numéros de page.
|
||||
// Cache process-wide des tokens : clé requête -> tokens[page] (tokens[1] = token
|
||||
// pour charger la page 2). Sans token connu pour page > 1, on s'arrête (nextPage null)
|
||||
// au lieu de re-servir la page 1 en boucle.
|
||||
// Clé requête -> tokens[page] (tokens[1] = token pour charger la page 2). Sans
|
||||
// token connu pour page > 1, on s'arrête (nextPage null) au lieu de re-servir
|
||||
// la page 1 en boucle.
|
||||
//
|
||||
// Phase 3.12 — cache à DEUX NIVEAUX. Le L1 (Map process-local) reste pour la
|
||||
// vitesse ; le L2 (`search_cache` en base) survit au redémarrage et au scale
|
||||
// horizontal. Avant, le plafond de 500 clés faisait `clear()` et la pagination
|
||||
// repartait de la page 1 SANS SIGNE : le L2 comble ce trou, une éviction du L1
|
||||
// ne perd plus le jeton.
|
||||
const ytTokenCache = new Map();
|
||||
function ytTokenStore(key, page, nextToken) {
|
||||
const YT_TOKEN_L1_MAX = 500;
|
||||
|
||||
async function ytTokenL2Get(key) {
|
||||
try {
|
||||
const { getCachedPageTokens } = await import('../db.mjs');
|
||||
return getCachedPageTokens?.(key) || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function ytTokenL2Set(key, arr) {
|
||||
try {
|
||||
const { setCachedPageTokens } = await import('../db.mjs');
|
||||
setCachedPageTokens?.(key, arr);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Écrit un jeton et propage la chaîne complète au L2 (les pages s'appuient les unes sur les autres). */
|
||||
async function ytTokenStore(key, page, nextToken) {
|
||||
if (!nextToken) return;
|
||||
let arr = ytTokenCache.get(key);
|
||||
let fromL2 = false;
|
||||
if (!arr) {
|
||||
if (ytTokenCache.size > 500) ytTokenCache.clear();
|
||||
arr = [];
|
||||
ytTokenCache.set(key, arr);
|
||||
// Page N-1 peut avoir été chargée dans un autre process : on relit le L2
|
||||
// avant d'écrire, sinon on écrase la chaîne avec un tableau à trous.
|
||||
arr = (await ytTokenL2Get(key)) || null;
|
||||
fromL2 = Array.isArray(arr);
|
||||
if (!arr) arr = [];
|
||||
}
|
||||
arr[page] = nextToken;
|
||||
if (!fromL2) {
|
||||
// Éviction LRU-lite au lieu d'un `clear()` global : on écarte la plus
|
||||
// ancienne entrée, pas tout le cache.
|
||||
if (ytTokenCache.size > YT_TOKEN_L1_MAX) {
|
||||
const oldest = ytTokenCache.keys().next().value;
|
||||
if (oldest !== undefined) ytTokenCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
ytTokenCache.set(key, arr);
|
||||
// `map`, PAS `filter` : les trous de l'indexation doivent survivre au round-trip
|
||||
// JSON. `filter(Boolean)` décalerait le jeton de la page 2 vers l'index 0 et la
|
||||
// page 3 renverrait le jeton de la page 4 — une pagination silencieusement
|
||||
// décalée. Les trous sont donc matérialisés par `''` (falsy : traité comme
|
||||
// absent à la lecture).
|
||||
await ytTokenL2Set(key, Array.from({ length: arr.length }, (_, i) => arr[i] || ''));
|
||||
}
|
||||
function ytTokenFor(key, page) {
|
||||
|
||||
async function ytTokenFor(key, page) {
|
||||
if (page <= 1) return '';
|
||||
const arr = ytTokenCache.get(key);
|
||||
return arr ? arr[page - 1] : undefined;
|
||||
const l1 = ytTokenCache.get(key);
|
||||
if (l1 && l1[page - 1]) return l1[page - 1];
|
||||
const l2 = await ytTokenL2Get(key);
|
||||
if (l2 && l2[page - 1]) {
|
||||
// Réhydratation du L1 : les pages suivantes de cette même chaîne évitent
|
||||
// alors l'accès L2.
|
||||
ytTokenCache.set(key, l2);
|
||||
return l2[page - 1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||
@@ -152,8 +202,8 @@ async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||
// externalId brut (handle ou UC...) : le scrape gère les deux formes
|
||||
const scraped = await fetchChannelViaScrape(externalId, { type, page: pageNum, limit: perPage });
|
||||
if (Array.isArray(scraped?.items) && scraped.items.length) {
|
||||
if (mode === 'scrape-only') return { ...scraped, total: null };
|
||||
// scrape-first : retour direct si non vide
|
||||
// Phase 2.4 - branche morte supprimée : `scrape-only` et `scrape-first`
|
||||
// renvoyaient tous deux `{ ...scraped, total: null }` à l'identique.
|
||||
return { ...scraped, total: null };
|
||||
}
|
||||
// vide -> on tente l'API (chaîne à faible volume ou tab non supporté en scrape)
|
||||
@@ -169,7 +219,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||
const channelId = await resolveYouTubeChannelId(externalId);
|
||||
if (type === 'playlists') {
|
||||
const key = ['pl', channelId, perPage].join('|');
|
||||
const token = ytTokenFor(key, pageNum);
|
||||
const token = await ytTokenFor(key, pageNum);
|
||||
if (token === undefined) return { items: [], nextPage: null };
|
||||
const params = { part: 'snippet,contentDetails', channelId, maxResults: String(perPage) };
|
||||
// Ne jamais envoyer pageToken=undefined (sérialisé en "undefined" -> 400).
|
||||
@@ -181,7 +231,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||
console.warn('[channel-content/yt] playlists failed:', e?.message || e);
|
||||
return { items: [], nextPage: null };
|
||||
}
|
||||
ytTokenStore(key, pageNum, data?.nextPageToken);
|
||||
await ytTokenStore(key, pageNum, data?.nextPageToken);
|
||||
const items = (data?.items || []).map((pl) => ({
|
||||
id: pl?.id,
|
||||
title: pl?.snippet?.title || '',
|
||||
@@ -193,7 +243,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||
}
|
||||
const order = sort === 'popular' ? 'viewCount' : sort === 'recent' ? 'date' : 'relevance';
|
||||
const key = ['search', channelId, type, order, q || '', perPage].join('|');
|
||||
const token = ytTokenFor(key, pageNum);
|
||||
const token = await ytTokenFor(key, pageNum);
|
||||
if (token === undefined) return { items: [], nextPage: null };
|
||||
const params = {
|
||||
part: 'snippet', channelId, type: 'video', maxResults: String(perPage), order,
|
||||
@@ -204,7 +254,7 @@ async function ytContent(externalId, { type, page, limit, sort, q }) {
|
||||
if (type === 'shorts') params.videoDuration = 'short';
|
||||
if (q) params.q = q;
|
||||
const data = await ytGet('search', params);
|
||||
ytTokenStore(key, pageNum, data?.nextPageToken);
|
||||
await ytTokenStore(key, pageNum, data?.nextPageToken);
|
||||
const ids = (data?.items || []).map((i) => i?.id?.videoId).filter(Boolean);
|
||||
const details = await ytVideoDetails(ids);
|
||||
let items = (data?.items || [])
|
||||
@@ -240,7 +290,11 @@ async function dmContent(externalId, { type, page, limit, sort, q }) {
|
||||
id: v.id, title: v.title, thumbnail: v.thumbnail_720_url || v.thumbnail_480_url,
|
||||
url: `https://www.dailymotion.com/video/${v.id}`, uploaderName: v['owner.screenname'],
|
||||
channelId: v['owner.id'], channelExternalId: v['owner.id'] || user,
|
||||
duration: Number(v.duration || 0), views: Number(v.views_total || 0),
|
||||
// Phase 2.2 - `|| 0` transformait "inconnu" en "0 s" : la carte affichait
|
||||
// 0:00 et la règle « verticale sans durée connue => pas un short » ne
|
||||
// pouvait plus distinguer les deux cas. On n'émet le champ que s'il est > 0.
|
||||
...(Number(v.duration) > 0 ? { duration: Number(v.duration) } : {}),
|
||||
...(Number(v.views_total) > 0 ? { views: Number(v.views_total) } : {}),
|
||||
publishedAt: v.created_time ? new Date(v.created_time * 1000).toISOString() : undefined, type: 'video',
|
||||
}));
|
||||
if (q) { const n = q.toLowerCase(); items = items.filter((i) => i.title.toLowerCase().includes(n)); }
|
||||
@@ -270,7 +324,7 @@ async function twUser(externalId, headers) {
|
||||
return data?.data?.[0] || null;
|
||||
}
|
||||
|
||||
async function twContent(externalId, { type, page, limit, sort, q }) {
|
||||
async function twContent(externalId, { type, page, limit, sort, q, cursor }) {
|
||||
const clientId = process.env.TWITCH_CLIENT_ID;
|
||||
const token = await twToken();
|
||||
if (!clientId || !token) return { items: [], nextPage: null };
|
||||
@@ -285,22 +339,44 @@ async function twContent(externalId, { type, page, limit, sort, q }) {
|
||||
const items = (data?.data || []).map((s) => ({
|
||||
id: s.id, title: s.title, thumbnail: String(s.thumbnail_url || '').replace('{width}', '1280').replace('{height}', '720'),
|
||||
url: `https://www.twitch.tv/${user.login}`, uploaderName: user.display_name,
|
||||
channelId: user.id, channelExternalId: user.id, views: Number(s.viewer_count || 0),
|
||||
publishedAt: s.started_at, type: 'video', kind: 'vod',
|
||||
channelId: user.id, channelExternalId: user.id,
|
||||
// Phase 3.2 - `viewer_count` est le nombre de spectateurs, PAS des vues.
|
||||
// Il sert a afficher « X spectateurs en direct », pas « X vues ».
|
||||
viewers: Number(s.viewer_count || 0) || undefined,
|
||||
// Phase 3.2 - anomalies #4/#5 : l'item live etait marque `type:'video'`
|
||||
// + `kind:'vod'`, donc `isLiveItem()` ne le classait jamais comme live et
|
||||
// l'onglet Live affichait une VOD. `type` doit valoir 'live' et `isLive`
|
||||
// etre pose — les deux, pour couvrir les deux règles de classification.
|
||||
isLive: true, type: 'live', kind: 'live',
|
||||
game: s.game_name || undefined, language: s.language || undefined,
|
||||
publishedAt: s.started_at, duration: undefined,
|
||||
uploaderAvatar: user.profile_image_url || undefined,
|
||||
channelHandle: user.login, channelUrl: `https://www.twitch.tv/${user.login}`,
|
||||
}));
|
||||
return { items, nextPage: null };
|
||||
}
|
||||
if (type === 'playlists' || type === 'shorts') return { items: [], nextPage: null };
|
||||
const qs = new URLSearchParams({ user_id: user.id, first: String(perPage), type: 'archive', sort: sort === 'popular' ? 'views' : 'time' });
|
||||
// Phase 3.1 - le curseur Helix est enfin renvoyé au client au lieu d'etre jeté.
|
||||
if (cursor) qs.set('after', cursor);
|
||||
const data = await readJson(await fetchWithTimeout(`https://api.twitch.tv/helix/videos?${qs}`, { headers }));
|
||||
let items = (data?.data || []).map((v) => ({
|
||||
id: v.id, title: v.title, thumbnail: v.thumbnail_url, url: v.url,
|
||||
uploaderName: user.display_name, channelId: user.id, channelExternalId: user.id,
|
||||
duration: undefined, views: Number(v.view_count || 0), publishedAt: v.created_at, type: 'video', kind: 'vod',
|
||||
// Helix `/videos` ne fournit pas la durée : absent reste absent (phase 2.2).
|
||||
duration: undefined,
|
||||
...(Number(v.view_count) > 0 ? { views: Number(v.view_count) } : {}),
|
||||
publishedAt: v.created_at, type: 'video', kind: 'vod',
|
||||
uploaderAvatar: user.profile_image_url || undefined,
|
||||
channelHandle: user.login, channelUrl: `https://www.twitch.tv/${user.login}`,
|
||||
}));
|
||||
if (q) { const n = q.toLowerCase(); items = items.filter((i) => i.title.toLowerCase().includes(n)); }
|
||||
const cursor = data?.pagination?.cursor;
|
||||
return { items, nextPage: cursor ? (page || 1) + 1 : null };
|
||||
if (q) { const n = q.toLowerCase(); items = items.filter((i) => String(i.title || '').toLowerCase().includes(n)); }
|
||||
const nextCursor = data?.pagination?.cursor || '';
|
||||
return {
|
||||
items,
|
||||
nextPage: nextCursor ? (page || 1) + 1 : null,
|
||||
...(nextCursor ? { nextCursor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- PeerTube (externalId = instance|channel) ----
|
||||
@@ -329,7 +405,9 @@ async function ptContent(externalId, { type, page, limit, sort, q }) {
|
||||
id: String(v.uuid || v.id), title: v.name, thumbnail: v?.thumbnailPath ? `https://${instance}${v.thumbnailPath}` : undefined,
|
||||
url: v?.url, uploaderName: v?.channel?.displayName || channel,
|
||||
channelId: externalId, channelExternalId: externalId,
|
||||
duration: Number(v.duration || 0), views: Number(v.views || 0), publishedAt: v.publishedAt, type: 'video',
|
||||
// Phase 2.2 - idem : jamais de 0 pour une durée/vues inconnues.
|
||||
...(Number(v.duration) > 0 ? { duration: Number(v.duration) } : {}),
|
||||
...(Number(v.views) > 0 ? { views: Number(v.views) } : {}), publishedAt: v.publishedAt, type: 'video',
|
||||
}));
|
||||
return { items, nextPage: items.length >= perPage ? (page || 1) + 1 : null, total: data?.total ?? null };
|
||||
}
|
||||
@@ -343,15 +421,21 @@ async function odContent(externalId, { type, page, limit, sort, q }) {
|
||||
jsonrpc: '2.0', id: 1, method: 'claim_search',
|
||||
params: { channel: claim, page: Math.max(1, Number(page || 1)), page_size: perPage, claim_type: 'stream', order_by: sort === 'popular' ? ['effective_amount'] : ['release_time'] },
|
||||
};
|
||||
const resp = await fetchWithTimeout('https://api.na-backend.odysee.com/api/v1/proxy?m=claim_search', {
|
||||
// Phase 1.3 - on repasse par readJson() : le .catch(() => ({})) avalait les
|
||||
// 4xx/5xx et transformait une erreur reseau en "chaine vide" silencieuse.
|
||||
const data = await readJson(await fetchWithTimeout('https://api.na-backend.odysee.com/api/v1/proxy?m=claim_search', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
})).catch((err) => { throw Object.assign(new Error(`odysee_channel_search_failed: ${err?.message || err}`), { status: 502 }); });
|
||||
let items = ((data?.result?.items) || []).map((c) => ({
|
||||
id: c.claim_id, title: c?.value?.title, thumbnail: c?.value?.thumbnail?.url,
|
||||
url: c.short_url || c.canonical_url, uploaderName: c?.signing_channel?.value?.title || claim,
|
||||
channelId: externalId, channelExternalId: externalId,
|
||||
duration: Number(c?.value?.video?.duration || 0), publishedAt: c?.value?.release_time ? new Date(Number(c.value.release_time) * 1000).toISOString() : undefined,
|
||||
...(Number(c?.value?.video?.duration) > 0 ? { duration: Math.round(Number(c.value.video.duration)) } : {}), publishedAt: c?.value?.release_time ? new Date(Number(c.value.release_time) * 1000).toISOString() : undefined,
|
||||
// Phase 1.1 - claim_search expose `value.video.view_count` quand la colonne est
|
||||
// demandee. L'`effective_amount` reste un montant LBC : jamais un compteur.
|
||||
...(Number.isFinite(Number(c?.value?.video?.view_count)) && Number(c?.value?.video?.view_count) >= 0
|
||||
? { views: Math.round(Number(c.value.video.view_count)) }
|
||||
: {}),
|
||||
type: 'video', slug: (c.short_url || '').replace('https://odysee.com/', ''),
|
||||
}));
|
||||
if (q) { const n = q.toLowerCase(); items = items.filter((i) => String(i.title || '').toLowerCase().includes(n)); }
|
||||
@@ -360,30 +444,44 @@ async function odContent(externalId, { type, page, limit, sort, q }) {
|
||||
}
|
||||
|
||||
// ---- Rumble : pas d'API publique -> recherche unifiée filtrée par chaîne ----
|
||||
async function ruContent(externalId, { type, page, limit, q }, searchRegistry) {
|
||||
async function ruContent(externalId, { type, page, limit, q }, ctx) {
|
||||
if (type !== 'videos') return { items: [], nextPage: null };
|
||||
try {
|
||||
const mod = searchRegistry?.ru;
|
||||
if (!mod || typeof mod.search !== 'function') return { items: [], nextPage: null };
|
||||
const needle = String(externalId || '').replace(/^@/, '').toLowerCase();
|
||||
const results = await mod.search(q || needle || 'videos', { limit: 50, page: 1 });
|
||||
const items = (results || []).filter((r) => {
|
||||
const hay = `${r?.uploaderName || ''} ${r?.channelId || ''} ${r?.url || ''}`.toLowerCase();
|
||||
return !needle || hay.includes(needle);
|
||||
}).slice(0, Number(limit || 24));
|
||||
return { items, nextPage: null };
|
||||
} catch { return { items: [], nextPage: null }; }
|
||||
const searchRegistry = ctx?.searchRegistry || ctx;
|
||||
const mod = searchRegistry?.ru;
|
||||
if (!mod || typeof mod.search !== 'function') return { items: [], nextPage: null };
|
||||
const needle = String(externalId || '').replace(/^@/, '').toLowerCase();
|
||||
// Phase 3.5 - filtre sur `channelId` en priorité : c'est l'identifiant
|
||||
// canonique extrait en phase 1.5, donc plus fiable qu'une comparaison de
|
||||
// sous-chaîne sur le nom ou l'URL.
|
||||
const results = await mod.search(q || needle || 'videos', { limit: 50, page: 1 });
|
||||
const items = (results || []).filter((r) => {
|
||||
if (r?.channelId && needle && String(r.channelId).toLowerCase() === needle) return true;
|
||||
const hay = `${r?.uploaderName || ''} ${r?.channelId || ''} ${r?.url || ''}`.toLowerCase();
|
||||
return !needle || hay.includes(needle);
|
||||
}).slice(0, Number(limit || 24));
|
||||
return { items, nextPage: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 8.1 — chaque provider est une extension, pas une entrée de switch :
|
||||
* `channelContentByProvider` indexe les collecteurs par id. `fetchChannelContent`
|
||||
* reste une façade (appelée par la route) mais n'EST PLUS la source : elle
|
||||
* délègue à la table, la même que celle que l'adaptateur unifié (`providerAdapters`)
|
||||
* expose comme `channelContent`. Un provider de plus = une entrée de table, pas
|
||||
* un case de plus.
|
||||
*/
|
||||
export const channelContentByProvider = {
|
||||
yt: ytContent,
|
||||
dm: dmContent,
|
||||
tw: twContent,
|
||||
pt: ptContent,
|
||||
od: odContent,
|
||||
ru: ruContent,
|
||||
};
|
||||
|
||||
export async function fetchChannelContent(provider, externalId, opts = {}, ctx = {}) {
|
||||
const { type = 'videos', page = 1, limit = 24, sort = 'recent', q = '' } = opts || {};
|
||||
switch (provider) {
|
||||
case 'yt': return ytContent(externalId, { type, page, limit, sort, q });
|
||||
case 'dm': return dmContent(externalId, { type, page, limit, sort, q });
|
||||
case 'tw': return twContent(externalId, { type, page, limit, sort, q });
|
||||
case 'pt': return ptContent(externalId, { type, page, limit, sort, q });
|
||||
case 'od': return odContent(externalId, { type, page, limit, sort, q });
|
||||
case 'ru': return ruContent(externalId, { type, page, limit, q }, ctx.searchRegistry);
|
||||
default: throw Object.assign(new Error('invalid_provider'), { status: 400 });
|
||||
}
|
||||
const { type = 'videos', page = 1, limit = 24, sort = 'recent', q = '', cursor = '' } = opts || {};
|
||||
const worker = channelContentByProvider[/** @type {keyof typeof channelContentByProvider} */ (provider)];
|
||||
if (!worker) throw Object.assign(new Error('invalid_provider'), { status: 400 });
|
||||
return worker(externalId, { type, page, limit, sort, q, cursor }, ctx);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Phase 6 — identité de chaîne normalisée (`channelRef`).
|
||||
*
|
||||
* AVANT, trois conventions d'identifiant de chaîne cohabitaient, chacune
|
||||
* implicite, codée à 4 endroits différents :
|
||||
*
|
||||
* 1. YouTube : `UC…` (dans `channelExternalId`)
|
||||
* 2. Dailymotion: identifiant numérique d'utilisateur (`owner.id`)
|
||||
* 3. Twitch : `login`
|
||||
* 4. PeerTube : `instance|channel` (construit par le FRONT depuis l'URL vidéo)
|
||||
* 5. Odysee : claim LBRY brut, avec ou sans `@` selon la source
|
||||
* 6. Rumble : slug `/c/<slug>`
|
||||
*
|
||||
* Aucun de ces formats ne disait « ceci est un identifiant de chaîne » : la même
|
||||
* chaîne pouvait être stockée sous deux formes (`yt` et `@MaChaine#a` pour Odysee,
|
||||
* `channel` seul et `instance|channel` pour PeerTube) et rien ne les réconciliait.
|
||||
*
|
||||
* `channelRef` rend l'imbrication explicite : le `scheme` DIT comment lire le
|
||||
* `value`. Le but n'est pas de supprimer les champs legacy (transition non
|
||||
* cassante, cf. phase 6.2) mais d'avoir une forme unique, sans perte et
|
||||
* réversible.
|
||||
*
|
||||
* SOURCE UNIQUE côté serveur. Le miroir TypeScript est
|
||||
* `src/app/shared/providers/channel-ref.ts`, et `npm run test:channelref`
|
||||
* vérifie que les deux listes de schemes ne divergent pas.
|
||||
*/
|
||||
|
||||
/** Schemes par provider (id court). L'ordre suit `ALL_PROVIDER_IDS`. */
|
||||
export const CHANNEL_REF_SCHEMES = Object.freeze({
|
||||
yt: 'yt-uc',
|
||||
dm: 'dm-user',
|
||||
tw: 'tw-login',
|
||||
pt: 'pt-composite',
|
||||
od: 'od-claim',
|
||||
ru: 'ru-slug',
|
||||
});
|
||||
|
||||
/**
|
||||
* Normalise un claim LBRY (Odysee) : un seul `@` en tête.
|
||||
*
|
||||
* Odysee est le seul provider où la même chaîne arrivait avec ET sans le `@`
|
||||
* selon le chemin (`channel` dans la réponse `claim_search`, `short_url` dans
|
||||
* la résolution, `externalId` en base) — d'où les deux normalisations
|
||||
* divergentes (`startsWith('@') ? … : '@'+…` au POST de resolve,
|
||||
* `replace(/^@/,'')` pour l'URL). La forme canonique porte le `@`.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {string|undefined} claim canonique, ou undefined si vide
|
||||
*/
|
||||
export function normalizeOdyseeClaim(value) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return undefined;
|
||||
return raw.startsWith('@') ? raw : `@${raw}`;
|
||||
}
|
||||
|
||||
/** Un claim LBRY pour une URL publique : `https://odysee.com/@x` -> `x`. */
|
||||
export function odyseeClaimToSlug(value) {
|
||||
const claim = normalizeOdyseeClaim(value);
|
||||
return claim ? claim.slice(1) : undefined;
|
||||
}
|
||||
|
||||
/** Un claim LBRY pour l'appel `resolve`, qui exige le `@`. */
|
||||
export function odyseeClaimToResolveArg(value) {
|
||||
return normalizeOdyseeClaim(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Découpe un identifiant PeerTube composite `instance|channel`.
|
||||
* Rétrocompatible avec l'ancien cas « channel seul » (instance inconnue).
|
||||
* @param {unknown} value
|
||||
*/
|
||||
export function parsePeerTubeComposite(value) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return { instance: undefined, channel: undefined };
|
||||
const sep = raw.indexOf('|');
|
||||
if (sep < 0) return { instance: undefined, channel: raw };
|
||||
const instance = raw.slice(0, sep) || undefined;
|
||||
const channel = raw.slice(sep + 1) || undefined;
|
||||
return { instance, channel };
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit `instance|channel` à partir de l'URL vidéo et du channelId.
|
||||
*
|
||||
* L'instance N'EST PAS optionnelle : sans elle, `parsePeerTubeComposite`
|
||||
* renverrait `{instance: undefined}` et `fetchPeerTubeChannel` construirait
|
||||
* `https://<channel>` — une URL fausse. On renvoie donc `undefined` plutôt
|
||||
* qu'un composite bancal. C'est aussi ce que faisait le front (`pt.ts` exigeait
|
||||
* `host && channelId`), et le critère d'acceptation 8.2 impose la parité.
|
||||
*/
|
||||
export function buildPeerTubeComposite(url, channelId) {
|
||||
const channel = String(channelId ?? '').trim();
|
||||
if (!channel) return undefined;
|
||||
let host = '';
|
||||
try {
|
||||
if (url) host = new URL(String(url)).hostname || '';
|
||||
} catch {}
|
||||
return host ? `${host}|${channel}` : undefined;
|
||||
}
|
||||
|
||||
const firstString = (...values) => {
|
||||
for (const v of values) {
|
||||
const s = String(v ?? '').trim();
|
||||
if (s) return s;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Construit le `channelRef` d'une Suggestion, à partir des champs **legacy**
|
||||
* déjà présents. N'invente jamais de donnée : si aucun identifiant n'est
|
||||
* disponible, renvoie `undefined` (jamais un objet à moitié rempli, jamais de
|
||||
* valeur inventée).
|
||||
*
|
||||
* Chaque règle reproduit à l'identique ce que le front calcule aujourd'hui dans
|
||||
* ses 6 adaptateurs — c'est le critère d'acceptation 8.2.
|
||||
*
|
||||
* @param {string} providerId id court ('yt', 'dm', …)
|
||||
* @param {object} item Suggestion
|
||||
* @returns {{provider: string, scheme: string, value: string}|undefined}
|
||||
*/
|
||||
export function buildChannelRef(providerId, item) {
|
||||
const pid = String(providerId ?? '').trim().toLowerCase();
|
||||
const scheme = CHANNEL_REF_SCHEMES[pid];
|
||||
if (!scheme || !item || typeof item !== 'object') return undefined;
|
||||
|
||||
let value;
|
||||
switch (pid) {
|
||||
case 'yt':
|
||||
// `yt.ts:32` : channelExternalId || channelId
|
||||
value = firstString(item.channelExternalId, item.channelId);
|
||||
break;
|
||||
case 'dm':
|
||||
// `dm.ts:30` : channelId (l'API ne donne que `owner.id`)
|
||||
value = firstString(item.channelId, item.channelExternalId);
|
||||
break;
|
||||
case 'tw':
|
||||
// `tw.ts:24` : channelExternalId || channelHandle (le login)
|
||||
value = firstString(item.channelExternalId, item.channelHandle);
|
||||
break;
|
||||
case 'pt':
|
||||
// `pt.ts:25-27` : hostname(url) + '|' + channelId
|
||||
value = buildPeerTubeComposite(item.url, item.channelId);
|
||||
break;
|
||||
case 'od':
|
||||
// `od.ts:40` : le claim brut, ici canonique (un seul `@`).
|
||||
value = normalizeOdyseeClaim(firstString(item.channel, item.uploaderName, item.channelHandle));
|
||||
break;
|
||||
case 'ru':
|
||||
// Phase 3.5 : slug extrait de `/c/<slug>` par l'adaptateur rumble.
|
||||
value = firstString(item.channelExternalId, item.channelId);
|
||||
break;
|
||||
default:
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
if (!value) return undefined;
|
||||
return { provider: pid, scheme, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Annote une liste de Suggestions avec leur `channelRef`.
|
||||
* Mutates les objets d'origine : c'est le même contrat que `type`/`isShort`,
|
||||
* ajoutés par les adaptateurs. Les objets sans identité restent inchangés
|
||||
* (pas de clé `channelRef` à `undefined`).
|
||||
*
|
||||
* @template T
|
||||
* @param {string} providerId
|
||||
* @param {T[]} items
|
||||
* @returns {T[]}
|
||||
*/
|
||||
export function withChannelRefs(providerId, items) {
|
||||
if (!Array.isArray(items)) return items;
|
||||
for (const item of items) {
|
||||
const ref = buildChannelRef(providerId, item);
|
||||
if (ref) item.channelRef = ref;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
parsePeerTubeComposite,
|
||||
odyseeClaimToResolveArg,
|
||||
odyseeClaimToSlug,
|
||||
} from './channel-ref.mjs';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = Number(process.env.CHANNEL_FETCH_TIMEOUT_MS || 6000);
|
||||
|
||||
// Fournisseur de token Twitch branché par server/index.mjs (qui gère le cache
|
||||
@@ -32,6 +38,29 @@ async function fetchWithTimeout(url, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** N'accepte qu'une URL http(s) absolue — bloque `javascript:` et `data:`. */
|
||||
function sanitizeImageUrl(raw) {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const url = raw.trim();
|
||||
if (!/^https?:\/\//i.test(url)) return undefined;
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.7 — descriptions : texte brut, souvent multi-lignes et souvent
|
||||
* énorme (YouTube renvoie plusieurs kilo-octets). On normalise les espaces et on
|
||||
* plafonne : une description de 10 Ko dans une page chaîne saccade le rendu et
|
||||
* écrase le contenu. Tronquée = information partielle mais honnête ; l'absence
|
||||
* de plafond = page cassée.
|
||||
*/
|
||||
const DESCRIPTION_MAX = 600;
|
||||
function cleanDescription(raw) {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const text = raw.replace(/\s+/g, ' ').trim();
|
||||
if (!text) return undefined;
|
||||
return text.length > DESCRIPTION_MAX ? `${text.slice(0, DESCRIPTION_MAX - 1).trimEnd()}…` : text;
|
||||
}
|
||||
|
||||
function safeMeta(meta = {}, fallback = {}) {
|
||||
return {
|
||||
provider: fallback.provider,
|
||||
@@ -39,6 +68,10 @@ function safeMeta(meta = {}, fallback = {}) {
|
||||
title: meta.title ?? fallback.title,
|
||||
handle: meta.handle ?? fallback.handle,
|
||||
avatarUrl: meta.avatarUrl ?? fallback.avatarUrl,
|
||||
// Phase 7.7 : bannière. URL seulement — un `javascript:` glissé dans une
|
||||
// bannière deviendrait un vecteur XSS au moment de l'afficher.
|
||||
bannerUrl: sanitizeImageUrl(meta.bannerUrl) ?? sanitizeImageUrl(fallback.bannerUrl),
|
||||
description: cleanDescription(meta.description) ?? cleanDescription(fallback.description),
|
||||
url: meta.url ?? fallback.url,
|
||||
subsCount: typeof meta.subsCount === 'number' ? meta.subsCount : fallback.subsCount,
|
||||
verified: typeof meta.verified === 'boolean'
|
||||
@@ -69,16 +102,19 @@ async function fetchYoutubeChannel(externalId) {
|
||||
const url = branding.channel?.customUrl
|
||||
? `https://www.youtube.com/${branding.channel.customUrl}`
|
||||
: `https://www.youtube.com/channel/${externalId}`;
|
||||
return {
|
||||
provider: 'yt',
|
||||
externalId,
|
||||
return safeMeta({
|
||||
title: snippet.title || branding.channel?.title,
|
||||
handle: snippet.customUrl ? `@${snippet.customUrl.replace(/^@/, '')}` : undefined,
|
||||
avatarUrl: avatars,
|
||||
// Phase 7.7 — `brandingSettings` est DÉJÀ demandé (pour l'URL), il contient
|
||||
// aussi la bannière : aucun appel réseau supplémentaire.
|
||||
bannerUrl: branding.image?.bannerImageUrl
|
||||
|| (Array.isArray(branding.image?.thumbnails) && branding.image.thumbnails.at(-1)?.url),
|
||||
description: snippet.description,
|
||||
url,
|
||||
subsCount: stats.subscriberCount ? Number(stats.subscriberCount) : undefined,
|
||||
verified: Array.isArray(snippet.badges) ? snippet.badges.includes('verified') : undefined,
|
||||
};
|
||||
}, { provider: 'yt', externalId, url });
|
||||
} catch {
|
||||
return { provider: 'yt', externalId, url: `https://www.youtube.com/channel/${externalId}` };
|
||||
}
|
||||
@@ -89,7 +125,7 @@ async function fetchDailymotionChannel(externalId) {
|
||||
// NOTE : `avatar_url` n'est pas un champ valide de l'API user (400 sur
|
||||
// toute la requête) — seuls avatar_720_url / avatar_medium_url le sont.
|
||||
const params = new URLSearchParams({
|
||||
fields: 'id,username,screenname,avatar_720_url,avatar_medium_url,url,followers_total,verified',
|
||||
fields: 'id,username,screenname,avatar_720_url,avatar_medium_url,cover_url,description,url,followers_total,verified',
|
||||
});
|
||||
const data = await fetchWithTimeout(`https://api.dailymotion.com/user/${externalId}?${params.toString()}`);
|
||||
const username = data.username || externalId;
|
||||
@@ -97,6 +133,9 @@ async function fetchDailymotionChannel(externalId) {
|
||||
title: data.screenname || data.username,
|
||||
handle: data.username ? `@${data.username}` : undefined,
|
||||
avatarUrl: data.avatar_720_url || data.avatar_medium_url,
|
||||
// Phase 7.7 — `cover_url` est la bannière (l'API n'a pas de champ « banner »).
|
||||
bannerUrl: data.cover_url,
|
||||
description: data.description,
|
||||
url: `https://www.dailymotion.com/user/${username}`,
|
||||
subsCount: typeof data.followers_total === 'number' ? data.followers_total : undefined,
|
||||
verified: Boolean(data.verified),
|
||||
@@ -129,6 +168,11 @@ async function fetchTwitchChannel(externalId) {
|
||||
title: user.display_name,
|
||||
handle: `@${user.login}`,
|
||||
avatarUrl: user.profile_image_url,
|
||||
// Phase 7.7 — Helix les expose dans le même `/users` : rien à demander
|
||||
// de plus. `banner_image_url` est absent tant que l'utilisateur n'a pas
|
||||
// de bannière : on ne fabrique pas d'URL de substitution.
|
||||
bannerUrl: user.banner_image_url,
|
||||
description: user.description,
|
||||
url: `https://www.twitch.tv/${user.login}`,
|
||||
subsCount: typeof user.view_count === 'number' ? user.view_count : undefined,
|
||||
}, { provider: 'tw', externalId, url: `https://www.twitch.tv/${externalId}` });
|
||||
@@ -138,11 +182,11 @@ async function fetchTwitchChannel(externalId) {
|
||||
return { provider: 'tw', externalId, url: `https://www.twitch.tv/${externalId}` };
|
||||
}
|
||||
|
||||
function parsePeerTubeExternalId(externalId) {
|
||||
const [instance, channel] = String(externalId).split('|');
|
||||
if (!channel) return { instance: null, channel: externalId };
|
||||
return { instance, channel };
|
||||
}
|
||||
/**
|
||||
* Phase 6.4 — délègue à la source unique (`channel-ref.mjs`) au lieu de
|
||||
* redécouper `instance|channel` localement.
|
||||
*/
|
||||
const parsePeerTubeExternalId = parsePeerTubeComposite;
|
||||
|
||||
async function fetchPeerTubeChannel(externalId) {
|
||||
const { instance, channel } = parsePeerTubeExternalId(externalId);
|
||||
@@ -155,6 +199,11 @@ async function fetchPeerTubeChannel(externalId) {
|
||||
title: data.displayName || data.name,
|
||||
handle: data.host ? `@${data.name}@${data.host}` : undefined,
|
||||
avatarUrl: data?.avatar?.path ? `https://${instance}${data.avatar.path}` : undefined,
|
||||
// Phase 7.7 — l'API vidéo-channels expose déjà `banners` et `description`.
|
||||
bannerUrl: (Array.isArray(data?.banners) && data.banners.at(-1)?.path)
|
||||
? `https://${instance}${data.banners.at(-1).path}`
|
||||
: (data?.banner?.path ? `https://${instance}${data.banner.path}` : undefined),
|
||||
description: data.description,
|
||||
url: data?.url || `https://${instance}/video-channels/${channel}`,
|
||||
subsCount: typeof data.followersCount === 'number' ? data.followersCount : undefined,
|
||||
verified: Boolean(data.ownerAccount?.verified)
|
||||
@@ -164,12 +213,32 @@ async function fetchPeerTubeChannel(externalId) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.7 — plus grande vignette LBRY disponible.
|
||||
* Le CDN Odysee sert la même image à plusieurs tailles via un paramètre `?size=` ;
|
||||
* on élargit donc la vignette de la chaîne, ce qui reste une VRAIE bannière.
|
||||
* À défaut de paramètre de taille, on rend l'URL telle quelle.
|
||||
*/
|
||||
function odyseeBannerUrl(value) {
|
||||
const base = value?.thumbnail?.url || value?.thumbnail;
|
||||
if (typeof base !== 'string' || !/^https?:\/\//i.test(base)) return undefined;
|
||||
if (/[?&]size=/i.test(base)) return base;
|
||||
return `${base}${base.includes('?') ? '&' : '?'}size=1200x600`;
|
||||
}
|
||||
|
||||
async function fetchOdyseeChannel(externalId) {
|
||||
try {
|
||||
// Phase 6.4 : la normalisation du claim passe par la source unique
|
||||
// (`channel-ref.mjs`). Avant, deux règles cohabitaient dans CE fichier
|
||||
// (`startsWith('@') ? … : '@'+…` ici, `replace(/^@/,'')` pour l'URL 20 lignes
|
||||
// plus bas) et une troisième vivait côté front — la forme canonique pouvait
|
||||
// donc être `@x` ici et `x` là-bas.
|
||||
const claim = odyseeClaimToResolveArg(externalId);
|
||||
if (!claim) return { provider: 'od', externalId, url: 'https://odysee.com/' };
|
||||
const body = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'resolve',
|
||||
params: { urls: [externalId.startsWith('@') ? externalId : `@${externalId}`] },
|
||||
params: { urls: [claim] },
|
||||
id: 1
|
||||
};
|
||||
const resp = await fetchWithTimeout('https://api.na-backend.odysee.com/api/v1/proxy?m=resolve', {
|
||||
@@ -188,12 +257,19 @@ async function fetchOdyseeChannel(externalId) {
|
||||
title: value?.title,
|
||||
handle: meta.short_url ? meta.short_url.replace('https://odysee.com/', '') : undefined,
|
||||
avatarUrl: meta?.thumbnail?.url,
|
||||
// Phase 7.7 — LBRY n'a pas de « bannière » distincte : la vignette du
|
||||
// claim EST l'image de la chaîne. On demande la plus grande taille
|
||||
// servie par le CDN plutôt que d'inventer une URL qui n'existerait pas.
|
||||
bannerUrl: odyseeBannerUrl(value),
|
||||
description: value?.description || value?.tagged_description || meta?.description,
|
||||
url: meta.short_url,
|
||||
subsCount: typeof meta?.meta?.effective_amount === 'number' ? meta.meta.effective_amount : undefined,
|
||||
}, { provider: 'od', externalId, url: meta.short_url });
|
||||
}
|
||||
} catch {}
|
||||
return { provider: 'od', externalId, url: `https://odysee.com/${externalId.replace(/^@/, '')}` };
|
||||
// L'URL publique ne porte pas le `@` — d'où la conversion explicite plutôt
|
||||
// qu'un `replace` local.
|
||||
return { provider: 'od', externalId, url: `https://odysee.com/${odyseeClaimToSlug(externalId) || ''}` };
|
||||
}
|
||||
|
||||
async function fetchRumbleChannel(externalId) {
|
||||
@@ -203,10 +279,19 @@ async function fetchRumbleChannel(externalId) {
|
||||
});
|
||||
const titleMatch = /<title>([^<]+)<\/title>/i.exec(data);
|
||||
const avatarMatch = /property="og:image" content="([^"]+)"/i.exec(data);
|
||||
// Phase 7.7 — `og:description` est déjà dans la page : aucun appel en plus.
|
||||
// `content=` en PREMIER attribut, comme pour og:image : sur Rumble l'ordre
|
||||
// n'est pas garanti et une regex trop rigide ne renvoie jamais rien.
|
||||
const descMatch = /<meta[^>]+property="og:description"[^>]+content="([^"]*)"/i.exec(data)
|
||||
|| /<meta[^>]+content="([^"]*)"[^>]+property="og:description"/i.exec(data);
|
||||
const name = titleMatch ? titleMatch[1].replace(/ on Rumble.*$/i, '').trim() : undefined;
|
||||
return safeMeta({
|
||||
title: name,
|
||||
avatarUrl: avatarMatch ? avatarMatch[1] : undefined,
|
||||
// Pas de bannière dédiée côté Rumble : `og:image` est déjà la meilleure
|
||||
// image disponible, mais le doublonner avec l'avatar n'apporte rien —
|
||||
// on laisse donc `bannerUrl` vide et l'UI masquera la bandeau.
|
||||
description: descMatch ? descMatch[1] : undefined,
|
||||
url: `https://rumble.com/${externalId}`,
|
||||
}, { provider: 'ru', externalId, url: `https://rumble.com/${externalId}` });
|
||||
} catch {
|
||||
@@ -214,17 +299,32 @@ async function fetchRumbleChannel(externalId) {
|
||||
}
|
||||
}
|
||||
|
||||
export const channelRegistry = {
|
||||
yt: { fetchChannelById: fetchYoutubeChannel },
|
||||
dm: { fetchChannelById: fetchDailymotionChannel },
|
||||
tw: { fetchChannelById: fetchTwitchChannel },
|
||||
pt: { fetchChannelById: fetchPeerTubeChannel },
|
||||
od: { fetchChannelById: fetchOdyseeChannel },
|
||||
ru: { fetchChannelById: fetchRumbleChannel },
|
||||
/**
|
||||
* Phase 8.1 — la table par provider devient la SOURCE : `channelMetaByProvider`
|
||||
* indexe les collecteurs de métadonnées par id. `channelRegistry` et
|
||||
* `getChannelAdapter` restent exportés pour compatibilité (tests, historique),
|
||||
* mais ne font qu'aliasser cette table — l'adaptateur unifié (`providerAdapters`)
|
||||
* consomme `channelMetaByProvider` et n'a pas besoin de savoir qu'une autre
|
||||
* table a existé.
|
||||
*/
|
||||
export const channelMetaByProvider = {
|
||||
yt: fetchYoutubeChannel,
|
||||
dm: fetchDailymotionChannel,
|
||||
tw: fetchTwitchChannel,
|
||||
pt: fetchPeerTubeChannel,
|
||||
od: fetchOdyseeChannel,
|
||||
ru: fetchRumbleChannel,
|
||||
};
|
||||
|
||||
// Compatibilité phase 0/… : `channelRegistry.yt.fetchChannelById(...)` a été
|
||||
// utilisé par des tests et l'historique. On le DÉRIVE de la table source plutôt
|
||||
// que de le dupliquer.
|
||||
export const channelRegistry = Object.fromEntries(
|
||||
Object.entries(channelMetaByProvider).map(([provider, fetchMeta]) => [provider, { fetchChannelById: fetchMeta }]),
|
||||
);
|
||||
|
||||
export function getChannelAdapter(provider) {
|
||||
return channelRegistry[provider];
|
||||
return channelMetaByProvider[provider];
|
||||
}
|
||||
|
||||
export default channelRegistry;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Phase 8.3 — Feature flags par provider (`FF_<PROVIDER>`).
|
||||
*
|
||||
* Motivation : certain upstreams sont fragiles hors de notre contrôle. Rumble
|
||||
* est derrière Cloudflare et peut casser du jour au lendemain ; Odysee dépend
|
||||
* d'un backend NA. Désactiver un provider ne doit pas exiger un redéploiement
|
||||
* ni un revert de code.
|
||||
*
|
||||
* Trois états, pas deux — c'est la distinction qui compte en exploitation :
|
||||
* - unset : le provider est actif (comportement historique) ;
|
||||
* - truthy : actif ;
|
||||
* - falsy : DÉSACTIVÉ, exclu du fan-out et signalé `errors.<id> = disabled_by_ff`.
|
||||
*
|
||||
* Un flag falsy **signale** au lieu de disparaître en silence : une colonne vide
|
||||
* sans explication ressemble à « aucune correspondance », ce qui envoie l'utilisateur
|
||||
* (et le support) chercher au mauvais endroit.
|
||||
*
|
||||
* Convention de nommage : `FF_YT`, `FF_DM`, `FF_TW`, `FF_PT`, `FF_OD`, `FF_RU`
|
||||
* (ids courts, cf. `provider-ids.ts`).
|
||||
*/
|
||||
|
||||
/** Valeurs explicitement fausses. Tout le reste est vrai (unset inclus). */
|
||||
const FALSY = new Set(['0', 'false', 'off', 'no', 'disabled']);
|
||||
|
||||
/**
|
||||
* Lit un flag depuis l'environnement, sans cache : le lire à chaque appel permet
|
||||
* de changer une variable sans redémarrer (utile en local, et ça évite un cache
|
||||
* qui masquerait un changement en CI).
|
||||
*
|
||||
* @param {string} key
|
||||
* @returns {{ set: boolean, enabled: boolean, raw: string|null }}
|
||||
*/
|
||||
function readFlag(key) {
|
||||
const raw = process.env[key];
|
||||
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
||||
// Unset = actif, et on le dit explicitement pour ne pas confondre
|
||||
// « pas configuré » et « désactivé » dans les logs.
|
||||
return { set: false, enabled: true, raw: null };
|
||||
}
|
||||
return { set: true, enabled: !FALSY.has(String(raw).trim().toLowerCase()), raw: String(raw) };
|
||||
}
|
||||
|
||||
/**
|
||||
* État d'un provider au regard des feature flags.
|
||||
* @param {string} providerId id court ('yt', 'ru', …)
|
||||
*/
|
||||
export function providerFlag(providerId) {
|
||||
const id = String(providerId || '').trim().toLowerCase();
|
||||
const key = `FF_${id.toUpperCase()}`;
|
||||
const { set, enabled, raw } = readFlag(key);
|
||||
return { provider: id, flag: key, set, enabled, raw };
|
||||
}
|
||||
|
||||
/** `true` si le provider est désactivé par un feature flag. */
|
||||
export function isProviderDisabled(providerId) {
|
||||
return !providerFlag(providerId).enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtre une liste de providers, en retirant les désactivés.
|
||||
*
|
||||
* @template {string} T
|
||||
* @param {T[]} providerIds
|
||||
* @returns {{ enabled: T[], disabled: Array<{provider: string, flag: string}> }}
|
||||
*/
|
||||
export function partitionEnabledProviders(providerIds) {
|
||||
const list = Array.isArray(providerIds) ? providerIds : [];
|
||||
const enabled = [];
|
||||
const disabled = [];
|
||||
for (const id of list) {
|
||||
const flag = providerFlag(id);
|
||||
if (flag.enabled) enabled.push(id);
|
||||
else disabled.push({ provider: flag.provider, flag: flag.flag });
|
||||
}
|
||||
return { enabled, disabled };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique les flags à une liste déjà validée, et renvoie les `errors` à fusionner
|
||||
* dans la réponse de fan-out. Utilisé par `/api/search` ET `/api/suggest` pour
|
||||
* qu'un provider désactivé se comporte pareil partout.
|
||||
*
|
||||
* @template {string} T
|
||||
* @param {T[]} providerIds
|
||||
* @returns {{ providerIds: T[], errors: Record<string, {message: string, code: string}> }}
|
||||
*/
|
||||
export function applyProviderFlags(providerIds) {
|
||||
const { enabled, disabled } = partitionEnabledProviders(providerIds);
|
||||
const errors = {};
|
||||
for (const d of disabled) {
|
||||
errors[d.provider] = {
|
||||
message: `Provider désactivé par le feature flag ${d.flag}`,
|
||||
code: 'disabled_by_ff',
|
||||
};
|
||||
}
|
||||
return { providerIds: enabled, errors };
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const handler = {
|
||||
s: q,
|
||||
size: perPage.toString(),
|
||||
from: ((pageNum - 1) * perPage).toString(),
|
||||
include: 'channel,thumbnail_url,title,description,duration,release_time,claimId,name',
|
||||
include: 'channel,thumbnail_url,title,description,duration,release_time,claimId,name,video',
|
||||
mediaType: 'video'
|
||||
});
|
||||
|
||||
@@ -44,6 +44,14 @@ const handler = {
|
||||
// lighthouse les expose — lecture défensive, aucun appel en plus.
|
||||
const vw = Number(item.video?.width ?? item.video?.video_width ?? item.width);
|
||||
const vh = Number(item.video?.height ?? item.video?.video_height ?? item.height);
|
||||
// Phase 1.2 - `release_time` est un timestamp unix (secondes) et etait
|
||||
// deja demande dans `include` mais jamais mappe : `publishedAt` etait donc
|
||||
// TOUJOURS undefined, ce qui rendait le filtre `period=` inoperant sur Odysee.
|
||||
const release = Number(item.release_time);
|
||||
// Phase 1.1 - `video.view_count` remonte par lighthouse quand `video`
|
||||
// est inclus. L'`effective_amount` est un montant LBC (bid), PAS un
|
||||
// nombre de vues : on ne l'utilise jamais comme compteur.
|
||||
const views = Number(item.video?.view_count ?? item.video?.views);
|
||||
|
||||
return {
|
||||
title: item.title || name,
|
||||
@@ -55,6 +63,10 @@ const handler = {
|
||||
duration: typeof item.duration === 'number' && item.duration > 0
|
||||
? Math.round(item.duration)
|
||||
: (typeof item.video?.duration === 'number' && item.video.duration > 0 ? Math.round(item.video.duration) : undefined),
|
||||
...(Number.isFinite(release) && release > 0
|
||||
? { publishedAt: new Date(release * 1000).toISOString() }
|
||||
: {}),
|
||||
...(Number.isFinite(views) && views >= 0 ? { views: Math.round(views) } : {}),
|
||||
...(Number.isFinite(vw) && vw > 0 ? { width: vw } : {}),
|
||||
...(Number.isFinite(vh) && vh > 0 ? { height: vh } : {}),
|
||||
};
|
||||
|
||||
@@ -58,8 +58,35 @@ const handler = {
|
||||
url: item.url,
|
||||
thumbnail,
|
||||
uploaderName: (item.account && (item.account.displayName || item.account.name)) || undefined,
|
||||
// Phase 1.8 - les avatars de compte ET de chaine sont exposes par
|
||||
// l'API sepiasearch mais n'etaient jamais mappes : la page chaine
|
||||
// affichait un avatar generique sur toutes les chaines PeerTube.
|
||||
uploaderAvatar: item.account?.avatars?.[0]?.path || item.account?.avatarUrl || undefined,
|
||||
channelAvatarUrl: item.channel?.avatars?.[0]?.path || item.channel?.avatarUrl || undefined,
|
||||
channelId: (item.channel && (item.channel.name || item.channel.uuid)) || (item.account && item.account.name) || undefined,
|
||||
// Phase 1.7 - champs deja presents dans la reponse, jamais mappes.
|
||||
// `views` est le nom canonique du contrat (les 5 autres adaptateurs
|
||||
// l'emettaient) ; `viewCount` reste en alias pour ne pas casser un
|
||||
// consommateur existant. Trouve en gelant la sortie reelle (phase 8.4) :
|
||||
// le front lisait `views`, le serveur n'envoyait que `viewCount`, et les
|
||||
// compteurs PeerTube n'atteignaient donc jamais l'UI.
|
||||
views: Number.isFinite(Number(item.views)) ? Number(item.views) : undefined,
|
||||
viewCount: Number.isFinite(Number(item.views)) ? Number(item.views) : undefined,
|
||||
likes: Number.isFinite(Number(item.likes)) ? Number(item.likes) : undefined,
|
||||
publishedAt: item.publishedAt || item.createdAt || undefined,
|
||||
// L'API PeerTube renvoie `language` comme OBJET `{ id, label }` (et non
|
||||
// `{ code }` comme le laissait croire l'ancienne garde) : sans ce
|
||||
// tri, l'objet entier partait dans le contrat, où `language` est
|
||||
// déclaré `string`. Le front recevait `{id: null, label: 'Unknown'}`
|
||||
// au lieu d'un code ISO. Vu en gelant la sortie réelle (phase 8.4).
|
||||
// `label` est volontairement exclu : c'est une étiquette lisible
|
||||
// ("Unknown") et non un code — la publier produirait une langue
|
||||
// inventée, ce que la règle « absent = undefined » interdit.
|
||||
language: typeof item.language === 'string' ? item.language
|
||||
: (item.language?.code || item.language?.id || undefined),
|
||||
hasSubtitles: Array.isArray(item.subtitleFiles) ? item.subtitleFiles.length > 0 : undefined,
|
||||
type: 'video',
|
||||
kind: 'vod',
|
||||
duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : undefined,
|
||||
...(fw > 0 ? { width: fw } : {}),
|
||||
...(fh > 0 ? { height: fh } : {}),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @typedef {Object} Suggestion
|
||||
* @property {string} title
|
||||
* @property {string} id
|
||||
* @property {number=} duration
|
||||
* @property {number=} duration (secondes ; > 0. NE JAMAIS 0 : absence = undefined)
|
||||
* @property {boolean=} isShort
|
||||
* @property {string=} url
|
||||
* @property {string=} thumbnail
|
||||
@@ -13,21 +13,103 @@
|
||||
* @property {string=} type
|
||||
* @property {number=} width (largeur vidéo en px, quand le provider l'expose)
|
||||
* @property {number=} height (hauteur vidéo en px, quand le provider l'expose)
|
||||
*
|
||||
* ── v2 (additif, phase 0 §2.4) ────────────────────────────────────────────────
|
||||
* Tous les champs ci-dessous sont OPTIONNELS et absents quand le provider ne les
|
||||
* fournit pas. `test:contract` (`server/tests/provider-contract.test.mjs`) les
|
||||
* vérifie à chaque build : c'est ce qui empêche le retour des champs «erts puis
|
||||
* perdus » (anomalies #3, #7).
|
||||
* Règle non négociable : une métadonnée absente reste `undefined`. Jamais de 0,
|
||||
* jamais de chaîne vide, jamais de date/devinette.
|
||||
*
|
||||
* @property {number=} views compteur de vues (> 0)
|
||||
* @property {number=} likes compteur de likes
|
||||
* @property {number=} dislikes
|
||||
* @property {string=} publishedAt ISO 8601 — jamais un libellé relatif
|
||||
* @property {string=} channelId id de chaîne chez le provider
|
||||
* @property {string=} channelExternalId
|
||||
* @property {string=} channelHandle
|
||||
* @property {string=} channelUrl
|
||||
* @property {string=} uploaderAvatar avatar de l'auteur
|
||||
* @property {string=} channelAvatarUrl
|
||||
* @property {string=} slug
|
||||
* @property {string=} channel
|
||||
* @property {string=} kind 'vod' | 'live' | 'clip' (Twitch)
|
||||
* @property {string=} game catégorie / jeu
|
||||
* @property {string=} language BCP-47
|
||||
* @property {boolean=} hasSubtitles
|
||||
* @property {boolean=} isLive
|
||||
* @property {boolean=} embeddable
|
||||
* @property {string=} description
|
||||
* @property {string[]} tags
|
||||
* @property {string=} durationRaw libellé brut, pour l'affichage d'incertitude
|
||||
* @property {string=} viewCountRaw
|
||||
* @property {{provider: ProviderId, scheme: string, value: string}=} channelRef
|
||||
* Phase 6 — identité de chaîne normalisée. Redondant avec
|
||||
* `channelExternalId` (transition non cassante) mais EXPLICITE : le
|
||||
* `scheme` dit comment lire `value` ('yt-uc' = `UC…`, 'pt-composite' =
|
||||
* `instance|channel`, 'od-claim' = claim LBRY…). Voir `channel-ref.mjs`.
|
||||
*/
|
||||
|
||||
/** Version du contrat `Suggestion` émise par `/api/search`. */
|
||||
export const SUGGESTION_CONTRACT_VERSION = 2;
|
||||
|
||||
/** Champs optionnels introduits en v2 (pour le test de contrat et le debug ?v=2). */
|
||||
export const SUGGESTION_V2_FIELDS = Object.freeze([
|
||||
'views', 'viewers', 'likes', 'dislikes', 'publishedAt', 'channelId', 'channelExternalId',
|
||||
'channelHandle', 'channelUrl', 'uploaderAvatar', 'channelAvatarUrl', 'slug',
|
||||
'channel', 'kind', 'game', 'language', 'hasSubtitles', 'isLive', 'embeddable',
|
||||
'description', 'tags', 'durationRaw', 'viewCountRaw', 'channelRef',
|
||||
// Phase 7.3 : provenance. `capturedAt` (epoch ms, instant réel de la collecte)
|
||||
// et `source` (`innertube` / `scrape` / `api` / `cache`) permettent d'afficher
|
||||
// « capturé il y a 4 min » au lieu d'un « à l'instant » trompeur sur un cache hit.
|
||||
'capturedAt', 'source',
|
||||
]);
|
||||
|
||||
/** @typedef {'yt'|'dm'|'tw'|'pt'|'od'|'ru'} ProviderId */
|
||||
|
||||
import channelRegistry from './channel-registry.mjs';
|
||||
import { channelMetaByProvider } from './channel-registry.mjs';
|
||||
import { channelContentByProvider } from './channel-content.mjs';
|
||||
import { withChannelRefs } from './channel-ref.mjs';
|
||||
import { hashSearchKey } from './youtube-common.mjs';
|
||||
|
||||
/**
|
||||
* Phase 8.1 — CONTRAT UNIQUE `ProviderAdapter`.
|
||||
*
|
||||
* Chaque provider est une extension, pas un câblage : l'objet exporté satisfait
|
||||
* intégralement cette forme (les membres optionnels absents sont simplement
|
||||
* `undefined`, mais la CLÉ existe), et tout consommateur (routes, wrappers,
|
||||
* tests de contrat) passe par `providerAdapters` — jamais par des tables
|
||||
* parallèles (`channelRegistry`, `channelContentByProvider`) qui restent des
|
||||
* implémentations internes.
|
||||
*
|
||||
* @typedef {Object} ProviderAdapter
|
||||
* @property {ProviderId} id
|
||||
* @property {string} label
|
||||
* @property {(q: string, opts: { limit: number, page?: number, sort?: string }) => Promise<Suggestion[]>} search
|
||||
* @property {(q: string, opts: { limit: number, page?: number, sort?: string, filters?: Object }) => Promise<Suggestion[]>} search
|
||||
* @property {(q: string, opts?: { limit?: number }) => Promise<string[]>} [suggest]
|
||||
* @property {(externalId: string, ctx?: any) => Promise<any=} } [fetchChannelById]
|
||||
* @property {(externalId: string, opts?: Object, ctx?: Object) => Promise<{ items: Suggestion[], nextPage?: number|null, total?: number|null, nextCursor?: string }>} channelContent
|
||||
* @property {(externalId: string, ctx?: Object) => Promise<Object>} channelMeta
|
||||
* @property {Object} capabilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Phase 8.1 — capacités déclarées par provider (source serveur du contrat).
|
||||
* Vérité comportementale : ce que le collecteur fait réellement (types de
|
||||
* contenu gérés, suggestion native, direct). Vérifié par le test de contrat.
|
||||
*/
|
||||
export const PROVIDER_CAPABILITIES = {
|
||||
yt: { suggest: true, live: false, channelMeta: true, channelContent: ['videos', 'shorts', 'playlists', 'live'] },
|
||||
dm: { suggest: true, live: false, channelMeta: true, channelContent: ['videos', 'playlists'] },
|
||||
tw: { suggest: false, live: true, channelMeta: true, channelContent: ['videos', 'live'] },
|
||||
pt: { suggest: false, live: false, channelMeta: true, channelContent: ['videos', 'playlists'] },
|
||||
od: { suggest: false, live: false, channelMeta: true, channelContent: ['videos'] },
|
||||
ru: { suggest: false, live: false, channelMeta: true, channelContent: ['videos'] },
|
||||
};
|
||||
|
||||
/** Ordre canonique des providers (un unique endroit, hors route). */
|
||||
export const PROVIDER_IDS = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
|
||||
|
||||
/** @type {Record<ProviderId, ProviderAdapter>} */
|
||||
export const providerRegistry = {
|
||||
/** @type {any} */ yt: (await import('./youtube.mjs')).default,
|
||||
@@ -39,10 +121,197 @@ export const providerRegistry = {
|
||||
};
|
||||
|
||||
for (const [pid, adapter] of Object.entries(providerRegistry)) {
|
||||
const channelAdapter = channelRegistry[/** @type {ProviderId} */(pid)];
|
||||
if (channelAdapter && typeof channelAdapter.fetchChannelById === 'function' && !adapter.fetchChannelById) {
|
||||
adapter.fetchChannelById = channelAdapter.fetchChannelById;
|
||||
const meta = channelMetaByProvider[/** @type {ProviderId} */(pid)];
|
||||
if (meta && !adapter.fetchChannelById) adapter.fetchChannelById = meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 4.1 — cache de recherche générique, appliqué ici plutôt que dans chaque
|
||||
* adaptateur : un seul point d'entrée, donc aucun fournisseur ne peut l'oublier
|
||||
* (c'est exactement le problème des tables dupliquées que la phase 0 a supprimé).
|
||||
*
|
||||
* YouTube est **exclu** : `youtube.mjs` a son propre cache à deux niveaux
|
||||
* (memoire + SQLite) et l'annoit en `source` different selon la voie utilisee
|
||||
* (`innertube` / `scrape` / `api`). Un second cache ici le neutraliserait et
|
||||
* masquerait les fallbacks dans les metriques.
|
||||
*
|
||||
* Le cache ne doit jamais faire echouer une recherche : toute erreur de base est
|
||||
* absorbee et la requete amont est relancee.
|
||||
*/
|
||||
const CACHE_WRAPPED_PROVIDERS = new Set(['dm', 'tw', 'pt', 'od', 'ru']);
|
||||
|
||||
/**
|
||||
* Phase 7.3 — provenance (`capturedAt` / `source`) de chaque suggestion.
|
||||
*
|
||||
* Posée ici, au registre, et pas dans les 6 adaptateurs : c'est le même
|
||||
* raisonnement que le cache et que `channelRef` (un seul point d'entrée, donc
|
||||
* aucun fournisseur ne peut l'oublier) — et surtout c'est le seul endroit où
|
||||
* l'on sait si la réponse vient du réseau ou d'une entrée de cache.
|
||||
*
|
||||
* `source` distingue les voies de collecte réelles : `innertube` / `scrape` /
|
||||
* `api` côté YouTube (déjà posés par `youtube.mjs`), `cache` pour un hit.
|
||||
*
|
||||
* Exportée pour être testée directement : ses règles d'honnêteté (un `0` n'est
|
||||
* pas une date, une source vide n'est pas une source) sont le cœur de la
|
||||
* tâche, et il est impossible de les atteindre par le HTTP sans écrire un
|
||||
* adaptateur factice qui court-circuiterait justement le wrapper.
|
||||
*
|
||||
* @param {any[]} items
|
||||
* @param {string} source voie de collecte à appliquer si l'item n'en porte pas
|
||||
* @param {number} [capturedAt] instant de capture (défaut : maintenant)
|
||||
* @returns {any[]} les mêmes items, stampés sur place
|
||||
*/
|
||||
export function stampProvenance(items, source, capturedAt) {
|
||||
if (!Array.isArray(items)) return items;
|
||||
const at = Number.isFinite(Number(capturedAt)) && Number(capturedAt) > 0
|
||||
? Number(capturedAt)
|
||||
: Date.now();
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
// Un adaptateur qui connaît SA propre voie de collecte (YouTube) reste
|
||||
// prioritaire : on ne réécrit pas `innertube` en `api`.
|
||||
//
|
||||
// `||` seul ne suffit PAS : `-1`, `NaN` ou `" "` sont « présents » au sens
|
||||
// de `||` pour les uns et rejettent pour les autres, et finissent affichés
|
||||
// comme une date absurde (« il y a -1 s »). On teste donc la valeur.
|
||||
const own = Number(item.capturedAt);
|
||||
item.capturedAt = Number.isFinite(own) && own > 0 ? own : at;
|
||||
const ownSource = String(item.source || '').trim();
|
||||
item.source = ownSource || source;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Voie de collecte annoncée par l'adaptateur, sinon `api` (appel officiel). */
|
||||
const DEFAULT_LIVE_SOURCE = 'api';
|
||||
|
||||
async function withSearchCache(adapter, providerId, q, opts) {
|
||||
const { getCachedSearch, setCachedSearch } = await import('../db.mjs').catch(() => ({}));
|
||||
if (!getCachedSearch || !setCachedSearch) return adapter.search(q, opts);
|
||||
|
||||
const limit = Math.min(Math.max(1, Number(opts?.limit || 10)), 100);
|
||||
const page = Math.max(1, Number(opts?.page || 1));
|
||||
const sort = String(opts?.sort || 'relevance');
|
||||
// La signature des filtres entre dans la cle : deux recherches identiques avec
|
||||
// des filtres differents ne doivent pas se partager de cache (meme regle que YT).
|
||||
const filterSig = opts?.filters ? JSON.stringify(opts.filters, Object.keys(opts.filters).sort()) : '';
|
||||
const key = `${providerId}|${hashSearchKey(`${String(q).toLowerCase().trim()}|${limit}|${page}|${sort}|${filterSig}`)}`;
|
||||
|
||||
const cached = getCachedSearch(providerId, key);
|
||||
if (cached?.items) {
|
||||
// Phase 7.3 : la provenance voyage DANS le payload mis en cache, donc un
|
||||
// hit restitue spontanément la VRAIE voie de collecte (`api`, `innertube`…)
|
||||
// et l'instant réel de capture — pas `Date.now()`, qui ferait croire à une
|
||||
// fraîcheur de façade sur une réponse vieille de 4 min. `stampProvenance`
|
||||
// ne remplit donc que ce qui manque : une ligne écrite par un serveur plus
|
||||
// ancien (avant la phase 7.3) récupère ici son `createdAt` en base.
|
||||
stampProvenance(cached.items, cached.source || DEFAULT_LIVE_SOURCE, cached.createdAt);
|
||||
return cached.items;
|
||||
}
|
||||
|
||||
// Phase 4.3 : on ne compte que les appels **amont** reels, pas les hits cache
|
||||
// (sinon le taux d'echec apparent disparaitrait sous l'effet du cache).
|
||||
const t0 = Date.now();
|
||||
const { incProviderMetrics } = await import('../db.mjs').catch(() => ({}));
|
||||
try {
|
||||
const items = await adapter.search(q, opts);
|
||||
incProviderMetrics?.(providerId, { ok: true, latencyMs: Date.now() - t0 });
|
||||
// Phase 7.3 : la provenance est posée AVANT la mise en cache, pour qu'un hit
|
||||
// ultérieur la transporte sans avoir à la deviner.
|
||||
stampProvenance(items, DEFAULT_LIVE_SOURCE);
|
||||
// Jamais de resultat vide persiste (regle phase 2.2 / 4.1) : une page vide
|
||||
// transitoire ne doit pas bloquer les requetes suivantes pendant 5 min.
|
||||
if (Array.isArray(items) && items.length > 0) setCachedSearch(providerId, key, q, items, 'api');
|
||||
return items;
|
||||
} catch (e) {
|
||||
incProviderMetrics?.(providerId, { ok: false, latencyMs: Date.now() - t0, error: e?.message || e });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
for (const pid of CACHE_WRAPPED_PROVIDERS) {
|
||||
const adapter = providerRegistry[/** @type {ProviderId} */(pid)];
|
||||
if (!adapter || typeof adapter.search !== 'function') continue;
|
||||
const raw = adapter.search.bind(adapter);
|
||||
adapter.search = (q, opts) => withSearchCache({ search: raw }, pid, q, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 6.2 — `channelRef` sur les 6 providers.
|
||||
*
|
||||
* Volontairement appliqué ICI, au niveau du registre, et pas dans les 6
|
||||
* adaptateurs : c'est le même raisonnement que le cache (un seul point d'entrée,
|
||||
* donc aucun fournisseur ne peut l'oublier). Il est posé **au-dessus** du cache
|
||||
* pour que la réponse soit identique que les items viennent du réseau ou d'une
|
||||
* entrée de cache écrite par une version antérieure (qui n'avait pas le champ).
|
||||
*
|
||||
* Les champs legacy (`channelExternalId`…) sont laissés intacts : c'est une
|
||||
* transition non cassante, pas un remplacement.
|
||||
*/
|
||||
for (const pid of /** @type {ProviderId[]} */(['yt', 'dm', 'tw', 'pt', 'od', 'ru'])) {
|
||||
const adapter = providerRegistry[pid];
|
||||
if (!adapter || typeof adapter.search !== 'function') continue;
|
||||
const raw = adapter.search.bind(adapter);
|
||||
adapter.search = async (q, opts) => withChannelRefs(pid, await raw(q, opts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 7.3 — `capturedAt` / `source` sur les 6 providers, y compris YouTube
|
||||
* qui est EXCLU du cache (il a son propre cache à deux niveaux) : sans cette
|
||||
* passe, YouTube n'aurait jamais de provenance — et c'est précisément le
|
||||
* provider dont la voie de collecte (`innertube` vs `scrape` vs `api`) est la
|
||||
* plus utile à afficher.
|
||||
*
|
||||
* Posée AU-DESSUS de `withChannelRefs` : la provenance décrit la donnée
|
||||
* collectée, pas sa forme normalisée.
|
||||
*/
|
||||
for (const pid of /** @type {ProviderId[]} */(['yt', 'dm', 'tw', 'pt', 'od', 'ru'])) {
|
||||
const adapter = providerRegistry[pid];
|
||||
if (!adapter || typeof adapter.search !== 'function') continue;
|
||||
const raw = adapter.search.bind(adapter);
|
||||
adapter.search = async (q, opts) => {
|
||||
const items = await raw(q, opts);
|
||||
// Remplissage seulement : les items déjà stampés par le cache ou par
|
||||
// l'adaptateur (YouTube connaît sa voie) gardent leur valeur d'origine.
|
||||
if (Array.isArray(items) && items.length > 0) stampProvenance(items, DEFAULT_LIVE_SOURCE);
|
||||
return items;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 8.1 — l'adaptateur UNIFIÉ. `providerAdapters[pid]` EST le point de
|
||||
* contact unique (recherche déjà enveloppée cache → channelRef → provenance)
|
||||
* enrichi des deux volets chaîne (contenu / métadonnées) et de ses capacités.
|
||||
* Positionné APRÈS les wrappers : les consommateurs (routes, tests de contrat)
|
||||
* traitent tous par cet accès, jamais par les tables internes.
|
||||
*
|
||||
* `channelContent` délègue au collecteur de phase : la signature est
|
||||
* `(externalId, opts, ctx)`, `ctx.searchRegistry` étant utilisé par `ru`.
|
||||
*/
|
||||
export const providerAdapters = /** @type {Record<ProviderId, ProviderAdapter>} */ (Object.fromEntries(
|
||||
PROVIDER_IDS.map((pid) => {
|
||||
const mod = providerRegistry[pid];
|
||||
const content = channelContentByProvider[pid];
|
||||
const meta = channelMetaByProvider[pid];
|
||||
return [pid, {
|
||||
id: pid,
|
||||
label: mod?.label || pid,
|
||||
search: mod?.search,
|
||||
suggest: typeof mod?.suggest === 'function' ? mod.suggest.bind(mod) : undefined,
|
||||
channelContent: content
|
||||
? (externalId, opts, ctx) => content(externalId, opts || {}, ctx || {})
|
||||
: undefined,
|
||||
channelMeta: meta
|
||||
? (externalId, ctx) => meta(externalId, ctx || {})
|
||||
: undefined,
|
||||
capabilities: PROVIDER_CAPABILITIES[pid],
|
||||
}];
|
||||
}),
|
||||
));
|
||||
|
||||
/** Point d'accès unique d'un adaptateur par id (compat : un seul chemin). */
|
||||
export function getProviderAdapter(provider) {
|
||||
return providerAdapters[/** @type {ProviderId} */(provider)] || providerRegistry[/** @type {ProviderId} */(provider)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+185
-3
@@ -132,6 +132,39 @@ function isChallenge(html) {
|
||||
return /Just a moment|challenge-platform|cf-chl/i.test(String(html || '').slice(0, 4000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3.3 — cache négatif court.
|
||||
* Sans lui, chaque recherche Rumble déclenchait 2 fetch Node + 2 fetch Python
|
||||
* (12-20 s) pour finir sur un challenge Cloudflare, à chaque frappe utilisateur.
|
||||
* TTL volontairement court (5 min) : un challenge se lève vite, et on ne veut
|
||||
* pas figer une panne de plus de quelques minutes.
|
||||
* @type {Map<string, number>}
|
||||
*/
|
||||
const NEGATIVE_CACHE_TTL_MS = Number(process.env.RUMBLE_NEGATIVE_CACHE_TTL_MS || 5 * 60 * 1000);
|
||||
const negativeCache = new Map();
|
||||
|
||||
function negativeCacheKey(q, page) { return `search:vide:${page}:${String(q || '').trim().toLowerCase()}`; }
|
||||
|
||||
function isNegativelyCached(key) {
|
||||
const ts = negativeCache.get(key);
|
||||
if (!ts) return false;
|
||||
if ((Date.now() - ts) >= NEGATIVE_CACHE_TTL_MS) { negativeCache.delete(key); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function markNegative(key) {
|
||||
// Garde-fou mémoire : le cache ne doit pas grossir sans borne.
|
||||
if (negativeCache.size >= 200) {
|
||||
for (const [k, ts] of negativeCache) {
|
||||
if ((Date.now() - ts) >= NEGATIVE_CACHE_TTL_MS) negativeCache.delete(k);
|
||||
if (negativeCache.size < 150) break;
|
||||
}
|
||||
}
|
||||
negativeCache.set(key, Date.now());
|
||||
}
|
||||
|
||||
export function resetRumbleNegativeCache() { negativeCache.clear(); }
|
||||
|
||||
/* --------------------------------- parsing -------------------------------- */
|
||||
|
||||
function parseDurationToSeconds(raw) {
|
||||
@@ -180,8 +213,101 @@ function normalizeThumb(raw) {
|
||||
return t.startsWith('//') ? `https:${t}` : t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compteur de vues localized : "1,2K views", "1.2M views", "3 456", "12,345".
|
||||
* L'ancien code faisait `.replace(/[^\d]/g, '')` : le suffixe disparaissait et
|
||||
* "1,2K" devenait 12 (au lieu de 1 200). Les vues étaient donc affichées
|
||||
* fausses sur la quasi-totalité des cartes Rumble au-delà de 999.
|
||||
*/
|
||||
export function parseRumbleViews(raw) {
|
||||
const s = String(raw ?? '').trim();
|
||||
if (!s) return undefined;
|
||||
const m = s.match(/([\d][\d\s\u00a0.,]*)\s*([KMBkmb])?/);
|
||||
if (!m) return undefined;
|
||||
// 1 234 -> "1234" ; 1,2 / 1.2 -> "1.2" (décimal) ; 1,234 -> "1234" (milliers)
|
||||
let digits = m[1].replace(/[\s\u00a0]/g, '');
|
||||
if (/^\d{1,3},\d{3}$/.test(digits) || /^\d{1,3}\.\d{3}$/.test(digits)) digits = digits.replace(/[.,]/g, '');
|
||||
else digits = digits.replace(',', '.');
|
||||
const num = Number(digits);
|
||||
if (!Number.isFinite(num) || num < 0) return undefined;
|
||||
const suffix = (m[2] || '').toLowerCase();
|
||||
const mult = suffix === 'b' ? 1e9 : suffix === 'm' ? 1e6 : suffix === 'k' ? 1e3 : 1;
|
||||
const total = Math.round(num * mult);
|
||||
return total > 0 ? total : undefined;
|
||||
}
|
||||
|
||||
/** Clé de rapprochement stable : dernier segment d'URL, sans extension ni tracking. */
|
||||
function urlKey(u) {
|
||||
const s = String(u || '').split('?')[0].replace(/\.html$/i, '').replace(/\/$/, '');
|
||||
return (s.split('/').filter(Boolean).pop() || '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3.1 — extraction du JSON-LD (`application/ld+json`).
|
||||
* Plus stable que le DOM : quand le balisage change, le JSON-LD reste. On
|
||||
* accepte `VideoObject`, `ItemList` et `@graph`, et on n'indexe QUE ce qui est
|
||||
* explicitement présent (aucune valeur déduite).
|
||||
* @returns {Map<string, { publishedAt?: string, views?: number, thumbnail?: string, duration?: number, channelId?: string }>}
|
||||
*/
|
||||
export function parseJsonLd(html) {
|
||||
/** @type {Map<string, any>} */
|
||||
const out = new Map();
|
||||
const re = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
||||
let m;
|
||||
while ((m = re.exec(String(html || ''))) !== null) {
|
||||
let data;
|
||||
try { data = JSON.parse(m[1].trim()); } catch { continue; }
|
||||
const nodes = Array.isArray(data) ? data
|
||||
: Array.isArray(data?.['@graph']) ? data['@graph']
|
||||
: data ? [data] : [];
|
||||
for (const node of nodes) visit(node, out, 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function visit(node, out, depth) {
|
||||
if (!node || typeof node !== 'object' || depth > 4) return;
|
||||
const list = Array.isArray(node['@graph'])
|
||||
? node['@graph']
|
||||
: (Array.isArray(node.itemListElement) ? node.itemListElement.map((e) => e?.item ?? e) : null);
|
||||
if (list) { for (const c of list) visit(c, out, depth + 1); }
|
||||
|
||||
const type = String(node['@type'] || '').toLowerCase();
|
||||
if (!type.includes('video')) return;
|
||||
const key = urlKey(node.url || node.embedUrl || node.contentUrl || node['@id']);
|
||||
if (!key) return;
|
||||
|
||||
const stats = node.interactionStatistic;
|
||||
const statList = Array.isArray(stats) ? stats : (stats ? [stats] : []);
|
||||
let views;
|
||||
for (const st of statList) {
|
||||
const t = String(st?.interactionType || '').toLowerCase();
|
||||
if (!t.includes('watch') && !t.includes('view')) continue;
|
||||
const n = Number(st?.userInteractionCount);
|
||||
if (Number.isFinite(n) && n >= 0) { views = Math.round(n); break; }
|
||||
}
|
||||
|
||||
const dateRaw = node.uploadDate || node.datePublished;
|
||||
const parsed = dateRaw ? Date.parse(dateRaw) : NaN;
|
||||
const publishedAt = Number.isFinite(parsed) && parsed > 0 ? new Date(parsed).toISOString() : undefined;
|
||||
const duration = parseDurationToSeconds(node.duration);
|
||||
const thumbRaw = Array.isArray(node.thumbnailUrl)
|
||||
? node.thumbnailUrl[0]
|
||||
: (typeof node.thumbnailUrl === 'string' ? node.thumbnailUrl : node.thumbnailUrl?.url);
|
||||
const authorUrl = typeof node.author === 'string' ? node.author : node.author?.url;
|
||||
const channelId = (String(authorUrl || '').match(/\/c\/([^/?#]+)/i)?.[1] || '').trim() || undefined;
|
||||
|
||||
out.set(key, {
|
||||
...(publishedAt ? { publishedAt } : {}),
|
||||
...(views !== undefined && views > 0 ? { views } : {}),
|
||||
...(duration !== undefined ? { duration } : {}),
|
||||
...(thumbRaw ? { thumbnail: normalizeThumb(thumbRaw) } : {}),
|
||||
...(channelId ? { channelId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Classic parser: li.video-listing-entry cards. */
|
||||
function parseSearchHtml(html, { limit = 50 } = {}) {
|
||||
export function parseSearchHtml(html, { limit = 50 } = {}) {
|
||||
const $ = load(html);
|
||||
const items = [];
|
||||
$('li.video-listing-entry').each((_idx, el) => {
|
||||
@@ -220,13 +346,35 @@ function parseSearchHtml(html, { limit = 50 } = {}) {
|
||||
if (typeof parsed === 'number' && parsed > 0) { durationSeconds = parsed; break; }
|
||||
}
|
||||
const viewsText = $el.find('.video-item--views').first().text().trim();
|
||||
const views = Number(String(viewsText).replace(/[^\d]/g, '')) || undefined;
|
||||
const views = parseRumbleViews(viewsText);
|
||||
// Phase 1.4 - date de publication. L'attribut `datetime` du <time> est la
|
||||
// vraie date ISO ; son texte est un libelle relatif ("2 months ago") que
|
||||
// Date.parse() ne sait pas lire, d'ou l'absence historique de publishedAt.
|
||||
const timeEl = $el.find('.video-item--meta time, time.video-item--date, time').first();
|
||||
const publishedRaw = (timeEl.attr('datetime') || timeEl.attr('data-datetime') || '').trim();
|
||||
const publishedMs = publishedRaw ? Date.parse(publishedRaw) : NaN;
|
||||
const publishedAt = Number.isFinite(publishedMs) && publishedMs > 0
|
||||
? new Date(publishedMs).toISOString()
|
||||
: undefined;
|
||||
// Phase 1.5 - identifiant de chaine. L'URL porte soit /c/<username>/,
|
||||
// soit (en fallback) le sous-domaine ; l'avatar est sur l'image du by-line.
|
||||
const channelLink = $el.find('a.video-item--channel-link, a[href*="/c/"]').first();
|
||||
const channelHref = (channelLink.attr('href') || '').trim();
|
||||
const channelId = (channelHref.match(/\/c\/([^/?#]+)/i)?.[1] || '').trim() || undefined;
|
||||
const avatarEl = $el.find('img.video-item--channel-thumb, .video-item--by-line img, a.video-item--channel-link img').first();
|
||||
const uploaderAvatar = normalizeThumb(avatarEl.attr('src') || avatarEl.attr('data-src') || '');
|
||||
items.push({
|
||||
title: title || url,
|
||||
id,
|
||||
url,
|
||||
thumbnail: normalizeThumb(rawThumbnail),
|
||||
uploaderName: uploaderName || undefined,
|
||||
// Phase 1.4 / 1.5 / 1.6 - les trois champs etaient presents dans le HTML
|
||||
// mais jamais extraits, ce qui vidait la page chaine de sa date, de son
|
||||
// lien "voir la chaine" et de son avatar.
|
||||
publishedAt,
|
||||
channelId,
|
||||
uploaderAvatar,
|
||||
views,
|
||||
type: 'video',
|
||||
duration: durationSeconds,
|
||||
@@ -234,6 +382,23 @@ function parseSearchHtml(html, { limit = 50 } = {}) {
|
||||
// durée ≤75 s) tranche. Le scraper HTML n'expose pas de dimensions.
|
||||
});
|
||||
});
|
||||
// Phase 3.1 — surcouche JSON-LD : complète ce que le DOM n'expose pas
|
||||
// (date de publication, vues, auteur) SANS jamais écraser une valeur réellement
|
||||
// présente dans la liste. Le DOM reste la source primaire, le JSON-LD le filet.
|
||||
try {
|
||||
const ld = parseJsonLd(html);
|
||||
if (ld.size > 0) {
|
||||
for (const it of items) {
|
||||
const patch = ld.get(urlKey(it.url)) || ld.get(urlKey(it.id));
|
||||
if (!patch) continue;
|
||||
if (it.publishedAt === undefined && patch.publishedAt) it.publishedAt = patch.publishedAt;
|
||||
if (it.views === undefined && patch.views) it.views = patch.views;
|
||||
if (it.duration === undefined && patch.duration) it.duration = patch.duration;
|
||||
if (it.thumbnail === undefined && patch.thumbnail) it.thumbnail = patch.thumbnail;
|
||||
if (it.channelId === undefined && patch.channelId) it.channelId = patch.channelId;
|
||||
}
|
||||
}
|
||||
} catch { /* la surcouche est un bonus, jamais un motif d'échec */ }
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -254,6 +419,16 @@ const handler = {
|
||||
const query = String(q || '').trim();
|
||||
if (!query) return [];
|
||||
|
||||
// Phase 3.3 — short-circuit si l'échec vient d'être constaté.
|
||||
const cacheKey = negativeCacheKey(query, pageNum);
|
||||
if (isNegativelyCached(cacheKey)) {
|
||||
// Phase 3.4 — un échec est une ERREUR, pas un résultat vide : le front
|
||||
// peut ainsi afficher un bandeau au lieu d'un « aucun résultat » trompeur.
|
||||
throw Object.assign(new Error('rumble_cloudflare_challenge'), {
|
||||
code: 'rumble_cloudflare_challenge', rumbleChallenged: true,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Attempt 1: canonical search page (Node fetch, then python/curl_cffi) ---
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
@@ -276,7 +451,14 @@ const handler = {
|
||||
}
|
||||
} catch { /* give up */ }
|
||||
|
||||
return [];
|
||||
// Phase 3.4 — on distingue « échec » de « vide ». Une recherche Rumble qui
|
||||
// renvoie 0 résultat à travers deux chemins est presque toujours un blocage
|
||||
// Cloudflare, pas une absence de contenu : on le remonte, on met en cache
|
||||
// négatif, et le groupe ne passe pas pour un recherche normale sans résultat.
|
||||
markNegative(cacheKey);
|
||||
throw Object.assign(new Error('rumble_unavailable'), {
|
||||
code: 'rumble_unavailable', rumbleChallenged: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+112
-72
@@ -172,7 +172,10 @@ function mapStream(s) {
|
||||
uploaderAvatar: thumbStream(s.thumbnail_url, 70, 70),
|
||||
type: 'live',
|
||||
isLive: true,
|
||||
views: typeof s.viewer_count === 'number' ? s.viewer_count : undefined,
|
||||
// Phase 3.8 / 7.3 : `viewer_count` n'est PAS un compteur de vues. Il est
|
||||
// expose dans `viewers` (joueurs en direct). Le mettre dans `views` faisait
|
||||
// afficher "1 234 vues" sur un live qui n'a jamais ete vu.
|
||||
viewers: typeof s.viewer_count === 'number' ? s.viewer_count : undefined,
|
||||
publishedAt: s.started_at || undefined,
|
||||
game: s.game_name || undefined,
|
||||
language: s.language || undefined,
|
||||
@@ -336,6 +339,29 @@ const handler = {
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 24)), 50);
|
||||
const targetPage = Math.max(1, Number(page || 1));
|
||||
|
||||
// Phase 3.10 — budget de section configurable.
|
||||
//
|
||||
// Helix compte 800 points/minute : chaque recherche Twitch déclenche une
|
||||
// dizaine d'appels, et le budget influe directement sur le nombre de
|
||||
// requêtes Helix (une requête = 1 point, `first` ne change pas le coût).
|
||||
// Le défaut reprend EXACTEMENT l'historique (`max(4, ceil(perPage/3))` et
|
||||
// `max(6, ceil(perPage/2))` pour les VODs) : sans variable d'environnement,
|
||||
// le comportement ne bouge pas d'un iota.
|
||||
//
|
||||
// `TWITCH_SECTION_BUDGET` = budget uniforme en résultats (une seule valeur,
|
||||
// lisible) ; les budgets par section restent réglables séparément.
|
||||
const budgetOverride = Number(process.env.TWITCH_SECTION_BUDGET);
|
||||
const budgetLives = Number.isFinite(budgetOverride) && budgetOverride > 0
|
||||
? Math.floor(budgetOverride)
|
||||
: Math.max(4, Math.ceil(perPage / 3));
|
||||
const budgetVods = Number.isFinite(budgetOverride) && budgetOverride > 0
|
||||
? Math.floor(budgetOverride)
|
||||
: Math.max(6, Math.ceil(perPage / 2));
|
||||
// `first` par broadcaster : borné par Helix (100) et par le budget, sinon
|
||||
// on paie le transport pour des résultats que l'on jetterait au budget.
|
||||
const vodFirst = Math.min(100, Math.max(1, budgetVods));
|
||||
const clipFirst = Math.min(100, Math.max(1, budgetLives));
|
||||
|
||||
// 1) Chaînes (paginées : la pagination repose sur ce curseur).
|
||||
// Helix ne matche pas les longues phrases : on cherche la requête
|
||||
// complète puis chaque mot-clé (ex. "cooking" pour "cooking food...").
|
||||
@@ -436,87 +462,101 @@ const handler = {
|
||||
// Tri des lives : vues décroissantes quand sort=views, sinon pertinence (ordre Helix).
|
||||
if (sort === 'views') lives = [...lives].sort((a, b) => Number(b.views || 0) - Number(a.views || 0));
|
||||
|
||||
// 3) VODs : par broadcaster (pertinence requête) + par jeu (thèmes).
|
||||
// 3) + 4) VODs et Clips EN PARALLÈLE (phase 3.9).
|
||||
//
|
||||
// Le plan evaluait un risque de « casser l'ordre des sections » : il n'y a
|
||||
// pas de risque ici, parce que les deux blocs écrivent dans deux variables
|
||||
// distinctes et que l'ordre final est explicite à l'assemblage
|
||||
// (`[...takeLives, ...takeVods, ...takeClips, ...takeChannels]`). Les
|
||||
// sections restent donc rendues dans le même ordre, mais on économise une
|
||||
// latence complète de Helix : ces blocs font chacun 5-6 requêtes, et
|
||||
// Helix compte 800 points/min — le gaspillage était mesurable.
|
||||
const vodSort = sort === 'views' ? 'views' : sort === 'date' ? 'time' : 'trending';
|
||||
let vods = [];
|
||||
try {
|
||||
const perUser = userIds.slice(0, 6);
|
||||
const lists = await Promise.all(
|
||||
perUser.map(async (uid) => {
|
||||
const clipSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
/** Ajoute `extra` à `list` sans doublon (le set est reconstruit à chaque fois : listes courtes). */
|
||||
const mergeUnique = (list, extra) => {
|
||||
const seen = new Set(list.map((v) => String(v.id)));
|
||||
for (const v of extra) {
|
||||
if (!seen.has(String(v.id))) { seen.add(String(v.id)); list.push(v); }
|
||||
}
|
||||
return list;
|
||||
};
|
||||
|
||||
const loadVods = async () => {
|
||||
let vods = [];
|
||||
try {
|
||||
const perUser = userIds.slice(0, 6);
|
||||
const lists = await Promise.all(
|
||||
perUser.map(async (uid) => {
|
||||
try {
|
||||
const data = await helixGet(
|
||||
'/videos',
|
||||
new URLSearchParams({ user_id: String(uid), first: String(vodFirst), type: 'archive', sort: vodSort }),
|
||||
auth,
|
||||
{ retries: 1 },
|
||||
);
|
||||
return Array.isArray(data?.data) ? data.data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
vods = lists.flat().map(mapVod);
|
||||
if (targetPage === 1 && categoryId) {
|
||||
try {
|
||||
const data = await helixGet(
|
||||
'/videos',
|
||||
new URLSearchParams({ user_id: String(uid), first: '4', type: 'archive', sort: vodSort }),
|
||||
new URLSearchParams({ game_id: String(categoryId), first: '12', sort: vodSort }),
|
||||
auth,
|
||||
{ retries: 1 },
|
||||
);
|
||||
return Array.isArray(data?.data) ? data.data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
vods = lists.flat().map(mapVod);
|
||||
if (targetPage === 1 && categoryId) {
|
||||
try {
|
||||
const data = await helixGet(
|
||||
'/videos',
|
||||
new URLSearchParams({ game_id: String(categoryId), first: '12', sort: vodSort }),
|
||||
auth,
|
||||
{ retries: 1 },
|
||||
);
|
||||
const extra = (Array.isArray(data?.data) ? data.data : []).map(mapVod);
|
||||
const seen = new Set(vods.map((v) => String(v.id)));
|
||||
for (const v of extra) {
|
||||
if (!seen.has(String(v.id))) {
|
||||
seen.add(String(v.id));
|
||||
vods.push(v);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
mergeUnique(vods, (Array.isArray(data?.data) ? data.data : []).map(mapVod));
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return vods;
|
||||
};
|
||||
|
||||
// 4) Clips : par broadcaster (30 derniers jours) + par jeu.
|
||||
let clips = [];
|
||||
try {
|
||||
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const perUser = userIds.slice(0, 5);
|
||||
const lists = await Promise.all(
|
||||
perUser.map(async (uid) => {
|
||||
const loadClips = async () => {
|
||||
let clips = [];
|
||||
try {
|
||||
const perUser = userIds.slice(0, 5);
|
||||
const lists = await Promise.all(
|
||||
perUser.map(async (uid) => {
|
||||
try {
|
||||
const data = await helixGet(
|
||||
'/clips',
|
||||
new URLSearchParams({ broadcaster_id: String(uid), first: String(clipFirst), started_at: clipSince }),
|
||||
auth,
|
||||
{ retries: 1 },
|
||||
);
|
||||
return Array.isArray(data?.data) ? data.data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
clips = lists.flat().map(mapClip);
|
||||
if (targetPage === 1 && categoryId) {
|
||||
try {
|
||||
const data = await helixGet(
|
||||
'/clips',
|
||||
new URLSearchParams({ broadcaster_id: String(uid), first: '3', started_at: since }),
|
||||
new URLSearchParams({ game_id: String(categoryId), first: '10', started_at: clipSince }),
|
||||
auth,
|
||||
{ retries: 1 },
|
||||
);
|
||||
return Array.isArray(data?.data) ? data.data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
clips = lists.flat().map(mapClip);
|
||||
if (targetPage === 1 && categoryId) {
|
||||
try {
|
||||
const data = await helixGet(
|
||||
'/clips',
|
||||
new URLSearchParams({ game_id: String(categoryId), first: '10', started_at: since }),
|
||||
auth,
|
||||
{ retries: 1 },
|
||||
);
|
||||
const extra = (Array.isArray(data?.data) ? data.data : []).map(mapClip);
|
||||
const seen = new Set(clips.map((v) => String(v.id)));
|
||||
for (const v of extra) {
|
||||
if (!seen.has(String(v.id))) {
|
||||
seen.add(String(v.id));
|
||||
clips.push(v);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
mergeUnique(clips, (Array.isArray(data?.data) ? data.data : []).map(mapClip));
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return clips;
|
||||
};
|
||||
|
||||
// `allSettled` et non `all` : chaque bloc avale déjà ses propres erreurs,
|
||||
// mais un rejet inattendu ne doit pas faire tomber la section voisine.
|
||||
const [vods, clips] = await Promise.allSettled([loadVods(), loadClips()])
|
||||
.then((rs) => rs.map((r) => (r.status === 'fulfilled' ? r.value : [])));
|
||||
|
||||
// 5) Assemblage façon directory : lives, vidéos, clips, chaînes (hors-ligne).
|
||||
const channels = channelsData.map(mapChannel);
|
||||
@@ -535,10 +575,10 @@ const handler = {
|
||||
}
|
||||
|
||||
// Budgets : on remplit chaque section sans noyer les autres.
|
||||
const takeLives = allLives.slice(0, Math.max(4, Math.ceil(perPage / 3)));
|
||||
const takeVods = vods.slice(0, Math.max(6, Math.ceil(perPage / 2)));
|
||||
const takeClips = clips.slice(0, Math.max(4, Math.ceil(perPage / 3)));
|
||||
const takeChannels = offlineChannels.slice(0, Math.max(4, Math.ceil(perPage / 3)));
|
||||
const takeLives = allLives.slice(0, budgetLives);
|
||||
const takeVods = vods.slice(0, budgetVods);
|
||||
const takeClips = clips.slice(0, budgetLives);
|
||||
const takeChannels = offlineChannels.slice(0, budgetLives);
|
||||
|
||||
const merged = [...takeLives, ...takeVods, ...takeClips, ...takeChannels];
|
||||
// Déduplique par id (les lives login vs chaînes id numérique ne collisionnent pas).
|
||||
|
||||
@@ -47,6 +47,84 @@ export function getSearchMode() {
|
||||
return 'innertube-first';
|
||||
}
|
||||
|
||||
// --- Phase 4.4 : bascule automatique InnerTube -> scrape --------------------
|
||||
/**
|
||||
* InnerTube casse plus souvent que la Data API (changement de format, bot-check).
|
||||
* On mesure le taux d'echec InnerTube sur une fenetre glissante et on bascule
|
||||
* temporairement sur `scrape-first`, avec retour automatique et cooldown.
|
||||
*
|
||||
* Pourquoi cet etat est dans le process et pas en base : une bascule doit
|
||||
* survivre a un redemarrage (les bases survivent au redemarrage, l'etat non),
|
||||
* et `provider_metrics` est de toute facon purge au-dela de 24 h.
|
||||
*/
|
||||
const failover = {
|
||||
/** null = bascule inactive, sinon { until, previousMode, reason }. */
|
||||
active: null,
|
||||
lastToggleAt: 0,
|
||||
};
|
||||
|
||||
/** Seuil de declenchement, 0.2 = 20 % d'echecs InnerTube sur 1 h. */
|
||||
function failoverThreshold() {
|
||||
const v = Number(process.env.YT_INNERTUBE_AUTO_FAILOVER);
|
||||
return Number.isFinite(v) && v > 0 && v < 1 ? v : 0.2;
|
||||
}
|
||||
|
||||
/** Cooldown apres un retour au mode normal, pour eviter l'oscillation. */
|
||||
function failoverCooldownMs() {
|
||||
const v = Number(process.env.YT_INNERTUBE_FAILOVER_COOLDOWN_MS);
|
||||
return Number.isFinite(v) && v > 0 ? v : 15 * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* A appeler apres chaque tentative InnerTube.
|
||||
* @param {boolean} ok
|
||||
* @param {object} snapshot `providerMetricsSnapshot({ hours: 1 })`
|
||||
*/
|
||||
export function recordInnerTubeOutcome(ok, snapshot) {
|
||||
try {
|
||||
if (!ok) ytMetrics.innertubeErrors++;
|
||||
const now = Date.now();
|
||||
// Retour au mode normal des que le taux repasse sous le seuil, en respectant
|
||||
// le cooldown anti-oscillation.
|
||||
if (failover.active && now < failover.active.until) return failover.active;
|
||||
if (failover.active && now - failover.lastToggleAt < failoverCooldownMs()) return failover.active;
|
||||
if (!Array.isArray(snapshot) || snapshot.length === 0) return failover.active;
|
||||
const row = snapshot.find((r) => r.provider === 'yt');
|
||||
if (!row || !row.calls || row.calls < 3) return failover.active; // pas assez d'echantillons
|
||||
if (row.errorRate >= failoverThreshold()) {
|
||||
const previousMode = getSearchMode();
|
||||
failover.active = { until: now + failoverCooldownMs(), previousMode, reason: `errorRate=${row.errorRate.toFixed(2)} calls=${row.calls}` };
|
||||
failover.lastToggleAt = now;
|
||||
console.warn(`[YT failover] InnerTube dégradé (${failover.active.reason}) -> scrape-first pendant ${Math.round(failoverCooldownMs() / 60000)} min`);
|
||||
return failover.active;
|
||||
}
|
||||
if (failover.active) {
|
||||
console.log(`[YT failover] InnerTube rétabli (errorRate=${(row.errorRate || 0).toFixed(2)}) -> retour ${failover.active.previousMode}`);
|
||||
failover.active = null;
|
||||
failover.lastToggleAt = now;
|
||||
}
|
||||
return failover.active;
|
||||
} catch { return failover.active; }
|
||||
}
|
||||
|
||||
export function getFailoverState() {
|
||||
return failover.active ? { ...failover.active, mode: 'scrape-first' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode **effectif** : le mode configure, force sur `scrape-first` pendant un
|
||||
* failover actif. `getSearchMode()` reste la lecture de l'intention (env) ;
|
||||
* les appelants doivent utiliser cette fonction.
|
||||
*/
|
||||
export function getEffectiveSearchMode() {
|
||||
if (failover.active && Date.now() < failover.active.until) {
|
||||
const base = getSearchMode();
|
||||
// On n'ecrase que les modes qui dependent d'InnerTube.
|
||||
return base.startsWith('innertube') ? 'scrape-first' : base;
|
||||
}
|
||||
return getSearchMode();
|
||||
}
|
||||
|
||||
export function getScrapeTtlMs() {
|
||||
const v = Number(process.env.YT_SCRAPE_TTL_MS || 30 * 60 * 1000);
|
||||
return Number.isFinite(v) && v > 0 ? v : 30 * 60 * 1000;
|
||||
|
||||
@@ -75,6 +75,45 @@ export function parseDurationLabel(label) {
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
/** "il y a 2 mois" / "Streamed 3 weeks ago" / "2 days ago" -> ISO, ou undefined. */
|
||||
export function parseRelativeDate(text, nowMs = Date.now()) {
|
||||
try {
|
||||
const s = String(text || '').toLowerCase().trim();
|
||||
if (!s) return undefined;
|
||||
// Une date absolue ("17 janv. 2024", "2024-01-17") passe deja par Date.parse.
|
||||
if (/\d{4}-\d{2}-\d{2}/.test(s)) {
|
||||
const abs = Date.parse(s);
|
||||
if (Number.isFinite(abs) && abs > 0 && abs < 8.64e15) return new Date(abs).toISOString();
|
||||
}
|
||||
// Fenetre temporelle : YouTube annonce "il y a 2 heures", "3 weeks ago", ...
|
||||
const re = /(\d+)\s*(second|minute|hour|day|week|month|year|seconde|heure|jour|semaine|mois|an|annee|année)/;
|
||||
const m = s.match(re);
|
||||
if (!m) {
|
||||
// "hier" / "yesterday" / "aujourd'hui" / "today" : cas sans quantite.
|
||||
if (/\b(yesterday|hier)\b/.test(s)) return new Date(nowMs - 864e5).toISOString();
|
||||
if (/\b(today|aujourd'hui)\b/.test(s)) return new Date(nowMs).toISOString();
|
||||
if (/\bjust now|just now|maintenant|venir de\b/.test(s)) return new Date(nowMs).toISOString();
|
||||
return undefined;
|
||||
}
|
||||
const n = Math.max(0, Number(m[1]));
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
const unit = m[2];
|
||||
const table = {
|
||||
second: 1e3, seconde: 1e3,
|
||||
minute: 6e4,
|
||||
hour: 36e5, heure: 36e5,
|
||||
day: 864e5, jour: 864e5,
|
||||
week: 6048e5, semaine: 6048e5,
|
||||
month: 26298e6, mois: 26298e6,
|
||||
year: 315576e5, an: 315576e5, annee: 315576e5, année: 315576e5,
|
||||
};
|
||||
const mult = table[unit];
|
||||
if (!mult) return undefined;
|
||||
const ms = nowMs - (n * mult);
|
||||
return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : undefined;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
function bestThumb(thumbs) {
|
||||
try {
|
||||
const arr = Array.isArray(thumbs) ? thumbs.filter((t) => t?.url) : [];
|
||||
@@ -151,7 +190,12 @@ export function mapVideoNode(n) {
|
||||
type: 'video',
|
||||
...(Number.isFinite(duration) && duration > 0 ? { duration } : {}),
|
||||
...(views !== undefined ? { views } : {}),
|
||||
...(n.published?.text ? { publishedAt: String(n.published.text) } : {}),
|
||||
// Phase 1.9 - `n.published.text` est un LIBELLE RELATIF ("il y a 2 mois"),
|
||||
// que Date.parse() renvoie en NaN. On le convertit en ISO : sans cela le
|
||||
// filtre `period=week` etait inoperant sur YouTube et `sort=date` triait
|
||||
// sur NaN. Si le libelle est incomprehensible, publishedAt reste absent
|
||||
// (jamais de date inventee).
|
||||
...(n.published?.text ? { publishedAt: parseRelativeDate(n.published.text) } : {}),
|
||||
...(authorId ? { channelId: authorId, channelExternalId: authorId, channelUrl: `https://www.youtube.com/channel/${authorId}` } : {}),
|
||||
...(authorName ? { channelHandle: String(authorName) } : {}),
|
||||
...(n.is_live ? { isLive: true } : {}),
|
||||
|
||||
+116
-13
@@ -10,7 +10,7 @@
|
||||
* @property {string=} type
|
||||
*/
|
||||
import {
|
||||
getYouTubeKeys, isKeyFailure, getSearchMode, getScrapeTtlMs,
|
||||
getYouTubeKeys, isKeyFailure, getEffectiveSearchMode, getScrapeTtlMs,
|
||||
hashSearchKey, ytMetrics,
|
||||
} from './youtube-common.mjs';
|
||||
import { searchViaScrape } from './youtube-scrape.mjs';
|
||||
@@ -109,6 +109,24 @@ export function ytScrapeCacheStats() {
|
||||
return { memEntries: memCache.size, memMax: MEM_MAX };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3.6 — cache des `pageToken` YouTube.
|
||||
* Avant : atteindre la page N exigeait de rejouer les pages 1..N à chaque visite
|
||||
* (`while (currentPage <= targetPage)`), soit jusqu'à 100 unités de quota pour un
|
||||
* simple « page 5 ». La Data API est paginée par jeton, pas par offset : on mémorise
|
||||
* donc la chaîne de jetons et chaque page thereafter coûte 1 appel.
|
||||
* TTL 15 min, borné à 200 entrées par instance.
|
||||
* @type {Map<string, { ts: number, tokens: string[] }>}
|
||||
*/
|
||||
const PAGE_TOKEN_TTL_MS = 15 * 60 * 1000;
|
||||
const pageTokenCache = new Map();
|
||||
|
||||
function pageTokenCacheKey(q, order, perPage, extra) {
|
||||
return `ytpage|${hashSearchKey(`${String(q).toLowerCase().trim()}|${order}|${perPage}|${JSON.stringify(extra || {})}`)}`;
|
||||
}
|
||||
|
||||
export function clearYouTubePageTokenCache() { pageTokenCache.clear(); }
|
||||
|
||||
async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filters = null } = {}) {
|
||||
const keys = getYouTubeKeys();
|
||||
if (!keys.length) {
|
||||
@@ -121,23 +139,42 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filte
|
||||
const extra = apiSearchParams(filters);
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
||||
const targetPage = Math.max(1, Number(page || 1));
|
||||
let pageToken = '';
|
||||
let currentPage = 1;
|
||||
const cacheKey = pageTokenCacheKey(q, order, perPage, extra);
|
||||
|
||||
const hit = pageTokenCache.get(cacheKey);
|
||||
const entry = (hit && (Date.now() - hit.ts) < PAGE_TOKEN_TTL_MS) ? hit : null;
|
||||
if (!entry && hit) pageTokenCache.delete(cacheKey);
|
||||
if (pageTokenCache.size >= 200) {
|
||||
for (const [k, v] of pageTokenCache) {
|
||||
if ((Date.now() - v.ts) >= PAGE_TOKEN_TTL_MS) pageTokenCache.delete(k);
|
||||
if (pageTokenCache.size < 150) break;
|
||||
}
|
||||
}
|
||||
|
||||
/** `tokens[i]` = pageToken permettant de fetch la page i+2. Doit être contigu depuis 0. */
|
||||
const tokens = entry ? entry.tokens : [];
|
||||
const buildParams = (pageToken) => ({
|
||||
part: 'snippet', q, maxResults: String(perPage), order,
|
||||
videoEmbeddable: 'true', safeSearch: 'moderate',
|
||||
...extra,
|
||||
...(pageToken ? { pageToken } : {}),
|
||||
});
|
||||
|
||||
// Si le jeton de la page cible est déjà connu, on saute directement dessus :
|
||||
// 1 appel au lieu de N. Sinon on rejoue les pages manquantes à partir d'où
|
||||
// la chaîne s'arrête (jamais depuis le début si le cache est partiel).
|
||||
const startPage = Math.min(targetPage, tokens.length + 1);
|
||||
let pageToken = startPage >= 2 ? (tokens[startPage - 2] || '') : '';
|
||||
let lastItems = [];
|
||||
while (currentPage <= targetPage) {
|
||||
const params = {
|
||||
part: 'snippet', q, maxResults: String(perPage), order,
|
||||
videoEmbeddable: 'true', safeSearch: 'moderate',
|
||||
...extra,
|
||||
};
|
||||
if (pageToken) params.pageToken = pageToken;
|
||||
const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', params);
|
||||
for (let currentPage = startPage; currentPage <= targetPage; currentPage++) {
|
||||
const data = await ytFetchJson('https://www.googleapis.com/youtube/v3/search', buildParams(pageToken));
|
||||
if (currentPage === targetPage) { lastItems = Array.isArray(data.items) ? data.items : []; break; }
|
||||
const next = data.nextPageToken;
|
||||
if (!next) { lastItems = []; break; }
|
||||
tokens[currentPage - 1] = String(next);
|
||||
pageToken = String(next);
|
||||
currentPage++;
|
||||
}
|
||||
pageTokenCache.set(cacheKey, { ts: Date.now(), tokens });
|
||||
const videoIds = (lastItems || []).map((item) => item?.id?.videoId).filter(Boolean);
|
||||
const detailsMap = new Map();
|
||||
if (videoIds.length > 0) {
|
||||
@@ -150,6 +187,31 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filte
|
||||
console.warn('[YouTube] details fetch failed, continuing without durations:', e?.message || e);
|
||||
}
|
||||
}
|
||||
// Phase 1.7 - `search.list` ne renvoie QUE la vignette de la video, jamais
|
||||
// celle de la chaine. Un seul appel `channels.list` par page suffit ahydrater
|
||||
// `uploaderAvatar` pour tous les resultats. Degradation silencieuse : si aucune
|
||||
// cle n'est disponible ou si l'appel echoue, on rend la main sans avatar
|
||||
// (jamais d'image inventee).
|
||||
const channelAvatars = new Map();
|
||||
try {
|
||||
const channelIds = Array.from(new Set(
|
||||
(lastItems || [])
|
||||
.map((i) => i?.snippet?.channelId || i?.id?.channelId)
|
||||
.filter(Boolean),
|
||||
)).slice(0, 50);
|
||||
if (channelIds.length > 0) {
|
||||
const chData = await ytFetchJson('https://www.googleapis.com/youtube/v3/channels', {
|
||||
part: 'snippet', id: channelIds.join(','),
|
||||
});
|
||||
for (const ch of chData?.items || []) {
|
||||
const t = ch?.snippet?.thumbnails;
|
||||
const url = t?.high?.url || t?.medium?.url || t?.default?.url;
|
||||
if (ch?.id && url) channelAvatars.set(ch.id, url);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[YouTube] channel avatars fetch failed, continuing without:', e?.message || e);
|
||||
}
|
||||
return (lastItems || []).map((item) => {
|
||||
const videoId = item?.id?.videoId;
|
||||
// Une recherche de type `channel` renvoie des channelId, pas des videoId.
|
||||
@@ -169,8 +231,18 @@ async function searchViaApi(q, { limit = 10, page = 1, sort = 'relevance', filte
|
||||
? `https://www.youtube.com/channel/${rawId}`
|
||||
: `https://www.youtube.com/watch?v=${rawId}`,
|
||||
thumbnail: thumb, uploaderName: snippet.channelTitle || undefined,
|
||||
// Phase 1.7 - avatar de la chaine (absent = pas d'avatar, pas de repli casse).
|
||||
...(channelId && channelAvatars.get(channelId)
|
||||
? { uploaderAvatar: channelAvatars.get(channelId), channelAvatarUrl: channelAvatars.get(channelId) }
|
||||
: {}),
|
||||
type: isChannel ? 'channel' : 'video',
|
||||
duration: duration > 0 ? duration : undefined, views,
|
||||
// Phase 1.7bis - `likeCount` est gratuit sur le meme appel
|
||||
// `videos?part=statistics` que la duree : autant le remonter pendant qu'on y est.
|
||||
...(details?.statistics?.likeCount != null ? { likes: Number(details.statistics.likeCount) } : {}),
|
||||
// `liveBroadcastContent` vient du snippet de la recherche (live/upcoming/none).
|
||||
...(snippet?.liveBroadcastContent === 'live' ? { isLive: true, type: 'live' } : {}),
|
||||
...(snippet?.liveBroadcastContent === 'upcoming' && !isChannel ? { type: 'upcoming' } : {}),
|
||||
publishedAt: snippet.publishedAt || undefined, channelId,
|
||||
channelHandle: snippet.channelTitle || undefined, channelExternalId: channelId,
|
||||
channelUrl: channelId ? `https://www.youtube.com/channel/${channelId}` : undefined, embeddable,
|
||||
@@ -191,7 +263,11 @@ const handler = {
|
||||
label: 'YouTube',
|
||||
async search(q, opts) {
|
||||
const { limit = 10, page = 1, sort = 'relevance', filters = null } = opts || {};
|
||||
const mode = getSearchMode();
|
||||
// Phase 4.4 : on branche sur le mode **effectif** (eventuellement force sur
|
||||
// scrape-first par la bascule automatique), pas sur l'intention. La cle de
|
||||
// cache inclut ce mode : sinon une bascule ferait servir des resultats
|
||||
// InnerTube comme s'ils venaient du scrape.
|
||||
const mode = getEffectiveSearchMode();
|
||||
const ttl = getScrapeTtlMs();
|
||||
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
||||
// La signature des filtres entre dans la clé de cache : deux recherches
|
||||
@@ -224,6 +300,30 @@ const handler = {
|
||||
return items;
|
||||
};
|
||||
const t0 = Date.now();
|
||||
/**
|
||||
* Phase 4.4 — alimente la bascule automatique.
|
||||
*
|
||||
* Deux choses indispensables, et la premiere etait absente :
|
||||
* 1. l'appel InnerTube est un VRAI appel sortant, il doit etre mesure dans
|
||||
* `provider_metrics` sous le provider `yt` ;
|
||||
* 2. le snapshot est lu APRES cet increment, sinon la tentative courante
|
||||
* n'est pas comptee.
|
||||
* Sans (1), la ligne `yt` restait absente de `provider_metrics` (le registre
|
||||
* met en cache les autres fournisseurs, pas YouTube), donc
|
||||
* `recordInnerTubeOutcome` voyait toujours `calls === 0`, la condition
|
||||
* `row.calls < 3` sortait toujours, et le failover ne pouvait jamais
|
||||
* se declencher en production — uniquement dans les tests, qui injectaient
|
||||
* un snapshot synthetique.
|
||||
*/
|
||||
const noteInnerTube = async (ok, latencyMs, error) => {
|
||||
try {
|
||||
const [{ providerMetricsSnapshot, incProviderMetrics }, common] = await Promise.all([
|
||||
import('../db.mjs'), import('./youtube-common.mjs'),
|
||||
]);
|
||||
incProviderMetrics('yt', { ok, latencyMs, error: error || null });
|
||||
common.recordInnerTubeOutcome(ok, providerMetricsSnapshot({ hours: 1 }));
|
||||
} catch {}
|
||||
};
|
||||
const tryScrape = async () => {
|
||||
ytMetrics.scrapeCalls++;
|
||||
try { const { incYoutubeMetrics } = await import('../db.mjs'); incYoutubeMetrics({ scrapeCalls: 1 }); } catch {}
|
||||
@@ -292,14 +392,17 @@ const handler = {
|
||||
// Chaque couche ne fait jamais échouer la recherche à elle seule.
|
||||
const errors = [];
|
||||
try {
|
||||
const tIt = Date.now();
|
||||
const items = await tryInnerTube();
|
||||
log('innertube', items.length);
|
||||
await noteInnerTube(true, Date.now() - tIt);
|
||||
return persist(items, 'innertube');
|
||||
} catch (itErr) {
|
||||
ytMetrics.innertubeErrors = (ytMetrics.innertubeErrors || 0) + 1;
|
||||
ytMetrics.fallbacks++;
|
||||
errors.push(`innertube=${itErr?.message}`);
|
||||
console.warn(`[YT search] innertube failed (${itErr?.code || 'unknown'}), fallback to scrape`);
|
||||
await noteInnerTube(false, Date.now() - tIt, itErr?.code || itErr?.message);
|
||||
}
|
||||
try {
|
||||
const items = await tryScrape();
|
||||
|
||||
@@ -123,10 +123,14 @@ export function itemDurationSec(item) {
|
||||
if (s.includes(':')) {
|
||||
const parts = s.split(':').map((p) => Number(p));
|
||||
if (parts.length && parts.every((p) => Number.isFinite(p))) {
|
||||
return parts.reduce((acc, p) => acc * 60 + p, 0);
|
||||
const total = parts.reduce((acc, p) => acc * 60 + p, 0);
|
||||
if (total > 0) return total;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
// Phase 2.2 — « durée inconnue » vaut `undefined`, jamais `0`. Le 0 signifiait
|
||||
// « vidéo de 0 s » et rendait la règle « verticale sans durée => pas un short »
|
||||
// inopérante, tout en faisant afficher "0:00" sur les cartes.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function itemPublishedTs(item) {
|
||||
@@ -191,7 +195,13 @@ export function isShortItem(item, providerId) {
|
||||
// font ≤ 60 s par construction, une durée > 75 s sur un clip est du bruit
|
||||
// de métadonnées (le test historique « shorts keeps clips » l'impose).
|
||||
if (kind === 'clip') return true;
|
||||
if (item.isShort === true || type === 'short') {
|
||||
// Règle (c) : `kind === 'clip'` ci-dessusprime sur `type: 'video'`. Sans cet
|
||||
// early return, un item `kind:'clip', type:'video'` (ce que produisait
|
||||
// `channel-content.mjs` pour les lives Twitch) retombait en règle 3/4.
|
||||
if (item.isShort === true || type === 'short' || String(item.url || '').includes('/shorts/')) {
|
||||
// Règle (a) : flag natif + durée INCONNUE => short. « Le provider a parlé »
|
||||
// prime quand rien ne le contredit. Règle (b) : un flag natif prime aussi
|
||||
// sur un 1:1 (repost Instagram/TikTok), mais JAMAIS sur la seule durée.
|
||||
if (hasKnownDuration && d > SHORT_MAX_SECONDS) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Phase 7.3 / 7.6 — transport de `/api/search`.
|
||||
*
|
||||
* Deux besoins nés dans la même route, regroupés parce que ce sont DEUX TRANSPORTS
|
||||
* du même contrat côté serveur :
|
||||
* - Phase 7.3 : le payload de diagnostic `?debug=1` (provenance + `raw` tronqué) ;
|
||||
* - Phase 7.6 : le media type `application/x-ndjson` du flux incrémental.
|
||||
*
|
||||
* Isolé dans son propre module (et non dans `index.mjs`) pour deux raisons :
|
||||
* - `index.mjs` fait 4 200+ lignes et démarre un serveur à l'import : la logique
|
||||
* de diagnostic ne peut donc pas être testée depuis le fichier d'entrée ;
|
||||
* - la troncature et la redaction sont une BARRIÈRE DE SÉCURITÉ. Elles
|
||||
* s'évaporent à la première refonte si elles vivent au milieu des routes.
|
||||
*
|
||||
* Chaîne du debug volontairement bornée : le but est de voir ce que le provider
|
||||
* a renvoyé et ce qui a survécu au mapping (pour comprendre un `views` manquant),
|
||||
* pas de transporter une réponse entière. Garde-fous : 2 items par provider,
|
||||
* 600 caractères de JSON, et suppression des clés d'habilitation.
|
||||
*/
|
||||
|
||||
export const DEBUG_RAW_MAX_CHARS = 600;
|
||||
export const DEBUG_RAW_ITEMS_PER_PROVIDER = 2;
|
||||
|
||||
/**
|
||||
* Phase 7.6 — media type du transport incrémental de `/api/search`.
|
||||
*
|
||||
* Exporté (et non écrit en littéral dans la route) parce que le client doit
|
||||
* négocier le MÊME type : un désaccord d'une lettre ferait retomber le front
|
||||
* silencieusement en mode atomique, sans le moindre message d'erreur — le pire
|
||||
* scénario possible pour une optimization de performance.
|
||||
*/
|
||||
export const APPLICATION_NDJSON = 'application/x-ndjson';
|
||||
|
||||
/**
|
||||
* Clés d'habilitation retirées du `raw`. Volontairement large : un `raw`
|
||||
* publié par erreur est journalisé en CI, donc en clair dans les artefacts de
|
||||
* build. Un faux positif coûte un `[redacted]` de plus dans un payload de debug.
|
||||
*/
|
||||
const DEBUG_SECRET_KEYS = /^(api[-_]?key|apikey|authorization|auth|token|access[-_]?token|refresh[-_]?token|cookie|set[-_]?cookie|password|passwd|secret|client[-_]?secret|private[-_]?key|signature)$/i;
|
||||
|
||||
/** Copie profonde superficiellement, valeurs sensibles remplacées. */
|
||||
export function redactDebugSecrets(value) {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (Array.isArray(value)) return value.map(redactDebugSecrets);
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
out[k] = DEBUG_SECRET_KEYS.test(k) ? '[redacted]' : redactDebugSecrets(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, any[]>} groups groupes de résultats par provider
|
||||
* @param {Record<string, {message?: string}>} [errors]
|
||||
*/
|
||||
export function buildDebugPayload(groups, errors) {
|
||||
const out = {};
|
||||
for (const [pid, items] of Object.entries(groups || {})) {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
const sample = list.slice(0, DEBUG_RAW_ITEMS_PER_PROVIDER).map((item) => {
|
||||
if (!item || typeof item !== 'object') return { value: item };
|
||||
const { raw, ...rest } = item;
|
||||
const base = {
|
||||
id: rest.id ?? null,
|
||||
source: rest.source ?? null,
|
||||
capturedAt: rest.capturedAt ?? null,
|
||||
// Les NOMS des champs mappés, pas seulement leur nombre : c'est ce qui
|
||||
// permet de distinguer « `views` absent » de « `views` nul ». Triés pour
|
||||
// que deux debugs successifs soient comparables.
|
||||
fields: Object.keys(rest)
|
||||
.filter((k) => k !== 'id' && k !== 'source' && k !== 'capturedAt')
|
||||
.sort(),
|
||||
};
|
||||
// `raw` n'est aujourd'hui produit par aucun adaptateur (ils normalisent
|
||||
// sans conserver la charge utile amont) : la branche reste pour le jour où
|
||||
// l'un d'eux l'expose, et `fields` prend le relais en attendant.
|
||||
if (raw === undefined) return base;
|
||||
let json;
|
||||
try { json = JSON.stringify(redactDebugSecrets(raw)); } catch { json = '[unserialisable]'; }
|
||||
return {
|
||||
...base,
|
||||
raw: json.length > DEBUG_RAW_MAX_CHARS
|
||||
? `${json.slice(0, DEBUG_RAW_MAX_CHARS)}… (${json.length} car.)`
|
||||
: json,
|
||||
};
|
||||
});
|
||||
out[pid] = {
|
||||
count: list.length,
|
||||
error: errors?.[pid]?.message ?? null,
|
||||
capturedAt: list.find((i) => i && i.capturedAt)?.capturedAt ?? null,
|
||||
source: list.find((i) => i && i.source)?.source ?? null,
|
||||
items: sample,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Phase 8.1 — CONTRAT UNIQUE `ProviderAdapter`.
|
||||
*
|
||||
* Chaque provider expose LA MÊME forme : `{ id, label, search, suggest?,
|
||||
* channelContent, channelMeta, capabilities }`. C'est le garde-fou du « un seul
|
||||
* fichier par provider devient l'unique point de contact » : si quelqu'un ajoute
|
||||
* un provider à la peine, ou casse un des 6 points de contact, la suite tombe.
|
||||
*
|
||||
* Aucun réseau n'est touché : la forme est vérifiée structurellement, et les
|
||||
* membres appelables restent appelables (une recherche réelle se fait dans les
|
||||
* suites des fixtures).
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { providerAdapters, providerRegistry, PROVIDER_CAPABILITIES, PROVIDER_IDS, getProviderAdapter } =
|
||||
await import('../providers/registry.mjs');
|
||||
|
||||
const REQUIRED = ['id', 'label', 'search', 'channelContent', 'channelMeta', 'capabilities'];
|
||||
|
||||
describe('Phase 8.1 — contrat unique ProviderAdapter', () => {
|
||||
it('les 6 providers sont présents et dans l\'ordre canonique', () => {
|
||||
assert.deepEqual(Object.keys(providerAdapters).sort(), ['dm', 'od', 'pt', 'ru', 'tw', 'yt']);
|
||||
assert.deepEqual(PROVIDER_IDS, ['yt', 'dm', 'tw', 'pt', 'od', 'ru']);
|
||||
});
|
||||
|
||||
for (const pid of PROVIDER_IDS) {
|
||||
it(`${pid}: forme complète du contrat`, () => {
|
||||
const a = providerAdapters[pid];
|
||||
assert.ok(a, 'adaptateur présent');
|
||||
assert.equal(a.id, pid, 'id aligné');
|
||||
assert.ok(String(a.label || '').length > 0, 'label non vide');
|
||||
for (const key of REQUIRED) {
|
||||
assert.ok(key in a, `clé requise \`${key}\` présente`);
|
||||
}
|
||||
assert.equal(typeof a.id, 'string', '`id` est une chaîne');
|
||||
assert.equal(typeof a.label, 'string', '`label` est une chaîne');
|
||||
assert.equal(typeof a.capabilities, 'object', '`capabilities` est un objet');
|
||||
for (const member of ['search', 'channelContent', 'channelMeta']) {
|
||||
assert.equal(typeof a[member], 'function', `\`${member}\` est appelable`);
|
||||
}
|
||||
if ('suggest' in a) {
|
||||
assert.ok(a.suggest === undefined || typeof a.suggest === 'function', '`suggest` est fonction ou undefined');
|
||||
}
|
||||
if ('fetchChannelById' in a) {
|
||||
assert.ok(a.fetchChannelById === undefined || typeof a.fetchChannelById === 'function',
|
||||
'`fetchChannelById` (compat) est fonction ou undefined');
|
||||
}
|
||||
if ('suggest' in a && a.suggest !== undefined) {
|
||||
assert.equal(typeof a.suggest, 'function', 'suggest est une fonction quand elle existe');
|
||||
}
|
||||
});
|
||||
|
||||
it(`${pid}: capabilities déclarées fidèles au comportement`, () => {
|
||||
const a = providerAdapters[pid];
|
||||
const caps = a.capabilities;
|
||||
assert.equal(caps, PROVIDER_CAPABILITIES[pid], 'la même instance de capacités que la constante');
|
||||
assert.equal(typeof caps.suggest, 'boolean', 'suggest: booléen');
|
||||
assert.equal(typeof caps.live, 'boolean', 'live: booléen');
|
||||
assert.equal(caps.channelMeta, true, 'chaque provider a des métadonnées de chaîne');
|
||||
assert.ok(Array.isArray(caps.channelContent) && caps.channelContent.length > 0, 'liste de types de contenu non vide');
|
||||
assert.ok(caps.channelContent.every((t) => ['videos', 'shorts', 'playlists', 'live'].includes(t)),
|
||||
'types de contenu dans le vocabulaire connu');
|
||||
assert.equal(caps.suggest, typeof a.suggest === 'function', 'suggest déclaré ⟺ suggest fourni');
|
||||
assert.equal(caps.live, pid === 'tw', 'seul Twitch est live (fait observable)');
|
||||
});
|
||||
|
||||
it(`${pid}: search du contrat EST la recherche enveloppée (cache → ref → provenance)`, () => {
|
||||
// Le point de contact unifié doit servir la version DÉJÀ enveloppée par le
|
||||
// registre (cache, channelRef, provenance) — pas une copie nue de module.
|
||||
assert.equal(providerAdapters[pid].search, providerRegistry[pid].search,
|
||||
'même fonction que le registre (les wrappers s\'appliquent en amont)');
|
||||
});
|
||||
|
||||
it(`${pid}: channelContent et channelMeta sont câblés (appelables, distincts)`, () => {
|
||||
const a = providerAdapters[pid];
|
||||
assert.equal(typeof a.channelContent, 'function', 'channelContent appelable');
|
||||
assert.equal(typeof a.channelMeta, 'function', 'channelMeta appelable');
|
||||
assert.notEqual(a.channelContent, a.channelMeta, 'deux responsabilités distinctes');
|
||||
// Le test de délégation se fait sans réseau : pour ru, ruContent passe par
|
||||
// `ctx.searchRegistry` — on peut donc l'observer offline (cf. plus bas).
|
||||
});
|
||||
}
|
||||
|
||||
it('ru: channelContent délègue au registre de recherche via le contexte (offline)', async () => {
|
||||
const a = providerAdapters.ru;
|
||||
const fakeItem = { id: 'r1', title: 'Rumble seed', channelId: 'peerless-canal', uploaderName: 'peerless' };
|
||||
const fakeRegistry = {
|
||||
ru: { search: async () => [fakeItem, { id: 'r2', title: 'autre channel', channelId: 'autre' }] },
|
||||
};
|
||||
const out = await a.channelContent('peerless-canal', { type: 'videos', limit: 5 }, { searchRegistry: fakeRegistry });
|
||||
assert.ok(Array.isArray(out.items), 'items normalisés');
|
||||
assert.equal(out.items.length, 1, 'filtre par channelId canonique');
|
||||
assert.equal(out.items[0].id, 'r1', 'l\'item retenu est le bon');
|
||||
});
|
||||
|
||||
it('getProviderAdapter est l\'accès unique (et retombe sur le registre)', () => {
|
||||
for (const pid of PROVIDER_IDS) {
|
||||
assert.equal(getProviderAdapter(pid), providerAdapters[pid], `${pid} via getProviderAdapter`);
|
||||
}
|
||||
assert.equal(getProviderAdapter('nope'), undefined, 'provider inconnu : pas de crash, undefined propre');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Phase 7.7 — bannières et descriptions de chaîne, les 6 fournisseurs.
|
||||
*
|
||||
* Aucun réseau : `globalThis.fetch` est stubé. On vérifie que
|
||||
* - chaque fournisseur expose `bannerUrl` et `description` quand la source les
|
||||
* donne (aucun appel réseau supplémentaire — YT compte ses appels),
|
||||
* - les URL non-http(s) sont rejetées (`javascript:` = vecteur XSS),
|
||||
* - la description est bornée (600 caractères, whitespace normalisé),
|
||||
* - les chaînes sans bannière restent `undefined` (on n'invente rien).
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
process.env.YOUTUBE_API_KEY = 'fake-key';
|
||||
process.env.TWITCH_CLIENT_ID = 'fake-client';
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-banners-'));
|
||||
process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'banners.db');
|
||||
|
||||
const { channelRegistry, setTwitchTokenProvider } = await import('../providers/channel-registry.mjs');
|
||||
setTwitchTokenProvider(async () => 'fake-token');
|
||||
const db = await import('../db.mjs');
|
||||
|
||||
const calls = [];
|
||||
function fakeResponse(body, { text = false } = {}) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
text: async () => (text ? body : JSON.stringify(body)),
|
||||
};
|
||||
}
|
||||
|
||||
function installFetch(routes) {
|
||||
const prev = globalThis.fetch;
|
||||
calls.length = 0;
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
calls.push({ url: String(url), opts });
|
||||
const match = routes.find(r => r.test(String(url)));
|
||||
if (!match) throw new Error(`network_not_stubbed: ${url}`);
|
||||
return match.respond(opts);
|
||||
};
|
||||
return () => { globalThis.fetch = prev; };
|
||||
}
|
||||
|
||||
test('7.7 — YouTube : bannière et description depuis le payload existing', async (t) => {
|
||||
const restore = installFetch([{
|
||||
test: u => u.startsWith('https://www.googleapis.com/youtube/v3/channels?'),
|
||||
respond: () => fakeResponse({
|
||||
items: [{
|
||||
snippet: {
|
||||
title: 'YT User',
|
||||
description: ' la belle description ',
|
||||
thumbnails: { high: { url: 'https://img/avatar.png' } },
|
||||
},
|
||||
statistics: { subscriberCount: '42' },
|
||||
brandingSettings: {
|
||||
image: { bannerImageUrl: 'https://img/banner.png' },
|
||||
channel: { customUrl: 'ytuser' },
|
||||
},
|
||||
}],
|
||||
}),
|
||||
}]);
|
||||
t.after(restore);
|
||||
|
||||
const meta = await channelRegistry.yt.fetchChannelById('UCxxxx');
|
||||
assert.equal(meta.bannerUrl, 'https://img/banner.png');
|
||||
assert.equal(meta.description, 'la belle description');
|
||||
// La bannière ne nécessite aucun appel de plus : un seul /channels.
|
||||
assert.equal(calls.filter(c => c.url.includes('googleapis.com')).length, 1);
|
||||
});
|
||||
|
||||
test('7.7 — YouTube : une URL non-http(s) est rejetée, pas propagee', async (t) => {
|
||||
const restore = installFetch([{
|
||||
test: u => u.startsWith('https://www.googleapis.com/youtube/v3/channels?'),
|
||||
respond: () => fakeResponse({
|
||||
items: [{
|
||||
snippet: { title: 'X', thumbnails: {} },
|
||||
brandingSettings: { image: { bannerImageUrl: 'javascript:alert(1)' } },
|
||||
}],
|
||||
}),
|
||||
}]);
|
||||
t.after(restore);
|
||||
|
||||
const meta = await channelRegistry.yt.fetchChannelById('UCxxxx');
|
||||
assert.equal(meta.bannerUrl, undefined, 'javascript: ne doit jamais ressortir');
|
||||
assert.equal(meta.description, undefined);
|
||||
});
|
||||
|
||||
test('7.7 — YouTube : description démesurée bornée à 600 caractères', async (t) => {
|
||||
const huge = 'x'.repeat(5000);
|
||||
const restore = installFetch([{
|
||||
test: u => u.startsWith('https://www.googleapis.com/youtube/v3/channels?'),
|
||||
respond: () => fakeResponse({
|
||||
items: [{
|
||||
snippet: { title: 'X', description: huge, thumbnails: {} },
|
||||
brandingSettings: {},
|
||||
}],
|
||||
}),
|
||||
}]);
|
||||
t.after(restore);
|
||||
|
||||
const meta = await channelRegistry.yt.fetchChannelById('UCxxxx');
|
||||
assert.ok(meta.description.endsWith('…'), 'tronquée avec ellipse');
|
||||
assert.equal(meta.description.length, 600);
|
||||
});
|
||||
|
||||
test('7.7 — Dailymotion : cover_url + description', async (t) => {
|
||||
const restore = installFetch([{
|
||||
test: u => u.startsWith('https://api.dailymotion.com/user/'),
|
||||
respond: () => fakeResponse({
|
||||
username: 'dmuser',
|
||||
screenname: 'DM User',
|
||||
avatar_720_url: 'https://img/avatar.png',
|
||||
cover_url: 'https://img/banner.png',
|
||||
description: 'desc dm',
|
||||
url: 'https://www.dailymotion.com/user/dmuser',
|
||||
followers_total: 7,
|
||||
verified: false,
|
||||
}),
|
||||
}]);
|
||||
t.after(restore);
|
||||
|
||||
const meta = await channelRegistry.dm.fetchChannelById('dmuser');
|
||||
assert.equal(meta.bannerUrl, 'https://img/banner.png');
|
||||
assert.equal(meta.description, 'desc dm');
|
||||
});
|
||||
|
||||
test('7.7 — Twitch : banner_image_url + description d\'Helix', async (t) => {
|
||||
const restore = installFetch([{
|
||||
test: u => u.startsWith('https://api.twitch.tv/helix/users?'),
|
||||
respond: () => fakeResponse({
|
||||
data: [{
|
||||
display_name: 'TW User',
|
||||
login: 'twuser',
|
||||
profile_image_url: 'https://img/avatar.png',
|
||||
banner_image_url: 'https://img/banner.png',
|
||||
description: 'desc tw',
|
||||
view_count: 12,
|
||||
}],
|
||||
}),
|
||||
}]);
|
||||
t.after(restore);
|
||||
|
||||
const meta = await channelRegistry.tw.fetchChannelById('twuser');
|
||||
assert.equal(meta.bannerUrl, 'https://img/banner.png');
|
||||
assert.equal(meta.description, 'desc tw');
|
||||
assert.equal(calls[0].opts.headers['Authorization'], 'Bearer fake-token');
|
||||
});
|
||||
|
||||
test('7.7 — Twitch : sans bannière Helix, rien d\'inventé', async (t) => {
|
||||
const restore = installFetch([{
|
||||
test: u => u.startsWith('https://api.twitch.tv/helix/users?'),
|
||||
respond: () => fakeResponse({ data: [{ display_name: 'TW', login: 'twuser', profile_image_url: 'https://img/a.png' }] }),
|
||||
}]);
|
||||
t.after(restore);
|
||||
|
||||
const meta = await channelRegistry.tw.fetchChannelById('twuser');
|
||||
assert.equal(meta.bannerUrl, undefined);
|
||||
assert.equal(meta.description, undefined);
|
||||
});
|
||||
|
||||
test('7.7 — PeerTube : banners (plus grande) + description', async (t) => {
|
||||
const restore = installFetch([{
|
||||
test: u => u.startsWith('https://peertube.example/api/v1/video-channels/'),
|
||||
respond: () => fakeResponse({
|
||||
displayName: 'PT User',
|
||||
name: 'chan',
|
||||
host: 'peertube.example',
|
||||
avatar: { path: '/a.png' },
|
||||
banners: [{ path: '/b1.png' }, { path: '/banner.png' }],
|
||||
description: 'desc pt',
|
||||
url: 'https://peertube.example/video-channels/chan',
|
||||
followersCount: 3,
|
||||
ownerAccount: { verified: true },
|
||||
}),
|
||||
}]);
|
||||
t.after(restore);
|
||||
|
||||
const meta = await channelRegistry.pt.fetchChannelById('peertube.example|chan');
|
||||
assert.equal(meta.bannerUrl, 'https://peertube.example/banner.png', 'la plus grande bannière');
|
||||
assert.equal(meta.description, 'desc pt');
|
||||
});
|
||||
|
||||
test('7.7 — PeerTube : repli sur `banner` si `banners` est vide', async (t) => {
|
||||
const restore = installFetch([{
|
||||
test: u => u.startsWith('https://peertube.example/api/v1/video-channels/'),
|
||||
respond: () => fakeResponse({
|
||||
displayName: 'PT User', name: 'chan', host: 'peertube.example',
|
||||
avatar: { path: '/a.png' }, banners: [], banner: { path: '/old.png' },
|
||||
}),
|
||||
}]);
|
||||
t.after(restore);
|
||||
|
||||
const meta = await channelRegistry.pt.fetchChannelById('peertube.example|chan');
|
||||
assert.equal(meta.bannerUrl, 'https://peertube.example/old.png');
|
||||
});
|
||||
|
||||
test('7.7 — Odysee : vignette élargie + description (pas de bannière LBRY)', async (t) => {
|
||||
const restore = installFetch([{
|
||||
test: u => u === 'https://api.na-backend.odysee.com/api/v1/proxy?m=resolve',
|
||||
respond: () => fakeResponse({
|
||||
result: {
|
||||
'@chan': {
|
||||
short_url: 'https://odysee.com/@chan',
|
||||
thumbnail: { url: 'https://cdn.odysee.com/thumb' },
|
||||
meta: { effective_amount: 1 },
|
||||
value: {
|
||||
title: 'OD User',
|
||||
description: 'desc od',
|
||||
thumbnail: { url: 'https://cdn.odysee.com/thumb' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}]);
|
||||
t.after(restore);
|
||||
|
||||
const meta = await channelRegistry.od.fetchChannelById('@chan');
|
||||
assert.equal(meta.bannerUrl, 'https://cdn.odysee.com/thumb?size=1200x600');
|
||||
assert.equal(meta.description, 'desc od');
|
||||
assert.equal(calls[0].opts.method, 'POST');
|
||||
});
|
||||
|
||||
test('7.7 — Rumble : og:description (attributs dans any order) sans appel de plus', async (t) => {
|
||||
const restore = installFetch([{
|
||||
test: u => u === 'https://rumble.com/ruuser',
|
||||
respond: () => fakeResponse(
|
||||
'<meta property="og:image" content="https://img/avatar.png">'
|
||||
+ '<meta content="desc ru" property="og:description">'
|
||||
+ '<title>RU User on Rumble</title>',
|
||||
{ text: true }
|
||||
),
|
||||
}]);
|
||||
t.after(restore);
|
||||
|
||||
const meta = await channelRegistry.ru.fetchChannelById('ruuser');
|
||||
assert.equal(meta.bannerUrl, undefined, 'pas de bannière Rumble dédiée');
|
||||
assert.equal(meta.description, 'desc ru');
|
||||
assert.equal(calls.length, 1, 'une seule requête HTML, pas de seconde pour le bandeau');
|
||||
});
|
||||
|
||||
test('7.7 — ensureChannelFresh persiste bannière + description en base (survit au process)', async (t) => {
|
||||
// Un « process B » qui ne passe plus par le fetch doit relire le bandeau et la
|
||||
// description écrits par le premier appel (exactement le trajet des 6/HEURES Tm).
|
||||
const first = await db.ensureChannelFresh('yt', 'UCpersist', async () => ({
|
||||
title: 'Persisté',
|
||||
avatarUrl: 'https://img/a.png',
|
||||
bannerUrl: ' https://img/banner.png ',
|
||||
description: ' une description ',
|
||||
url: 'https://www.youtube.com/channel/UCpersist',
|
||||
}));
|
||||
assert.equal(first.bannerUrl, 'https://img/banner.png');
|
||||
// Le stockage coupe les extrémités ; le repli des espaces internes est fait au
|
||||
// niveau du fetch (`safeMeta`/cleanDescription), pas en base.
|
||||
assert.equal(first.description, 'une description');
|
||||
|
||||
// Deuxième relevé dans le TTL : la ligne sert seule, sans re-fetch.
|
||||
const second = await db.ensureChannelFresh('yt', 'UCpersist', async () => { throw new Error('offline'); });
|
||||
assert.equal(second.bannerUrl, 'https://img/banner.png', 'le bandeau survit sans appel réseau');
|
||||
assert.equal(second.description, 'une description');
|
||||
});
|
||||
|
||||
test('7.7 — upsertChannelRow ne persiste jamais une URL non-http(s)', () => {
|
||||
const meta = db.upsertChannelRow({
|
||||
provider: 'yt', externalId: 'UCevil',
|
||||
bannerUrl: 'javascript:alert(1)', description: 'ok',
|
||||
});
|
||||
assert.equal(meta.bannerUrl, null, 'javascript: est rejeté à la persistance');
|
||||
assert.equal(meta.description, 'ok');
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
// Phase 6 §8.2 — identité de chaîne normalisée (`channelRef`).
|
||||
// Run with: npm run test:channelref
|
||||
//
|
||||
// 100 % offline. Vérifie :
|
||||
// (a) les 3 conventions historiques se reconvertissent SANS PERTE,
|
||||
// (b) `pt` et `od` produisent exactement la valeur que le front produisait hier
|
||||
// (critère d'acceptation 8.2) — implémentation historique rejouée ici,
|
||||
// (c) les listes de schemes serveur <-> front ne divergent pas,
|
||||
// (d) rien n'est inventé quand l'identifiant est absent.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
CHANNEL_REF_SCHEMES,
|
||||
buildChannelRef,
|
||||
buildPeerTubeComposite,
|
||||
withChannelRefs,
|
||||
parsePeerTubeComposite,
|
||||
normalizeOdyseeClaim,
|
||||
odyseeClaimToSlug,
|
||||
odyseeClaimToResolveArg,
|
||||
} from '../providers/channel-ref.mjs';
|
||||
import { SUGGESTION_V2_FIELDS } from '../providers/registry.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let passed = 0;
|
||||
function expect(cond, msg) { if (!cond) throw new Error(`Assertion failed: ${msg}`); }
|
||||
function eq(actual, expected, msg) {
|
||||
const a = JSON.stringify(actual); const b = JSON.stringify(expected);
|
||||
if (a !== b) throw new Error(`Assertion failed: ${msg} (expected ${b}, got ${a})`);
|
||||
}
|
||||
function ok(msg) { passed++; console.log(` V ${msg}`); }
|
||||
|
||||
// ---------- 6.1 Le contrat expose bien channelRef ----------
|
||||
expect(SUGGESTION_V2_FIELDS.includes('channelRef'), 'channelRef est déclaré dans le contrat v2');
|
||||
ok('channelRef est un champ du contrat Suggestion v2');
|
||||
|
||||
// ---------- 6.1 Un scheme par provider, pour les 6 ----------
|
||||
eq(Object.keys(CHANNEL_REF_SCHEMES).sort(), ['dm', 'od', 'pt', 'ru', 'tw', 'yt'], 'les 6 providers ont un scheme');
|
||||
eq(CHANNEL_REF_SCHEMES, {
|
||||
yt: 'yt-uc', dm: 'dm-user', tw: 'tw-login', pt: 'pt-composite', od: 'od-claim', ru: 'ru-slug',
|
||||
}, 'les schemes sont ceux du plan §8.1');
|
||||
ok('scheme unique et explicite pour les 6 providers');
|
||||
|
||||
// ---------- 8.2 (a) Les 3 conventions historiques, sans perte ----------
|
||||
|
||||
// (1) YouTube : `UC…`
|
||||
const ytRef = buildChannelRef('yt', { channelExternalId: 'UCabcdefghijklmnopqrstuv' });
|
||||
eq(ytRef, { provider: 'yt', scheme: 'yt-uc', value: 'UCabcdefghijklmnopqrstuv' }, 'yt : convention UC conservée');
|
||||
eq(ytRef.value, 'UCabcdefghijklmnopqrstuv', 'yt : la valeur est bit-pour-bit identique');
|
||||
ok('convention historique (1) YouTube `UC…` : sans perte');
|
||||
|
||||
// (2) PeerTube : `instance|channel`
|
||||
const ptRef = buildChannelRef('pt', { url: 'https://framatube.org/videos/watch/abc', channelId: 'toto' });
|
||||
eq(ptRef, { provider: 'pt', scheme: 'pt-composite', value: 'framatube.org|toto' }, 'pt : composite construit depuis l\'URL');
|
||||
eq(parsePeerTubeComposite(ptRef.value), { instance: 'framatube.org', channel: 'toto' }, 'pt : le composite se redécoupe');
|
||||
ok('convention historique (2) PeerTube `instance|channel` : sans perte');
|
||||
|
||||
// (3) Odysee : claim LBRY
|
||||
eq(buildChannelRef('od', { uploaderName: '@MaChaine#a' }),
|
||||
{ provider: 'od', scheme: 'od-claim', value: '@MaChaine#a' }, 'od : claim conservé tel quel');
|
||||
// Forme SANS `@` (arrivait par d'autres chemins) : la valeur canonique porte le `@`.
|
||||
eq(buildChannelRef('od', { uploaderName: 'MaChaine#a' }),
|
||||
{ provider: 'od', scheme: 'od-claim', value: '@MaChaine#a' }, 'od : le @ manquant est restauré');
|
||||
ok('convention historique (3) Odysee claim LBRY : sans perte, forme canonique unique');
|
||||
|
||||
// Odysee : les deux usages du claim (resolve vs URL) passent par la source unique.
|
||||
eq(odyseeClaimToResolveArg('MaChaine#a'), '@MaChaine#a', 'od : argument de resolve préfixé @');
|
||||
eq(odyseeClaimToResolveArg('@MaChaine#a'), '@MaChaine#a', 'od : déjà préfixé, inchange');
|
||||
eq(odyseeClaimToSlug('@MaChaine#a'), 'MaChaine#a', 'od : slug d\'URL sans @');
|
||||
eq(odyseeClaimToSlug('MaChaine#a'), 'MaChaine#a', 'od : slug d\'URL tolère l\'absence de @');
|
||||
eq(normalizeOdyseeClaim(normalizeOdyseeClaim('x')), '@x', 'od : normalisation idempotente (pas de @@)');
|
||||
ok('Odysee : un seul jeu de règles pour resolve et pour l\'URL');
|
||||
|
||||
// ---------- 8.2 (b) Parité stricte avec le front d'aujourd'hui ----------
|
||||
// On rejoue ICI les règles historiques écrites dans les adaptateurs front avant
|
||||
// la phase 6. Si le serveur produit autre chose, le front (qui préfère désormais
|
||||
// `channelRef`) afficherait un lien de chaîne différent d'avant : régression.
|
||||
|
||||
function frontPtExternalId(it) {
|
||||
let channelExternalId;
|
||||
try {
|
||||
const host = it?.url ? new URL(String(it.url)).hostname : '';
|
||||
if (host && it?.channelId) channelExternalId = `${host}|${it.channelId}`;
|
||||
} catch {}
|
||||
return channelExternalId;
|
||||
}
|
||||
function frontOdExternalId(it) { return it.uploaderName || undefined; }
|
||||
function frontYtExternalId(it) { return it.channelExternalId || it.channelId || undefined; }
|
||||
function frontDmExternalId(it) { return it.channelId || undefined; }
|
||||
function frontTwExternalId(it) { return it.channelExternalId || it.channelHandle || undefined; }
|
||||
|
||||
const fixtures = {
|
||||
pt: [
|
||||
{ url: 'https://framatube.org/videos/watch/abc', channelId: 'toto' },
|
||||
{ url: 'https://tube.example.org/w/xyz', channelId: 'channel-42' },
|
||||
{ url: 'https://miamutube.net/videos/watch/9', channelId: 'a.b_c' },
|
||||
// Cas dégradés : pas d'URL, pas de channelId → les deux côtés doivent ne rien inventer.
|
||||
{ url: 'https://x.org/w/1' },
|
||||
{ channelId: 'orphelin' },
|
||||
{ url: 'pas-une-url', channelId: 'c' },
|
||||
{},
|
||||
],
|
||||
od: [
|
||||
{ uploaderName: '@MaChaine#a' },
|
||||
{ uploaderName: '@1234' },
|
||||
{ uploaderName: '' },
|
||||
{ uploaderName: undefined },
|
||||
{},
|
||||
],
|
||||
yt: [
|
||||
{ channelExternalId: 'UC1' },
|
||||
{ channelId: 'UC2' },
|
||||
{ channelExternalId: 'UC3', channelId: 'autre' },
|
||||
{},
|
||||
],
|
||||
dm: [{ channelId: 'x1abc' }, { channelId: '42' }, {}],
|
||||
tw: [{ channelExternalId: 'streamer' }, { channelHandle: 'zz' }, { channelExternalId: 'a', channelHandle: 'b' }, {}],
|
||||
ru: [{ channelExternalId: 'some-channel' }, { channelId: 'slug2' }, {}],
|
||||
};
|
||||
|
||||
const fronts = { pt: frontPtExternalId, od: frontOdExternalId, yt: frontYtExternalId, dm: frontDmExternalId, tw: frontTwExternalId, ru: frontYtExternalId };
|
||||
for (const [pid, list] of Object.entries(fixtures)) {
|
||||
for (const it of list) {
|
||||
const ref = buildChannelRef(pid, it);
|
||||
const front = fronts[pid](it);
|
||||
if (!ref) {
|
||||
eq(front ?? undefined, undefined, `${pid} : le front ne produisait rien, le serveur non plus (${JSON.stringify(it)})`);
|
||||
continue;
|
||||
}
|
||||
// Odysee : le front d'hier renvoyait le claim BRUT. Il porte déjà `@` dans la
|
||||
// réponse LBRY, donc la valeur canonique doit lui être identique.
|
||||
expect(ref.value === front,
|
||||
`${pid} : valeur serveur ≠ front pour ${JSON.stringify(it)} (serveur ${ref.value}, front ${front})`);
|
||||
}
|
||||
}
|
||||
ok('pt / od / yt / dm / tw / ru : channelRef.value strictement identique au front d\'hier (33 fixtures)');
|
||||
|
||||
// Le cas dégénéré `od` sans `@` est le SEUL écart volontaire, et il est
|
||||
// idempotent : une 2ᵉ passe ne change plus rien.
|
||||
eq(buildChannelRef('od', { uploaderName: 'MaChaine#a' }).value, normalizeOdyseeClaim('MaChaine#a'),
|
||||
'od : l\'écart se stabilise après normalisation');
|
||||
ok('od : l\'écart de normalisation est stable (idempotent)');
|
||||
|
||||
// ---------- (c) Parité des schemes serveur <-> front ----------
|
||||
const tsPath = path.join(__dirname, '../../src/app/shared/providers/channel-ref.ts');
|
||||
const ts = fs.readFileSync(tsPath, 'utf8');
|
||||
const tsBlock = ts.match(/CHANNEL_REF_SCHEMES[^=]*=\s*\{([\s\S]*?)\}/);
|
||||
expect(!!tsBlock, 'le front déclare CHANNEL_REF_SCHEMES');
|
||||
const tsSchemes = {};
|
||||
for (const m of tsBlock[1].matchAll(/(\w+)\s*:\s*'([\w-]+)'/g)) tsSchemes[m[1]] = m[2];
|
||||
eq(tsSchemes, { ...CHANNEL_REF_SCHEMES }, 'les schemes front et serveur sont identiques');
|
||||
ok('parité serveur <-> front des schemes vérifiée sur la source TypeScript');
|
||||
|
||||
// Le type du contrat doit exposer les mêmes 6 schemes que l'implémentation.
|
||||
const union = ts.match(/type ChannelRefScheme\s*=\s*([^;]+);/);
|
||||
expect(!!union, 'ChannelRefScheme est une union de littéraux');
|
||||
const unionSchemes = [...union[1].matchAll(/'([\w-]+)'/g)].map((m) => m[1]).sort();
|
||||
eq(unionSchemes, Object.values(CHANNEL_REF_SCHEMES).sort(), 'l\'union TypeScript couvre les 6 schemes');
|
||||
ok('l\'union TypeScript `ChannelRefScheme` couvre exactement les 6 schemes');
|
||||
|
||||
// ---------- (d) Aucune donnée inventée ----------
|
||||
eq(buildChannelRef('yt', {}), undefined, 'aucun identifiant => pas de ref');
|
||||
eq(buildChannelRef('yt', null), undefined, 'item null => pas de ref');
|
||||
eq(buildChannelRef('xx', { channelId: 'a' }), undefined, 'provider inconnu => pas de ref');
|
||||
eq(buildChannelRef('pt', { url: 'https://x.org/w/1' }), undefined, 'pt sans channelId => pas de ref composite bancal');
|
||||
eq(buildChannelRef('od', { uploaderName: ' ' }), undefined, 'claim vide => pas de ref');
|
||||
const noRef = { title: 'T', id: '1' };
|
||||
withChannelRefs('yt', [noRef]);
|
||||
eq(Object.keys(noRef), ['title', 'id'], 'aucune clé channelRef ajoutée quand il n\'y a rien à dire');
|
||||
ok('rien n\'est inventé : pas de clé vide, pas de valeur devinée');
|
||||
|
||||
// ---------- withChannelRefs : pose le ref, ne casse rien ----------
|
||||
const items = withChannelRefs('yt', [
|
||||
{ id: 'a', channelExternalId: 'UCa' },
|
||||
{ id: 'b' },
|
||||
{ id: 'c', channelId: 'UCc' },
|
||||
]);
|
||||
eq(items[0].channelRef, { provider: 'yt', scheme: 'yt-uc', value: 'UCa' }, 'ref posé sur l\'item 1');
|
||||
eq('channelRef' in items[1], false, 'item sans identité : aucune clé ajoutée');
|
||||
eq(items[2].channelRef.value, 'UCc', 'ref posé depuis channelId');
|
||||
eq(withChannelRefs('yt', null), null, 'withChannelRefs tolère un non-tableau');
|
||||
ok('withChannelRefs annote sans muter la forme des items sans identité');
|
||||
|
||||
console.log(`\n channel-ref: ${passed} assertions OK`);
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Phase 8.2 / 8.3 — contrat `Suggestion` v2 strict + feature flags `FF_<PROVIDER>`.
|
||||
*
|
||||
* Le module front `src/app/search/search-contract.ts` est réellement *chargé*
|
||||
* (transpilé à la volée via esbuild) au lieu d'être relu par expression
|
||||
* régulière : une regex passerait au travers d'un bug de logique alors qu'elle
|
||||
* voit bien les mots-clés, ce qui est exactement le défaut qu'on traque ici.
|
||||
* esbuild est une dépendance d'@angular/build ; si elle disparaît, le test
|
||||
* échoue bruyamment plutôt que de devenir vert par accident.
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import {
|
||||
providerFlag, isProviderDisabled, partitionEnabledProviders, applyProviderFlags,
|
||||
} from '../providers/feature-flags.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
|
||||
/** Charge un module TypeScript du front dans le contexte node (esbuild). */
|
||||
async function loadTs(relPath) {
|
||||
const abs = path.join(ROOT, relPath);
|
||||
const esbuild = await import('esbuild');
|
||||
const { code } = esbuild.transformSync(fs.readFileSync(abs, 'utf8'), {
|
||||
loader: 'ts', format: 'esm', target: 'node20',
|
||||
});
|
||||
// `import type` est effacé par esbuild : le module n'a plus de dépendance.
|
||||
const url = 'data:text/javascript;base64,' + Buffer.from(code, 'utf8').toString('base64');
|
||||
return import(url);
|
||||
}
|
||||
|
||||
const contract = await loadTs('src/app/search/search-contract.ts');
|
||||
|
||||
/** Capture les console.warn pendant l'appel à `fn`. */
|
||||
async function captureWarnings(fn) {
|
||||
const original = console.warn;
|
||||
const lines = [];
|
||||
console.warn = (...a) => { lines.push(a.map(String).join(' ')); };
|
||||
try { return { value: await fn(), lines }; }
|
||||
finally { console.warn = original; }
|
||||
}
|
||||
|
||||
// --- 8.2 : contrat v2 strict ------------------------------------------------
|
||||
|
||||
test('8.2 — v2 est accepté sans avertissement', async () => {
|
||||
contract.resetContractWarningsForTests();
|
||||
const res = { v: 2, groups: { yt: [{ id: 'a' }] } };
|
||||
const { value, lines } = await captureWarnings(() => contract.readSearchGroup(res, 'yt'));
|
||||
assert.equal(value.legacyContract, false);
|
||||
assert.equal(value.contractVersion, 2);
|
||||
assert.equal(value.items.length, 1);
|
||||
assert.deepEqual(lines, [], 'un contrat à jour ne doit rien écrire dans la console');
|
||||
});
|
||||
|
||||
test('8.2 — v1 reste lisible (compat) mais est signalé', async () => {
|
||||
contract.resetContractWarningsForTests();
|
||||
const res = { v: 1, groups: { ru: [{ id: 'x' }, { id: 'y' }] } };
|
||||
const { value, lines } = await captureWarnings(() => contract.readSearchGroup(res, 'ru'));
|
||||
assert.equal(value.legacyContract, true, 'v1 doit être marqué legacy');
|
||||
assert.equal(value.items.length, 2, 'la compat ne doit pas perdre de résultats');
|
||||
assert.equal(lines.length, 1, 'un serveur obsolète doit être signalé');
|
||||
assert.match(lines[0], /contrat v1/);
|
||||
});
|
||||
|
||||
test('8.2 — version absente = legacy (le plus probable est un proxy mal configuré)', async () => {
|
||||
contract.resetContractWarningsForTests();
|
||||
const { value, lines } = await captureWarnings(() => contract.readSearchGroup({ groups: {} }, 'od'));
|
||||
assert.equal(value.legacyContract, true);
|
||||
assert.equal(value.contractVersion, null);
|
||||
assert.match(lines[0], /contrat v\?/);
|
||||
});
|
||||
|
||||
test('8.2 — l\'avertissement est émis une seule fois par process', async () => {
|
||||
contract.resetContractWarningsForTests();
|
||||
const first = await captureWarnings(() => contract.readSearchGroup({ v: 1, groups: {} }, 'yt'));
|
||||
const second = await captureWarnings(() => contract.readSearchGroup({ v: 1, groups: {} }, 'yt'));
|
||||
const third = await captureWarnings(() => contract.readSearchGroup({ v: 1, groups: {} }, 'dm'));
|
||||
assert.equal(first.lines.length, 1);
|
||||
assert.deepEqual(second.lines, [], 'une 2e recherche ne doit pas respammer');
|
||||
assert.deepEqual(third.lines, [], 'le décompte est global, pas par provider');
|
||||
});
|
||||
|
||||
test('8.2 — un groupe absent ou malformé donne un tableau vide, jamais un throw', async () => {
|
||||
contract.resetContractWarningsForTests();
|
||||
for (const res of [undefined, {}, { v: 2 }, { v: 2, groups: { yt: null } }, { v: 2, groups: { yt: 'nope' } }]) {
|
||||
const r = contract.readSearchGroup(res, 'yt');
|
||||
assert.deepEqual(r.items, [], `cas ${JSON.stringify(res)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('8.2 — l\'erreur serveur du provider est remontée', async () => {
|
||||
contract.resetContractWarningsForTests();
|
||||
const r = contract.readSearchGroup({ v: 2, groups: {}, errors: { pt: { message: 'upstream 503' } } }, 'pt');
|
||||
assert.equal(r.providerError, 'upstream 503');
|
||||
});
|
||||
|
||||
test('8.3 — un provider désactivé par FF n\'est PAS une erreur technique', async () => {
|
||||
contract.resetContractWarningsForTests();
|
||||
const r = contract.readSearchGroup(
|
||||
{ v: 2, groups: {}, errors: { ru: { message: 'Provider désactivé par le feature flag FF_RU', code: 'disabled_by_ff' } } },
|
||||
'ru',
|
||||
);
|
||||
assert.equal(r.providerError, undefined, 'sinon l\'UI afficherait une panne pour un arrêt volontaire');
|
||||
});
|
||||
|
||||
// --- 8.3 : feature flags ----------------------------------------------------
|
||||
|
||||
const withEnv = (vars, fn) => {
|
||||
const saved = {};
|
||||
for (const k of Object.keys(vars)) {
|
||||
saved[k] = process.env[k];
|
||||
// `process.env[k] = undefined` stocke la chaîne « undefined » : il faut
|
||||
// supprimer la clé pour truly simuler un flag absent.
|
||||
if (vars[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = vars[k];
|
||||
}
|
||||
try { return fn(); } finally {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test('8.3 — un flag absent laisse le provider actif (comportement historique)', () => {
|
||||
withEnv({ FF_RU: undefined }, () => {
|
||||
const f = providerFlag('ru');
|
||||
assert.equal(f.enabled, true);
|
||||
assert.equal(f.set, false, '`set` distingue « non configuré » de « désactivé »');
|
||||
assert.equal(f.flag, 'FF_RU');
|
||||
});
|
||||
});
|
||||
|
||||
test('8.3 — les fausses valeurs usuelles désactivent le provider', () => {
|
||||
for (const v of ['0', 'false', 'FALSE', 'off', 'no', 'disabled', ' 0 ', 'No']) {
|
||||
withEnv({ FF_RU: v }, () => {
|
||||
assert.equal(isProviderDisabled('ru'), true, `FF_RU=${JSON.stringify(v)} doit désactiver`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('8.3 — les vraies valeurs usuelles gardent le provider actif', () => {
|
||||
for (const v of ['1', 'true', 'TRUE', 'on', 'yes', 'enabled']) {
|
||||
withEnv({ FF_RU: v }, () => {
|
||||
assert.equal(isProviderDisabled('ru'), false, `FF_RU=${JSON.stringify(v)} doit rester actif`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('8.3 — une variable vide est « non configurée », pas « désactivée »', () => {
|
||||
// Piège classique en prod : un `FF_RU=` laissé vide dans un .env éteindrait
|
||||
// silencieusement tout le provider. On privilégie le comportement historique.
|
||||
withEnv({ FF_RU: ' ' }, () => {
|
||||
const f = providerFlag('ru');
|
||||
assert.equal(f.enabled, true);
|
||||
assert.equal(f.set, false);
|
||||
});
|
||||
});
|
||||
|
||||
test('8.3 — les flags sont lus à chaque appel (pas de cache figé)', () => {
|
||||
withEnv({ FF_RU: '1' }, () => assert.equal(isProviderDisabled('ru'), false));
|
||||
withEnv({ FF_RU: '0' }, () => assert.equal(isProviderDisabled('ru'), true));
|
||||
// Relu après bascule, sans redémarrage ni invalidation manuelle.
|
||||
withEnv({ FF_RU: '1' }, () => assert.equal(isProviderDisabled('ru'), false));
|
||||
});
|
||||
|
||||
test('8.3 — le flag est insensibilisé à la casse de l\'id provider', () => {
|
||||
withEnv({ FF_RU: '0' }, () => {
|
||||
for (const id of ['ru', 'RU', ' Ru ']) assert.equal(isProviderDisabled(id), true, `id=${id}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('8.3 — partition : seuls les ids valides survivent, et on sait lesquels tombent', () => {
|
||||
withEnv({ FF_RU: '0', FF_OD: '1' }, () => {
|
||||
const { enabled, disabled } = partitionEnabledProviders(['yt', 'ru', 'od', 'dm']);
|
||||
assert.deepEqual(enabled, ['yt', 'od', 'dm'], 'l\'ordre demandé est préservé');
|
||||
assert.deepEqual(disabled, [{ provider: 'ru', flag: 'FF_RU' }]);
|
||||
});
|
||||
});
|
||||
|
||||
test('8.3 — applyProviderFlags produit une erreur `disabled_by_ff` explicable', () => {
|
||||
withEnv({ FF_RU: 'off' }, () => {
|
||||
const { providerIds, errors } = applyProviderFlags(['yt', 'ru', 'tw']);
|
||||
assert.deepEqual(providerIds, ['yt', 'tw']);
|
||||
assert.equal(errors.ru.code, 'disabled_by_ff');
|
||||
assert.match(errors.ru.message, /FF_RU/, 'le message doit nommer le flag : c\'est la piste n°1 en prod');
|
||||
assert.equal(errors.yt, undefined, 'un provider actif ne doit pas produire d\'erreur');
|
||||
});
|
||||
});
|
||||
|
||||
test('8.3 — entrée non tableau : dégradation sûre', () => {
|
||||
withEnv({}, () => {
|
||||
for (const bad of [undefined, null, 'yt', 42]) {
|
||||
const { enabled, disabled } = partitionEnabledProviders(bad);
|
||||
assert.deepEqual(enabled, []);
|
||||
assert.deepEqual(disabled, []);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Phase 8.3 (intégration) — le câblage HTTP des feature flags.
|
||||
*
|
||||
* Les tests unitaires de `feature-flags.mjs` prouvent la sémantique des flags ;
|
||||
* ceux-ci prouvent qu'ils sont *branchés* : que le provider éteint n'est pas
|
||||
* appelé, qu'il est signalé dans la réponse, et que le diagnostic de santé ne le
|
||||
* sonde pas.
|
||||
*
|
||||
* Méthode : on démarre le serveur avec les SIX flags à `0`. Un fan-out normal
|
||||
* appellerait six upstreams ; ici il n'y a donc **aucun accès réseau**, ce qui
|
||||
* rend le test déterministe (et le prouve : voir l'assertion de durée).
|
||||
*/
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const ALL = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-ff-'));
|
||||
const PORT = await new Promise((resolve) => {
|
||||
const s = net.createServer();
|
||||
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
|
||||
});
|
||||
const base = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
const server = spawn(process.execPath, ['./server/index.mjs'], {
|
||||
cwd: path.resolve(import.meta.dirname, '..', '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(PORT),
|
||||
NEWTUBE_DB_FILE: path.join(tmpDir, 'ff.db'),
|
||||
JWT_SECRET: 'ff-test-secret',
|
||||
NODE_ENV: 'test',
|
||||
// Tous les providers éteints -> aucun appel upstream attendu.
|
||||
...Object.fromEntries(ALL.map((p) => [`FF_${p.toUpperCase()}`, '0'])),
|
||||
// Coupe aussi l'enrichissement web des suggestions (sinon : réseau).
|
||||
SUGGEST_WEB_ENABLED: '0',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let logs = '';
|
||||
server.stdout.on('data', (d) => { logs += d; });
|
||||
server.stderr.on('data', (d) => { logs += d; });
|
||||
|
||||
const J = async (url) => {
|
||||
const r = await fetch(url);
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
};
|
||||
|
||||
describe('Phase 8.3 — feature flags câblés sur le HTTP', () => {
|
||||
before(async () => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < 25000) {
|
||||
try { const r = await fetch(`${base}/api/health`); if (r.status < 500) return; } catch {}
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
assert.fail(`API ne démarre pas:\n${logs.slice(-3000)}`);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server.kill();
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
|
||||
it('/api/search exclut les providers éteints du fan-out et les signale', async () => {
|
||||
const t0 = Date.now();
|
||||
const { status, body } = await J(`${base}/api/search?q=test&providers=${ALL.join(',')}`);
|
||||
const elapsed = Date.now() - t0;
|
||||
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.v, 2, 'la version de contrat reste annoncée');
|
||||
assert.deepEqual(body.providers, [], 'aucun provider ne doit rester dans le fan-out');
|
||||
for (const p of ALL) {
|
||||
assert.deepEqual(body.groups[p], [], `${p} doit avoir un groupe vide et non absent`);
|
||||
assert.equal(body.errors[p].code, 'disabled_by_ff', `${p} doit être signalé disabled_by_ff`);
|
||||
assert.match(body.errors[p].message, new RegExp(`FF_${p.toUpperCase()}`), 'le message nomme le flag');
|
||||
}
|
||||
// Preuve indirecte qu'aucun upstream n'a été contacté : un fan-out réel
|
||||
// part en parallèle sur 6 services et dépasse largement ce seuil.
|
||||
assert.ok(elapsed < 2000, `réponse en ${elapsed}ms : un provider éteint est probablement encore appelé`);
|
||||
});
|
||||
|
||||
it('un provider éteint est absent de `providers` mais garde sa colonne', async () => {
|
||||
const { body } = await J(`${base}/api/search?q=test&providers=yt,ru,od`);
|
||||
assert.deepEqual(body.providers, [], 'tous les providers demandés sont éteints dans ce test');
|
||||
assert.ok('yt' in body.groups && 'ru' in body.groups && 'od' in body.groups,
|
||||
'le front doit pouvoir afficher une colonne pour chacun des providers demandés');
|
||||
});
|
||||
|
||||
it('/api/suggest applique les mêmes flags et signale la raison', async () => {
|
||||
const { status, body } = await J(`${base}/api/search/suggest?q=test&providers=${ALL.join(',')}`);
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.errors.ru.code, 'disabled_by_ff');
|
||||
for (const p of ALL) assert.deepEqual(body.groups[p], [], `${p} doit être vide`);
|
||||
});
|
||||
|
||||
it('/api/providers/health distingue « éteint » de « en panne »', async () => {
|
||||
const { body } = await J(`${base}/api/providers/health?provider=ru`);
|
||||
const ru = body.providers.ru;
|
||||
assert.equal(ru.disabled, true);
|
||||
assert.equal(ru.flag, 'FF_RU');
|
||||
assert.equal(ru.flagValue, '0');
|
||||
assert.equal(ru.lastError, 'disabled_by_ff');
|
||||
// Un provider éteint n'est pas « cassé » : ni erreur, ni latence mesurée.
|
||||
assert.equal(ru.consecutiveFailures, 0);
|
||||
assert.equal(ru.errorRate, 0);
|
||||
assert.equal(ru.latencyMs, 0);
|
||||
});
|
||||
});
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
{
|
||||
"_comment": "FIGÉ PAR `npm run fixtures:record`. Ne pas éditer à la main : régénérer.",
|
||||
"_contractVersion": 2,
|
||||
"_recordedAt": "2026-09-30T00:23:23.598Z",
|
||||
"_query": "tutorial",
|
||||
"providers": {
|
||||
"yt": {
|
||||
"items": [
|
||||
{
|
||||
"badges": [
|
||||
"Nouveau",
|
||||
"4K"
|
||||
],
|
||||
"channelExternalId": "UC9deX25Xus0U-Znt3_ILSpw",
|
||||
"channelHandle": "360Jeezy",
|
||||
"channelId": "UC9deX25Xus0U-Znt3_ILSpw",
|
||||
"channelRef": {
|
||||
"provider": "yt",
|
||||
"scheme": "yt-uc",
|
||||
"value": "UC9deX25Xus0U-Znt3_ILSpw"
|
||||
},
|
||||
"channelUrl": "https://www.youtube.com/channel/UC9deX25Xus0U-Znt3_ILSpw",
|
||||
"duration": 678,
|
||||
"id": "RzmfAJUqDh0",
|
||||
"thumbnail": "https://i.ytimg.com/vi/RzmfAJUqDh0/hq720.jpg?sqp=-oaymwEcCOgCEMoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLA0urkQ_b_2XIVuqpVA68sMKr1IIQ",
|
||||
"title": "HAIRCUT TUTORIAL: WE CUT IT OFF! 360 WAVES | LOW TAPER on COARSE HAIR",
|
||||
"type": "video",
|
||||
"uploaderName": "360Jeezy",
|
||||
"url": "https://www.youtube.com/watch?v=RzmfAJUqDh0",
|
||||
"views": 6665
|
||||
},
|
||||
{
|
||||
"channelExternalId": "UCp-oS-GBudIPdLrfV_EuSxQ",
|
||||
"channelHandle": "Sabse Bada Maker",
|
||||
"channelId": "UCp-oS-GBudIPdLrfV_EuSxQ",
|
||||
"channelRef": {
|
||||
"provider": "yt",
|
||||
"scheme": "yt-uc",
|
||||
"value": "UCp-oS-GBudIPdLrfV_EuSxQ"
|
||||
},
|
||||
"channelUrl": "https://www.youtube.com/channel/UCp-oS-GBudIPdLrfV_EuSxQ",
|
||||
"duration": 12,
|
||||
"id": "wAKZjllBB0w",
|
||||
"thumbnail": "https://i.ytimg.com/vi/wAKZjllBB0w/hq720.jpg?sqp=-oaymwE2COgCEMoBSFXyq4qpAygIARUAAIhCGABwAcABBvABAfgBtgiAAoAPigIMCAAQARhbIF8oZTAP&rs=AOn4CLCISuErJFs1JV7EDlMTxHf1NfRNMw",
|
||||
"title": "Crazy mobile videography editing tutorial 📱👀 #shorts #tutorial",
|
||||
"type": "video",
|
||||
"uploaderName": "Sabse Bada Maker",
|
||||
"url": "https://www.youtube.com/watch?v=wAKZjllBB0w",
|
||||
"views": 38214938
|
||||
},
|
||||
{
|
||||
"badges": [
|
||||
"4K"
|
||||
],
|
||||
"channelExternalId": "UCXs6kLLJ8mhYVbu3OlC3dow",
|
||||
"channelHandle": "BAKAR EDITORIAL ",
|
||||
"channelId": "UCXs6kLLJ8mhYVbu3OlC3dow",
|
||||
"channelRef": {
|
||||
"provider": "yt",
|
||||
"scheme": "yt-uc",
|
||||
"value": "UCXs6kLLJ8mhYVbu3OlC3dow"
|
||||
},
|
||||
"channelUrl": "https://www.youtube.com/channel/UCXs6kLLJ8mhYVbu3OlC3dow",
|
||||
"duration": 17,
|
||||
"id": "NI1iEBGNDb0",
|
||||
"thumbnail": "https://i.ytimg.com/vi/NI1iEBGNDb0/hq720_2.jpg?sqp=-oaymwE2COgCEMoBSFXyq4qpAygIARUAAIhCGABwAcABBvABAfgBtgiAAu4LigIMCAAQARhbIFsoWzAP&rs=AOn4CLDIKfn0CDbxP3eX-zd6e9lzI6KogA",
|
||||
"title": "Master Masking in CapCut Step-by-Step Editing Tutorial CapCut Explained Beginner to Pro Guide",
|
||||
"type": "video",
|
||||
"uploaderName": "BAKAR EDITORIAL ",
|
||||
"url": "https://www.youtube.com/watch?v=NI1iEBGNDb0",
|
||||
"views": 12428432
|
||||
}
|
||||
]
|
||||
},
|
||||
"dm": {
|
||||
"items": [
|
||||
{
|
||||
"channelId": "x1avcry",
|
||||
"channelRef": {
|
||||
"provider": "dm",
|
||||
"scheme": "dm-user",
|
||||
"value": "x1avcry"
|
||||
},
|
||||
"duration": 106,
|
||||
"height": 768,
|
||||
"id": "x1067b5",
|
||||
"thumbnail": "https://s2.dmcdn.net/v/3dmw11g41PTBKxtO0/x720",
|
||||
"title": "Tutorial Wunsch Tutorial",
|
||||
"type": "video",
|
||||
"uploadedDate": "2013-05-24T11:11:56.000Z",
|
||||
"uploaderAvatar": "https://s1.dmcdn.net/u/4iK1k1gYtdQ4tt6Uv/80x80",
|
||||
"uploaderName": "diebestentutorials",
|
||||
"url": "https://www.dailymotion.com/video/x1067b5",
|
||||
"views": 14,
|
||||
"width": 1024
|
||||
},
|
||||
{
|
||||
"channelId": "x1xewsq",
|
||||
"channelRef": {
|
||||
"provider": "dm",
|
||||
"scheme": "dm-user",
|
||||
"value": "x1xewsq"
|
||||
},
|
||||
"duration": 99,
|
||||
"height": 1040,
|
||||
"id": "x6fsvsp",
|
||||
"thumbnail": "https://s2.dmcdn.net/v/NDDRv1clhOzylBCZ-/x720",
|
||||
"title": "Tutorial-Tutorial-คั่วกลิ้งหมูสับ",
|
||||
"type": "video",
|
||||
"uploadedDate": "2018-03-07T04:57:23.000Z",
|
||||
"uploaderAvatar": "https://s1.dmcdn.net/u/6ymAA1gWyHxJ8up9q/80x80",
|
||||
"uploaderName": "Nutthakit Boontrakan",
|
||||
"url": "https://www.dailymotion.com/video/x6fsvsp",
|
||||
"views": 8,
|
||||
"width": 1040
|
||||
},
|
||||
{
|
||||
"channelId": "x1kgnxy",
|
||||
"channelRef": {
|
||||
"provider": "dm",
|
||||
"scheme": "dm-user",
|
||||
"value": "x1kgnxy"
|
||||
},
|
||||
"duration": 98,
|
||||
"height": 720,
|
||||
"id": "x36s5pb",
|
||||
"thumbnail": "https://s1.dmcdn.net/v/BVRtV1e9wmzeEIfvy/x720",
|
||||
"title": "[TUTORIAL] Audacity Acapella Tutorial",
|
||||
"type": "video",
|
||||
"uploadedDate": "2015-09-13T22:23:02.000Z",
|
||||
"uploaderAvatar": "https://s1.dmcdn.net/u/5fnKs1gYDjmqATtPF/80x80",
|
||||
"uploaderName": "Acapellas",
|
||||
"url": "https://www.dailymotion.com/video/x36s5pb",
|
||||
"views": 4,
|
||||
"width": 1280
|
||||
}
|
||||
]
|
||||
},
|
||||
"tw": {
|
||||
"items": [],
|
||||
"note": "aucun résultat au moment du gel — couverture non testée pour ce provider"
|
||||
},
|
||||
"pt": {
|
||||
"items": [
|
||||
{
|
||||
"channelId": "stereo",
|
||||
"channelRef": {
|
||||
"provider": "pt",
|
||||
"scheme": "pt-composite",
|
||||
"value": "videoteca.kenobit.it|stereo"
|
||||
},
|
||||
"duration": 599,
|
||||
"id": "33e18a40-9bb1-4ab2-8cc6-211cc088a85b",
|
||||
"kind": "vod",
|
||||
"likes": 2,
|
||||
"publishedAt": "2024-02-02T19:35:59.534Z",
|
||||
"thumbnail": "https://videoteca.kenobit.it/static/thumbnails/b612fafc-5b64-42ff-bc10-26d922a70162.jpg",
|
||||
"title": "Tutorial per uploader: come si carica un disco su STEREO?",
|
||||
"type": "video",
|
||||
"uploaderAvatar": "/lazy-static/avatars/b0d2f3c7-bb5b-46f5-a3ca-dc2276630c89.png",
|
||||
"uploaderName": "Kenobit",
|
||||
"url": "https://videoteca.kenobit.it/videos/watch/33e18a40-9bb1-4ab2-8cc6-211cc088a85b",
|
||||
"viewCount": 74,
|
||||
"views": 74
|
||||
},
|
||||
{
|
||||
"channelAvatarUrl": "/lazy-static/avatars/84119f2d-ae55-4a05-b882-5cc2166a9b62.jpg",
|
||||
"channelId": "learning",
|
||||
"channelRef": {
|
||||
"provider": "pt",
|
||||
"scheme": "pt-composite",
|
||||
"value": "fediverse.tv|learning"
|
||||
},
|
||||
"duration": 170,
|
||||
"id": "2149c7a9-5dc0-41b6-aa29-f19f88e20873",
|
||||
"kind": "vod",
|
||||
"language": "es",
|
||||
"likes": 4,
|
||||
"publishedAt": "2021-11-12T16:49:04.454Z",
|
||||
"thumbnail": "https://fediverse.tv/lazy-static/thumbnails/2961e0bc-a9c9-4338-bdff-ac0fc326d827.jpg",
|
||||
"title": "[TUTORIAL] - Como hacer un fedicorto",
|
||||
"type": "video",
|
||||
"uploaderAvatar": "/lazy-static/avatars/881f287d-1b9d-4609-8e75-93d75fdb7380.png",
|
||||
"uploaderName": "spectrumgirl",
|
||||
"url": "https://fediverse.tv/videos/watch/2149c7a9-5dc0-41b6-aa29-f19f88e20873",
|
||||
"viewCount": 57,
|
||||
"views": 57
|
||||
},
|
||||
{
|
||||
"channelId": "51063_channel",
|
||||
"channelRef": {
|
||||
"provider": "pt",
|
||||
"scheme": "pt-composite",
|
||||
"value": "openmedia.edunova.it|51063_channel"
|
||||
},
|
||||
"duration": 705,
|
||||
"id": "e01c50d1-1035-4dec-927b-828187bdef91",
|
||||
"kind": "vod",
|
||||
"language": "it",
|
||||
"likes": 0,
|
||||
"publishedAt": "2024-11-02T15:40:01.069Z",
|
||||
"thumbnail": "https://openmedia.edunova.it/lazy-static/thumbnails/d266b279-aaeb-4b7a-8fd7-0d7f1cd996c7.jpg",
|
||||
"title": "tutorial",
|
||||
"type": "video",
|
||||
"uploaderName": "MARINA DE ANGELIS",
|
||||
"url": "https://openmedia.edunova.it/videos/watch/e01c50d1-1035-4dec-927b-828187bdef91",
|
||||
"viewCount": 21,
|
||||
"views": 21
|
||||
}
|
||||
]
|
||||
},
|
||||
"od": {
|
||||
"items": [
|
||||
{
|
||||
"channelRef": {
|
||||
"provider": "od",
|
||||
"scheme": "od-claim",
|
||||
"value": "@AllThingsSecured"
|
||||
},
|
||||
"duration": 527,
|
||||
"id": "1c26fcfbd6e8b8acbee692b1d789e70c04d4366a",
|
||||
"thumbnail": "https://thumbs.odycdn.com/11cd24cd9a5bff1d21c965d635a130c0.webp",
|
||||
"title": "PGP Email Tutorial: How to Encrypt a Gmail Message",
|
||||
"type": "video",
|
||||
"uploaderName": "@AllThingsSecured",
|
||||
"url": "https://odysee.com/PGP-Email-Tutorial_-How-to-Encrypt-a-Gmail-Message:1c26fcfbd6e8b8acbee692b1d789e70c04d4366a"
|
||||
},
|
||||
{
|
||||
"channelRef": {
|
||||
"provider": "od",
|
||||
"scheme": "od-claim",
|
||||
"value": "@pacheldestructor"
|
||||
},
|
||||
"duration": 398,
|
||||
"id": "259776a24b1b646ba485fa9a2c3a9291503ead91",
|
||||
"thumbnail": "https://thumbs.odycdn.com/219f40e4bdb4cd3b45c636d20757ce93.webp",
|
||||
"title": "Tutorial Color Dino",
|
||||
"type": "video",
|
||||
"uploaderName": "@pacheldestructor",
|
||||
"url": "https://odysee.com/Tutorial_Color_Dino:259776a24b1b646ba485fa9a2c3a9291503ead91"
|
||||
},
|
||||
{
|
||||
"channelRef": {
|
||||
"provider": "od",
|
||||
"scheme": "od-claim",
|
||||
"value": "@Cahlen"
|
||||
},
|
||||
"duration": 142,
|
||||
"id": "2a248bebcfaed005a0f56afabf22f01e9e507b08",
|
||||
"thumbnail": "https://thumbs.odycdn.com/bc956cd079fc5c30567f369728c79121.webp",
|
||||
"title": "Plasma Machine Video Tutorial",
|
||||
"type": "video",
|
||||
"uploaderName": "@Cahlen",
|
||||
"url": "https://odysee.com/CahlenLee_20260915_PlasmaMachineVideoTutorial:2a248bebcfaed005a0f56afabf22f01e9e507b08"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ru": {
|
||||
"items": [],
|
||||
"note": "erreur au gel : rumble_unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Phase 8.5 — documentation GÉNÉRÉE depuis les fixtures gelées.
|
||||
*
|
||||
* Produit la matrice « champ ↔ provider » à partir de ce que les adaptateurs
|
||||
* émettent RÉELLEMENT, et l'injecte entre deux marqueurs dans
|
||||
* `docs/ingestion-catalogue-video-par-fournisseur.md`.
|
||||
*
|
||||
* Raison d'être : une matrice écrite à la main devient fausse au premier
|
||||
* adaptateur modifié, et personne ne le remarque — c'est le pire sort pour une
|
||||
* doc de référence. Générée, elle ne peut qu'être à jour ou absente.
|
||||
*
|
||||
* La section générée est donc explicitement marquée comme telle, et `npm run
|
||||
* test:shapes` échoue si elle n'est plus synchronisée (dérive = doc fausse).
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '../..');
|
||||
const FIXTURES = path.join(ROOT, 'server/tests/fixtures/provider-suggestions.json');
|
||||
const DOC = path.join(ROOT, 'docs/ingestion-catalogue-video-par-fournisseur.md');
|
||||
const BEGIN = '<!-- GENERATED:provider-matrix (npm run doc:providers) -->';
|
||||
const END = '<!-- /GENERATED:provider-matrix -->';
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(FIXTURES, 'utf8'));
|
||||
const ids = Object.keys(data.providers);
|
||||
const labels = { yt: 'YouTube', dm: 'Dailymotion', tw: 'Twitch', pt: 'PeerTube', od: 'Odysee', ru: 'Rumble' };
|
||||
|
||||
// Champs d'intérêt métier, dans un ordre de lecture stable (le tri
|
||||
// alphabétique de `Object.keys` n'est pas l'ordre du contrat).
|
||||
const FIELDS = [
|
||||
['duration', 'durée', 'secondes'],
|
||||
['views', 'vues', 'nombre'],
|
||||
['likes', 'likes', 'nombre'],
|
||||
['publishedAt', 'publication', 'date ISO'],
|
||||
['thumbnail', 'vignette', 'URL'],
|
||||
['uploaderName', 'chaîne', 'texte'],
|
||||
['channelRef', 'identité chaîne', 'scheme + value'],
|
||||
['type', 'type', 'video / live / short'],
|
||||
['kind', 'kind', 'vod / live / clip / channel'],
|
||||
['isLive', 'direct', 'booléen'],
|
||||
['width', 'largeur', 'px'],
|
||||
['height', 'hauteur', 'px'],
|
||||
['language', 'langue', 'code'],
|
||||
];
|
||||
|
||||
const mark = (present) => (present ? 'x' : '·');
|
||||
|
||||
const lines = [];
|
||||
lines.push(BEGIN);
|
||||
lines.push('');
|
||||
lines.push(`> Section **générée** par \`npm run doc:providers\` depuis \`server/tests/fixtures/provider-suggestions.json\``);
|
||||
lines.push(`> (gel du ${data._recordedAt.slice(0, 10)}, requête \`${data._query}\`). Ne pas éditer à la main.`);
|
||||
lines.push('');
|
||||
lines.push(`Legende : \`x\` = émis dans le gel, \`·\` = absent (donnée inconnue, donc \`undefined\` côté front).`);
|
||||
lines.push('');
|
||||
|
||||
const head = ['Champ', 'Type', ...ids.map((id) => labels[id] || id)];
|
||||
lines.push(`| ${head.join(' | ')} |`);
|
||||
lines.push(`|${head.map(() => '---').join('|')}|`);
|
||||
|
||||
for (const [field, label, type] of FIELDS) {
|
||||
const cells = ids.map((id) => {
|
||||
const items = data.providers[id]?.items || [];
|
||||
// Sur un provider sans fixture, on ne peut rien affirmer : ni x ni ·.
|
||||
if (items.length === 0) return '?';
|
||||
return mark(items.some((it) => it[field] !== undefined));
|
||||
});
|
||||
lines.push(`| \`${field}\` (${label}) | ${type} | ${cells.join(' | ')} |`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Couverture du gel :');
|
||||
lines.push('');
|
||||
for (const id of ids) {
|
||||
const entry = data.providers[id];
|
||||
const n = entry.items.length;
|
||||
lines.push(n
|
||||
? `- **${labels[id] || id}** : ${n} item(s) vérifié(s).`
|
||||
: `- **${labels[id] || id}** : *non couvert* — ${entry.note || 'aucune fixture'}.`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('(`?` = provider sans fixture au gel : la matrice ne prétend rien sur lui.)');
|
||||
lines.push('');
|
||||
lines.push(END);
|
||||
|
||||
const block = lines.join('\n');
|
||||
const doc = fs.readFileSync(DOC, 'utf8');
|
||||
|
||||
if (doc.includes(BEGIN)) {
|
||||
const from = doc.indexOf(BEGIN);
|
||||
const to = doc.indexOf(END) + END.length;
|
||||
fs.writeFileSync(DOC, doc.slice(0, from) + block + doc.slice(to));
|
||||
console.log('matrice régénérée (remplacement)');
|
||||
} else {
|
||||
// Ancre : juste après le titre de la section correspondente.
|
||||
const anchor = '## 4. Catalogue';
|
||||
const at = doc.indexOf(anchor);
|
||||
if (at < 0) throw new Error(`ancre « ${anchor} » introuvable dans ${path.basename(DOC)}`);
|
||||
const insertAt = doc.indexOf('\n', at) + 1;
|
||||
fs.writeFileSync(DOC, `${doc.slice(0, insertAt)}\n${block}\n${doc.slice(insertAt)}`);
|
||||
console.log('matrice générée (insertion)');
|
||||
}
|
||||
console.log(`→ ${path.relative(process.cwd(), DOC)}`);
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Validateur de forme d'une `Suggestion` — source unique.
|
||||
*
|
||||
* Partagé par `provider-contract.test.mjs` (invariants sur objets synthétiques)
|
||||
* et `provider_shapes.test.mjs` (fixtures gelées issues des 6 adaptateurs
|
||||
* réels). Deux définitions du contrat finiraient par diverger, et c'est
|
||||
* précisément la divergence qu'on veut détecter.
|
||||
*
|
||||
* Règle centrale (phase 2) : une métadonnée absente est `undefined`, jamais `0`.
|
||||
* Un `0` inventé s'affiche dans l'UI comme une donnée réelle.
|
||||
*/
|
||||
|
||||
/** Champs textuels : chaîne non vide, ou absents. */
|
||||
const STRING_FIELDS = [
|
||||
'url', 'thumbnail', 'uploaderName', 'uploaderAvatar', 'channelId', 'slug',
|
||||
'channelUrl', 'language', 'channelExternalId', 'channelHandle', 'channel',
|
||||
'kind', 'game', 'durationRaw',
|
||||
];
|
||||
/**
|
||||
* Champs numériques où 0 est impossible : un 0 y est un bug ou un mensonge.
|
||||
* `duration: 0` rendait notamment la détection de Short inopérante
|
||||
* (« verticale sans durée connue » ne doit pas être classée short).
|
||||
*/
|
||||
const STRICTLY_POSITIVE_FIELDS = ['duration', 'width', 'height'];
|
||||
/**
|
||||
* Champs de comptage où 0 est une VRAIE information : l'API peut legitimately
|
||||
* répondre « 0 like » (PeerTube le fait). Traduire ce 0 en `undefined`
|
||||
* confondrait « personne n'a aimé » avec « l'API n'a rien dit » — on perdrait
|
||||
* l'information au lieu de la protéger.
|
||||
*
|
||||
* L'invariant protégé reste : ABSENT reste absent. Ce qui est interdit, c'est
|
||||
* d'inventer un 0, pas de rencontrer un 0.
|
||||
*/
|
||||
const COUNT_FIELDS = ['views', 'likes', 'dislikes', 'viewers', 'viewCountRaw'];
|
||||
const BOOLEAN_FIELDS = ['isShort', 'isLive', 'hasSubtitles', 'embeddable'];
|
||||
|
||||
/**
|
||||
* Schemes d'identité de chaîne connus (phase 6).
|
||||
*
|
||||
* `yt-uc` et non `yt-channel` : c'est le scheme réellement émis par
|
||||
* l'adaptateur YouTube (canal = identifiant `UC…`). Le nommer autrement
|
||||
* donnerait l'illusion que le front sait le lire.
|
||||
*/
|
||||
const CHANNEL_REF_SCHEMES = new Set(['yt-uc', 'pt-composite', 'od-claim', 'tw-login', 'ru-slug', 'dm-user']);
|
||||
|
||||
/**
|
||||
* Valide un objet `Suggestion`.
|
||||
* @param {any} s
|
||||
* @param {string} label contexte pour le message d'erreur
|
||||
* @param {(msg: string) => void} expect
|
||||
* @returns {string[]} messages d'erreur ([] si conforme)
|
||||
*/
|
||||
export function suggestionShapeErrors(s, label, expect) {
|
||||
expect(typeof s?.title === 'string', `${label}: title is a string`);
|
||||
expect(typeof s?.id === 'string' && s.id.length > 0, `${label}: id is a non-empty string`);
|
||||
|
||||
for (const f of STRICTLY_POSITIVE_FIELDS) {
|
||||
if (s?.[f] !== undefined) {
|
||||
expect(typeof s[f] === 'number' && Number.isFinite(s[f]), `${label}: ${f} is a finite number`);
|
||||
expect(s[f] > 0, `${label}: ${f} is strictly > 0 (never 0)`);
|
||||
}
|
||||
}
|
||||
for (const f of COUNT_FIELDS) {
|
||||
if (s?.[f] !== undefined) {
|
||||
expect(typeof s[f] === 'number' && Number.isFinite(s[f]), `${label}: ${f} is a finite number`);
|
||||
expect(s[f] >= 0, `${label}: ${f} is >= 0 (an explicit 0 from the upstream is real data)`);
|
||||
}
|
||||
}
|
||||
if (s?.publishedAt !== undefined) {
|
||||
expect(typeof s.publishedAt === 'string', `${label}: publishedAt is a string`);
|
||||
const t = Date.parse(s.publishedAt);
|
||||
expect(Number.isFinite(t), `${label}: publishedAt is parseable as a date`);
|
||||
// Piège historique : « 2 months ago » n'est pas une date.
|
||||
expect(!/ago|il y a/i.test(s.publishedAt), `${label}: publishedAt is not a relative label`);
|
||||
expect(t > 0 && t < 8.64e15, `${label}: publishedAt is in a sane range`);
|
||||
}
|
||||
for (const f of STRING_FIELDS) {
|
||||
if (s?.[f] !== undefined) {
|
||||
expect(typeof s[f] === 'string' && s[f].length > 0, `${label}: ${f} is a non-empty string when present`);
|
||||
}
|
||||
}
|
||||
if (s?.tags !== undefined) {
|
||||
expect(Array.isArray(s.tags), `${label}: tags is an array when present`);
|
||||
}
|
||||
for (const f of BOOLEAN_FIELDS) {
|
||||
if (s?.[f] !== undefined) expect(typeof s[f] === 'boolean', `${label}: ${f} is a boolean when present`);
|
||||
}
|
||||
// Phase 6 : `channelRef` est additif, mais s'il est présent il doit être
|
||||
// exploitable tel quel par le front. Forme réelle = objet `{ provider,
|
||||
// scheme, value }` (et non une chaîne) : c'est ce que les 6 adaptateurs
|
||||
// émettent, vérifié sur fixtures gelées.
|
||||
if (s?.channelRef !== undefined) {
|
||||
const ref = s.channelRef;
|
||||
expect(ref && typeof ref === 'object' && !Array.isArray(ref), `${label}: channelRef est un objet`);
|
||||
expect(typeof ref.provider === 'string' && ref.provider.length > 0, `${label}: channelRef.provider est une chaîne non vide`);
|
||||
expect(typeof ref.scheme === 'string' && ref.scheme.length > 0, `${label}: channelRef.scheme est une chaîne non vide`);
|
||||
expect(typeof ref.value === 'string' && ref.value.length > 0, `${label}: channelRef.value est une chaîne non vide`);
|
||||
expect(CHANNEL_REF_SCHEMES.has(ref.scheme), `${label}: channelRef.scheme inconnu « ${ref.scheme} »`);
|
||||
expect(!/\s/.test(ref.value), `${label}: channelRef.value sans espace`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Schémas connus, exportés pour la génération de doc (phase 8.5). */
|
||||
export { CHANNEL_REF_SCHEMES, STRING_FIELDS, STRICTLY_POSITIVE_FIELDS, COUNT_FIELDS, BOOLEAN_FIELDS };
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Phase 3.12 — persistance des `pageToken` YouTube en base.
|
||||
*
|
||||
* Le test porte sur le comportement observable, pas sur l'implémentation :
|
||||
* un jeton écrit par un « process A » doit être relisible par un « process B »
|
||||
* qui ne partage aucun état mémoire. C'est exactement ce qui échouait avec la
|
||||
* Map process-local — et ce que le redémarrage en production provocait.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-tokens-'));
|
||||
process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'tokens.db');
|
||||
|
||||
const db = await import('../db.mjs');
|
||||
|
||||
test('3.12 — un jeton écrit est relu par un autre process (L2 partagé)', () => {
|
||||
const key = 'yt|UCtest|pl|24';
|
||||
assert.equal(db.getCachedPageTokens(key), null, 'clé absente au départ');
|
||||
|
||||
assert.equal(db.setCachedPageTokens(key, [undefined, 'TOKEN_PAGE_2', 'TOKEN_PAGE_3']), true);
|
||||
|
||||
// On repart d'un cache mémoire vierge : c'est le cas d'un process neuf ou d'un
|
||||
// pod différent. Seul le L2 peut répondre ici.
|
||||
const reread = db.getCachedPageTokens(key);
|
||||
assert.ok(Array.isArray(reread), 'le jeton doit survivre hors du cache mémoire');
|
||||
// Indexation : `tokens[n]` = jeton pour la page n+2 (l'index 1 est donc la
|
||||
// page 2). Un trou est matérialisé par `''`, jamais par un décalage : si le
|
||||
// round-trip compressait le tableau, le jeton de la page 2 se retrouverait à
|
||||
// l'index 0 et la page 3 renverrait celui de la page 4.
|
||||
assert.equal(reread[0], '', 'le trou de l\'index 0 reste un trou, pas un décalage');
|
||||
assert.equal(reread[1], 'TOKEN_PAGE_2', 'tokens[1] = jeton de la page 2');
|
||||
assert.equal(reread[2], 'TOKEN_PAGE_3', 'tokens[2] = jeton de la page 3');
|
||||
});
|
||||
|
||||
test('3.12 — un jeton absent reste absent (jamais de token inventé)', () => {
|
||||
assert.equal(db.getCachedPageTokens('yt|UCinconnu|pl|24'), null);
|
||||
});
|
||||
|
||||
test('3.12 — un jeton expiré n\'est pas resservi', () => {
|
||||
const key = 'yt|UCexpire|pl|24';
|
||||
db.setCachedPageTokens(key, ['TOK'], 1);
|
||||
// TTL de 1 ms : la ligne est déjà hors délai au tour suivant.
|
||||
assert.equal(db.getCachedPageTokens(key), null, 'un jeton expiré doit être traité comme absent');
|
||||
// Et la ligne doit avoir été retirée, pas laissée traîner.
|
||||
assert.equal(db.getCachedPageTokens(key), null);
|
||||
});
|
||||
|
||||
test('3.12 — une écriture vide est refusée (pas de ligne inutile)', () => {
|
||||
assert.equal(db.setCachedPageTokens('yt|vide|pl|24', []), false);
|
||||
assert.equal(db.getCachedPageTokens('yt|vide|pl|24'), null);
|
||||
});
|
||||
|
||||
test('3.12 — les jetons ne se confondent pas avec les résultats de recherche', () => {
|
||||
// Namespace distinct : sinon le plafond par fournisseur et les stats de cache
|
||||
// du fournisseur `yt` seraient faussés par des jetons qui ne sont pas des
|
||||
// résultats.
|
||||
const key = 'collision|1';
|
||||
db.setCachedPageTokens(key, ['TOK_A']);
|
||||
// `setCachedSearch` n'écrit que des tableaux d'items, jamais sous ce provider.
|
||||
assert.equal(db.setCachedSearch('yt_tokens', key, 'q', ['item'], 'api'), true);
|
||||
// Le même cache_key sous deux providers ne se chevauche pas : la lecture
|
||||
// page_token et la lecture search restent distinctes.
|
||||
assert.ok(db.getCachedPageTokens(key), 'le jeton reste lisible');
|
||||
});
|
||||
|
||||
test('3.12 — une chaîne de jetons se complète sans écraser les pages précédentes', () => {
|
||||
const key = 'yt|UCchain|pl|24';
|
||||
// Page 1 écrite seule, puis page 2 : le tableau doit garder les deux.
|
||||
db.setCachedPageTokens(key, ['TOK_PAGE_2']);
|
||||
assert.equal(db.getCachedPageTokens(key)[0], 'TOK_PAGE_2');
|
||||
db.setCachedPageTokens(key, ['TOK_PAGE_2', 'TOK_PAGE_3']);
|
||||
const arr = db.getCachedPageTokens(key);
|
||||
assert.equal(arr.length, 2);
|
||||
assert.equal(arr[0], 'TOK_PAGE_2', 'la page 2 ne doit pas disparaître');
|
||||
assert.equal(arr[1], 'TOK_PAGE_3');
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Phase 7.3 — provenance des suggestions (`capturedAt` / `source`).
|
||||
*
|
||||
* Ce qui est réellement vérifié, c'est la HONNÊTETÉ de la fraîcheur :
|
||||
* - un résultat vivant est estampillé à l'instant de la collecte ;
|
||||
* - un résultat servi par le cache conserve l'instant du VRAI appel amont,
|
||||
* pas l'heure de lecture du cache (sinon « à l'instant » sur du vieux data) ;
|
||||
* - une entrée de cache écrite par un serveur antérieur à la phase 7.3 (donc
|
||||
* sans provenance) récupère son `createdAt` en base.
|
||||
*
|
||||
* Aucun réseau : `globalThis.fetch` est stubé, et le provider testé est
|
||||
* `dm` (Dailymotion), l'un de ceux enveloppés par le cache générique.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-prov-'));
|
||||
process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'prov.db');
|
||||
|
||||
const db = await import('../db.mjs');
|
||||
const { providerRegistry, stampProvenance } = await import('../providers/registry.mjs');
|
||||
|
||||
const calls = [];
|
||||
function installFetch() {
|
||||
const prev = globalThis.fetch;
|
||||
calls.length = 0;
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
page: 1,
|
||||
total: 1,
|
||||
list: [{
|
||||
id: 'x1dm',
|
||||
created_time: 1700000000,
|
||||
duration: 300,
|
||||
views_total: 1234,
|
||||
owner: { screenname: 'DM User', username: 'dmuser', avatar_720_url: 'https://img/a.png' },
|
||||
title: 'Vidéo Dailymotion',
|
||||
description: 'une description',
|
||||
url: 'https://www.dailymotion.com/video/x1dm',
|
||||
}],
|
||||
}),
|
||||
};
|
||||
};
|
||||
return () => { globalThis.fetch = prev; };
|
||||
}
|
||||
|
||||
const dm = providerRegistry.dm;
|
||||
const OPTS = { limit: 5, page: 1, sort: 'relevance' };
|
||||
|
||||
test('7.3 — un résultat vivant porte capturedAt + source', async (t) => {
|
||||
const restore = installFetch();
|
||||
t.after(restore);
|
||||
|
||||
const before = Date.now();
|
||||
const items = await dm.search('provenance vivante', OPTS);
|
||||
assert.ok(items.length > 0, 'la recherche stubée doit produire des résultats');
|
||||
const at = Number(items[0].capturedAt);
|
||||
assert.ok(Number.isFinite(at) && at >= before, `capturedAt epoch ms plausible (${at})`);
|
||||
assert.ok(at <= Date.now(), 'capturedAt ne peut pas être dans le futur');
|
||||
assert.equal(items[0].source, 'api', 'voie de collecte explicite');
|
||||
// Provenance sur TOUS les items, pas seulement le premier.
|
||||
assert.ok(items.every((i) => i.capturedAt && i.source), 'chaque item est estampillé');
|
||||
});
|
||||
|
||||
test('7.3 — un hit de cache garde le capturedAt du VRAI appel amont', async (t) => {
|
||||
const restore = installFetch();
|
||||
t.after(restore);
|
||||
|
||||
const first = await dm.search('provenance cache', OPTS);
|
||||
const firstAt = Number(first[0].capturedAt);
|
||||
const networkCallsAfterFirst = calls.length;
|
||||
assert.ok(networkCallsAfterFirst > 0, 'le premier appel est bien allé au réseau');
|
||||
|
||||
// Deuxième appel identique : servi par le cache, aucun nouvel appel amont.
|
||||
const second = await dm.search('provenance cache', OPTS);
|
||||
assert.equal(calls.length, networkCallsAfterFirst, 'aucun appel amont sur un hit de cache');
|
||||
assert.equal(Number(second[0].capturedAt), firstAt,
|
||||
'la fraîcheur affichée est celle de la DONNÉE, pas celle de la lecture du cache');
|
||||
});
|
||||
|
||||
test('7.3 — une entrée de cache sans provenance récupère son createdAt', async (t) => {
|
||||
const restore = installFetch();
|
||||
t.after(restore);
|
||||
|
||||
// Simule une ligne écrite par un serveur antérieur à la phase 7.3 : payload
|
||||
// sans `capturedAt`/`source`.
|
||||
const q = 'provenance legacy';
|
||||
const { hashSearchKey } = await import('../providers/youtube-common.mjs');
|
||||
const filterSig = '';
|
||||
const key = `dm|${hashSearchKey(`${q.toLowerCase().trim()}|5|1|relevance|${filterSig}`)}`;
|
||||
assert.equal(db.setCachedSearch('dm', key, q, [{ id: 'legacy1', title: 'vieux' }], 'api'), true);
|
||||
|
||||
const items = await dm.search(q, OPTS);
|
||||
assert.equal(calls.length, 0, 'la ligne préexistante est servie sans réseau');
|
||||
assert.equal(items[0].id, 'legacy1');
|
||||
assert.ok(Number.isFinite(Number(items[0].capturedAt)) && Number(items[0].capturedAt) > 0,
|
||||
'createdAt en base comble le champ manquant');
|
||||
assert.equal(items[0].source, 'api', 'source de la ligne de cache');
|
||||
});
|
||||
|
||||
test('7.3 — la voie réelle de l\'adaptateur n\'est jamais réécrite', () => {
|
||||
// YouTube connaît sa propre voie (`innertube` / `scrape`) : la passe de
|
||||
// provenance ne doit pas la surcharger avec la valeur par défaut `api`.
|
||||
const items = stampProvenance([{ id: 'yt1', source: 'innertube', capturedAt: 1700000000000 }], 'api');
|
||||
assert.equal(items[0].source, 'innertube', 'source existante conservée');
|
||||
assert.equal(Number(items[0].capturedAt), 1700000000000, 'capturedAt existant conservé');
|
||||
});
|
||||
|
||||
test('7.3 — hit de cache : source `cache` + instant de la lecture', () => {
|
||||
const items = stampProvenance([{ id: 'a' }, { id: 'b' }], 'cache', 1700000000000);
|
||||
assert.ok(items.every((i) => i.source === 'cache'), 'tous les items sont marqués');
|
||||
assert.ok(items.every((i) => Number(i.capturedAt) === 1700000000000), 'instant appliqué à tous');
|
||||
});
|
||||
|
||||
test('7.3 — payload invalide toléré (jamais de 0 ni de date bidon)', () => {
|
||||
// `0` n'est pas une epoch ms, `''` n'est pas une voie de collecte : les deux
|
||||
// doivent être remplacés, sinon l'IHB affiche « il y a 0 s » sur du vide.
|
||||
const items = stampProvenance([
|
||||
{ id: 'x', capturedAt: 0, source: '' },
|
||||
{ id: 'y', capturedAt: -1, source: ' ' },
|
||||
{ id: 'z', capturedAt: NaN },
|
||||
], 'api', 1700000000000);
|
||||
for (const it of items) {
|
||||
assert.equal(Number(it.capturedAt), 1700000000000, `${it.id} : capturedAt replaced`);
|
||||
assert.equal(it.source, 'api', `${it.id} : source replaced`);
|
||||
}
|
||||
});
|
||||
|
||||
test('7.3 — entrée vide / non tableau toléré', () => {
|
||||
assert.deepEqual(stampProvenance([], 'api'), []);
|
||||
assert.equal(stampProvenance(null, 'api'), null);
|
||||
assert.equal(stampProvenance(undefined, 'api'), undefined);
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
// Phase 0 §2.6 — Contrat `Suggestion` v2.
|
||||
//
|
||||
// Run with: npm run test:contract
|
||||
//
|
||||
// Pourquoi ce test existe : avant, les champs `views` / `publishedAt` / `channelId`
|
||||
// / `uploaderAvatar` étaient bien récupérés par certains providers, puis perdus
|
||||
// silencieusement en route (anomalies #3, #7). Rien ne le détectait : le code
|
||||
// « fonctionnait », la grille restait vide. Ce test fige les invariants.
|
||||
//
|
||||
// 100% offline : aucun appel réseau. On teste (a) le contrat déclaré, (b) les
|
||||
// parseurs HTML/JSON en local, (c) la parité serveur <-> front.
|
||||
|
||||
import {
|
||||
SUGGESTION_CONTRACT_VERSION,
|
||||
SUGGESTION_V2_FIELDS,
|
||||
providerRegistry,
|
||||
validateProviders,
|
||||
} from '../providers/registry.mjs';
|
||||
import { parseRelativeDate } from '../providers/youtube-innertube.mjs';
|
||||
import { isShortItem, isLiveItem, itemDurationSec, itemPublishedTs } from '../search-filters.mjs';
|
||||
import { suggestionShapeErrors } from './lib/suggestion-shape.mjs';
|
||||
|
||||
let passed = 0;
|
||||
function expect(cond, msg) { if (!cond) throw new Error(`Assertion failed: ${msg}`); }
|
||||
function eq(actual, expected, msg) {
|
||||
const a = JSON.stringify(actual); const b = JSON.stringify(expected);
|
||||
if (a !== b) throw new Error(`Assertion failed: ${msg} (expected ${b}, got ${a})`);
|
||||
}
|
||||
function ok(msg) { passed++; console.log(` V ${msg}`); }
|
||||
|
||||
// ---------- 0.1 Le contrat est versionné ----------
|
||||
eq(SUGGESTION_CONTRACT_VERSION, 2, 'contract version is 2');
|
||||
ok('contract is versioned (v=2)');
|
||||
|
||||
expect(Array.isArray(SUGGESTION_V2_FIELDS) && SUGGESTION_V2_FIELDS.length > 0, 'SUGGESTION_V2_FIELDS is a non-empty list');
|
||||
for (const f of SUGGESTION_V2_FIELDS) expect(typeof f === 'string' && f.length > 0, `field "${f}" is a non-empty string`);
|
||||
ok(`v2 declares ${SUGGESTION_V2_FIELDS.length} optional fields`);
|
||||
|
||||
// ---------- 0.2 Les 6 providers sont bien enregistrés ----------
|
||||
for (const id of ['yt', 'dm', 'tw', 'pt', 'od', 'ru']) {
|
||||
expect(providerRegistry[id], `provider "${id}" is registered`);
|
||||
expect(typeof providerRegistry[id].search === 'function', `provider "${id}" exposes search()`);
|
||||
expect(typeof providerRegistry[id].label === 'string' && providerRegistry[id].label.length > 0, `provider "${id}" has a label`);
|
||||
}
|
||||
ok('all 6 providers registered with search() + label');
|
||||
|
||||
eq(validateProviders('').sort(), ['dm', 'od', 'pt', 'ru', 'tw', 'yt'], 'empty providers -> all 6');
|
||||
eq(validateProviders('yt,bogus,YT').sort(), ['yt'], 'unknown + duplicates + case are normalized');
|
||||
ok('validateProviders normalizes unknown/duplicate/case');
|
||||
|
||||
// ---------- 0.3-0.6 Invariants sur des Suggestions synthétiques ----------
|
||||
// Ce sont les règles que TOUT provider doit respecter. On les applique à un objet
|
||||
// « bien formé » puis à ses variantes dégradées, comme le ferait la grille.
|
||||
|
||||
// Validateur de contrat — source unique dans lib/suggestion-shape.mjs, partagee
|
||||
// avec provider_shapes.test.mjs (phase 8.4). Deux copies du contrat finitissent par
|
||||
// diverger, et c'est precisement la divergence qu'on cherche a detecter.
|
||||
const assertSuggestion = (s, label) => suggestionShapeErrors(s, label, expect) && true;
|
||||
|
||||
const good = {
|
||||
title: 'Test', id: 'abc', url: 'https://example.com/v/abc', thumbnail: 'https://example.com/t.jpg',
|
||||
uploaderName: 'Chaîne', uploaderAvatar: 'https://example.com/a.jpg', duration: 212,
|
||||
views: 1234, likes: 56, publishedAt: '2026-01-17T10:30:00.000Z', type: 'video',
|
||||
width: 1080, height: 1920, channelId: 'UC123', channelExternalId: 'UC123', language: 'fr',
|
||||
tags: ['a', 'b'], isLive: false, hasSubtitles: true, embeddable: true,
|
||||
};
|
||||
assertSuggestion(good, 'well-formed');
|
||||
ok('well-formed Suggestion passes the contract validator');
|
||||
|
||||
// Champs absents => strictement absents (pas de 0, pas de '')
|
||||
const minimal = { title: 'Test', id: 'abc' };
|
||||
assertSuggestion(minimal, 'minimal');
|
||||
eq(Object.keys(minimal), ['title', 'id'], 'a minimal Suggestion carries no nullish junk keys');
|
||||
ok('minimal Suggestion stays minimal (no invented zeroes)');
|
||||
|
||||
// `duration: 0` est le bug historique : il rendait la règle
|
||||
// « verticale sans durée connue => pas un short » inopérante.
|
||||
const zeroDuration = { title: 'T', id: 'i', duration: 0 };
|
||||
try { assertSuggestion(zeroDuration, 'zero-duration'); throw new Error('Assertion failed: duration:0 should have been rejected'); }
|
||||
catch (e) { expect(String(e.message).includes('duration is strictly > 0'), 'duration:0 is rejected with a clear message'); }
|
||||
ok('duration:0 is rejected (the phase 1/2 bug class)');
|
||||
|
||||
// Distinction affinée en phase 8.4, en gelant la sortie réelle des providers :
|
||||
// un COMPTEUR à 0 est une donnée vraie (« 0 like »), pas un mensonge. PeerTube
|
||||
// renvoie `likes: 0` legitimately ; le rejeter en `undefined` ferait perdre
|
||||
// l'information. Ce qui reste interdit, c'est d'inventer un 0 : l'ABSENT doit
|
||||
// rester absent.
|
||||
assertSuggestion({ title: 'T', id: 'i', views: 0, likes: 0, dislikes: 0, viewers: 0 }, 'zero-counts');
|
||||
ok('an explicit 0 on a counter is accepted (real data, not an invented zero)');
|
||||
const zeroDims = { title: 'T', id: 'i', width: 0, height: 0 };
|
||||
try { assertSuggestion(zeroDims, 'zero-dims'); throw new Error('Assertion failed: width:0 should have been rejected'); }
|
||||
catch (e) { expect(String(e.message).includes('is strictly > 0'), 'width:0 is rejected'); }
|
||||
ok('0 on width/height stays rejected (a 0-sized video does not exist)');
|
||||
|
||||
// ---------- 0.7-0.8 Classification : parité des règles ----------
|
||||
// Une verticale sans durée connue ne doit PAS être classée short.
|
||||
eq(isShortItem({ type: 'video', width: 1080, height: 1920, duration: undefined }), false,
|
||||
'portrait WITHOUT duration is not a short (absent duration is not evidence)');
|
||||
eq(isShortItem({ type: 'video', width: 1080, height: 1920, duration: 30 }), true,
|
||||
'portrait + 30 s is a short');
|
||||
eq(isShortItem({ type: 'video', width: 1920, height: 1080, duration: 30 }), false,
|
||||
'landscape + 30 s is NOT a short (duration alone is not enough)');
|
||||
eq(isShortItem({ type: 'video', isShort: true }), true, 'explicit provider isShort wins');
|
||||
ok('isShortItem: no duration => no short, and portrait is required');
|
||||
|
||||
// Règles (a)/(b)/(c) de la tâche 2.3 — ces trois cas doivent être identiques
|
||||
// côté serveur ET côté front (`isShortVideo`), sinon la pastille SHORT apparaît
|
||||
// dans la grille puis disparaît au changement d'onglet.
|
||||
eq(isShortItem({ isShort: true }), true, '(a) native flag + unknown duration => short');
|
||||
eq(isShortItem({ isShort: true, width: 1080, height: 1080, duration: 60 }), true,
|
||||
'(b) square 60 s + native flag => short (flag primes over orientation)');
|
||||
eq(isShortItem({ width: 1080, height: 1080, duration: 60 }), false,
|
||||
'(b) square 60 s WITHOUT a flag => not a short (duration alone never qualifies)');
|
||||
eq(isShortItem({ kind: 'clip', type: 'video', duration: 42 }), true,
|
||||
'(c) kind:clip beats type:video');
|
||||
eq(isShortItem({ url: 'https://www.youtube.com/shorts/abcdefghijk', duration: 30 }), true,
|
||||
'/shorts/ URL marker is honoured (parity with the front)');
|
||||
ok('rules (a)/(b)/(c) hold, including the /shorts/ URL marker');
|
||||
|
||||
// itemDurationSec doit distinguer 0 de undefined.
|
||||
eq(itemDurationSec({ duration: 0 }), undefined, 'itemDurationSec maps 0 -> undefined');
|
||||
eq(itemDurationSec({ duration: 30 }), 30, 'itemDurationSec keeps a real duration');
|
||||
eq(itemDurationSec({ durationSec: 42 }), 42, 'itemDurationSec reads durationSec too');
|
||||
ok('itemDurationSec never leaks a 0 duration');
|
||||
|
||||
// Une date relative ne doit jamais arriver jusqu'ici (bug InnerTube 1.9).
|
||||
eq(itemPublishedTs({ publishedAt: '2 months ago' }), 0, 'relative label yields no timestamp');
|
||||
eq(itemPublishedTs({ publishedAt: '2026-01-17T10:30:00.000Z' }), Date.parse('2026-01-17T10:30:00.000Z'),
|
||||
'ISO date yields a timestamp');
|
||||
ok('itemPublishedTs rejects relative labels (InnerTube regression guard)');
|
||||
|
||||
eq(isLiveItem({ type: 'live' }), true, 'type=live is live');
|
||||
eq(isLiveItem({ type: 'video', isLive: true }), true, 'isLive flag is honoured');
|
||||
eq(isLiveItem({ type: 'vod', kind: 'vod' }), false, 'a Twitch VOD is not live');
|
||||
ok('isLiveItem honours both type and the isLive flag');
|
||||
|
||||
// ---------- 0.9 parseRelativeDate (tâche 1.9) ----------
|
||||
const NOW = Date.parse('2026-03-01T12:00:00.000Z');
|
||||
const day = 864e5;
|
||||
eq(parseRelativeDate('il y a 2 heures', NOW), new Date(NOW - 2 * 36e5).toISOString(), 'FR relative hours');
|
||||
eq(parseRelativeDate('il y a 3 jours', NOW), new Date(NOW - 3 * day).toISOString(), 'FR relative days');
|
||||
eq(parseRelativeDate('il y a 2 semaines', NOW), new Date(NOW - 2 * 6048e5).toISOString(), 'FR relative weeks');
|
||||
eq(parseRelativeDate('2 months ago', NOW), new Date(NOW - 2 * 26298e6).toISOString(), 'EN relative months');
|
||||
eq(parseRelativeDate('Streamed 3 weeks ago', NOW), new Date(NOW - 3 * 6048e5).toISOString(), 'EN streamed weeks');
|
||||
eq(parseRelativeDate('1 an', NOW), new Date(NOW - 315576e5).toISOString(), 'FR relative years');
|
||||
eq(parseRelativeDate('hier', NOW), new Date(NOW - day).toISOString(), 'FR yesterday');
|
||||
eq(parseRelativeDate('yesterday', NOW), new Date(NOW - day).toISOString(), 'EN yesterday');
|
||||
eq(parseRelativeDate("aujourd'hui", NOW), new Date(NOW).toISOString(), 'FR today');
|
||||
eq(parseRelativeDate('today', NOW), new Date(NOW).toISOString(), 'EN today');
|
||||
eq(parseRelativeDate('2026-01-17T10:30:00Z', NOW), '2026-01-17T10:30:00.000Z', 'absolute ISO passthrough');
|
||||
eq(parseRelativeDate('', NOW), undefined, 'empty string -> undefined');
|
||||
eq(parseRelativeDate(null, NOW), undefined, 'null -> undefined');
|
||||
eq(parseRelativeDate('Streaming', NOW), undefined, 'unparseable label -> undefined (no invented date)');
|
||||
ok(`parseRelativeDate handles ${13} FR/EN cases and never invents a date`);
|
||||
|
||||
// ---------- 0.10 Cohérence provider <-> contrat ----------
|
||||
// `id` court attendu par groupe, et un `search` qui dégrade proprement.
|
||||
for (const id of ['yt', 'dm', 'tw', 'pt', 'od', 'ru']) {
|
||||
const mod = providerRegistry[id];
|
||||
expect(mod.id === id, `provider "${id}" declares id === "${id}"`);
|
||||
}
|
||||
ok('each adapter self-declares its own id');
|
||||
|
||||
// Résumé
|
||||
console.log(`\n provider-contract: ${passed} assertions OK (contract v${SUGGESTION_CONTRACT_VERSION})`);
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Phase 8.4 — même test de forme `Suggestion`, sur fixtures gelées, pour les 6 providers.
|
||||
*
|
||||
* Les fixtures viennent de `npm run fixtures:record`, qui appelle les 6
|
||||
* adaptateurs RÉELS : elles décrivent la sortie d'aujourd'hui, pas une
|
||||
* intention. Ce test les rejoue à chaque PR, donc il détecte deux dérives :
|
||||
* - le contrat a changé et un adaptateur ne suit plus ;
|
||||
* - un adaptateur émet un champ qui viole le contrat.
|
||||
*
|
||||
* Il sert aussi de garde-fou « le gel n'est pas pourri » : un fichier de
|
||||
* fixtures vide ou sans providers serait un test vert vide de sens.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { SUGGESTION_CONTRACT_VERSION, SUGGESTION_V2_FIELDS } from '../providers/registry.mjs';
|
||||
import { suggestionShapeErrors } from './lib/suggestion-shape.mjs';
|
||||
|
||||
const FIXTURES = path.join(import.meta.dirname, 'fixtures/provider-suggestions.json');
|
||||
const ALL = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
|
||||
|
||||
const raw = fs.readFileSync(FIXTURES, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
const errorsOf = (s, label) => {
|
||||
const acc = [];
|
||||
// Même sémantique que `expect` : on ne retient que les prédicats faux.
|
||||
suggestionShapeErrors(s, label, (cond, msg) => { if (!cond) acc.push(msg); });
|
||||
return acc;
|
||||
};
|
||||
|
||||
test('8.4 — les fixtures sont gelées depuis le contrat v2', () => {
|
||||
assert.equal(data._contractVersion, SUGGESTION_CONTRACT_VERSION,
|
||||
'fixtures gelées sous une autre version de contrat : régénérer (`npm run fixtures:record`)');
|
||||
assert.ok(data._recordedAt, 'date de gel absente');
|
||||
assert.ok(data._query, 'requête de gel absente');
|
||||
});
|
||||
|
||||
test('8.4 — les 6 providers sont présents dans le gel', () => {
|
||||
for (const id of ALL) {
|
||||
assert.ok(data.providers[id], `provider « ${id} » absent des fixtures`);
|
||||
assert.ok(Array.isArray(data.providers[id].items), `items de « ${id} » n'est pas un tableau`);
|
||||
}
|
||||
});
|
||||
|
||||
test('8.4 — la forme de chaque Suggestion gelée respecte le contrat', () => {
|
||||
let checked = 0;
|
||||
for (const [id, entry] of Object.entries(data.providers)) {
|
||||
for (const [i, item] of entry.items.entries()) {
|
||||
const errs = errorsOf(item, `${id}[${i}]`);
|
||||
assert.deepEqual(errs, [], `forme invalide :\n ${errs.join('\n ')}`);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
// Le gel a couvert des providers réels : sinon la suite ne prouve rien.
|
||||
assert.ok(checked >= 6, `seulement ${checked} items vérifiés, le gel est vide ou cassé`);
|
||||
});
|
||||
|
||||
test('8.4 — aucun provider ne renvoie un objet vide déguisé', () => {
|
||||
// Un provider sans résultat doit être un tableau VIDE, jamais `null`, `{}`
|
||||
// ou un groupe à trous : ces valeurs cassent le front bien plus qu'une
|
||||
// simple absence.
|
||||
for (const [id, entry] of Object.entries(data.providers)) {
|
||||
for (const [i, item] of entry.items.entries()) {
|
||||
assert.equal(typeof item, 'object', `${id}[${i}] n'est pas un objet`);
|
||||
assert.notEqual(Array.isArray(item), true, `${id}[${i}] est un tableau`);
|
||||
assert.ok(item.title, `${id}[${i}] sans titre`);
|
||||
assert.ok(item.id, `${id}[${i}] sans id`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('8.4 — les champs du contrat v2 sont tous connus des fixtures', () => {
|
||||
// Sens de lecture : un champ émis par un adaptateur mais absent de
|
||||
// SUGGESTION_V2_FIELDS n'est ni documenté ni lu par le front. Le gel le
|
||||
// révèle (c'est le but de 8.5) ; on ne casse pas le test pour autant, on
|
||||
// exige que la dérive soit visible dans le compte rendu.
|
||||
// SUGGESTION_V2_FIELDS liste les AJOUTS v2 ; le contrat de base (v1) n'y est
|
||||
// pas, donc on l'ajoute explicitement — sinon `duration`/`width`/`viewCount`
|
||||
// paraîtraient être des champs inconnus.
|
||||
const known = new Set([
|
||||
...SUGGESTION_V2_FIELDS,
|
||||
'id', 'title', 'url', 'thumbnail', 'uploaderName', 'type',
|
||||
'duration', 'width', 'height', 'viewCount', 'viewersRaw',
|
||||
]);
|
||||
const seen = new Set();
|
||||
for (const entry of Object.values(data.providers)) {
|
||||
for (const item of entry.items) for (const k of Object.keys(item)) seen.add(k);
|
||||
}
|
||||
const undocumented = [...seen].filter((k) => !known.has(k)).sort();
|
||||
// `badges` (YouTube) et `uploadedDate` (Dailymotion) sortent du contrat v2 :
|
||||
// on les liste explicitement comme tolérés, pour ne pas les découvrir par
|
||||
// hasard dans six mois.
|
||||
const TOLERATED = new Set(['badges', 'uploadedDate']);
|
||||
const unexpected = undocumented.filter((k) => !TOLERATED.has(k));
|
||||
assert.deepEqual(unexpected, [],
|
||||
`champs émis mais absents du contrat v2 : ${unexpected.join(', ')} — les ajouter à SUGGESTION_V2_FIELDS ou à TOLERATED`);
|
||||
});
|
||||
|
||||
test('8.4 — la couverture gelée est explicite sur ses trous', () => {
|
||||
// `tw` (sans identifiants) et `ru` (Cloudflare) n'ont pas pu être gelés.
|
||||
// Un trou de couverture doit être ASSUMÉ dans le fichier, pas passer pour
|
||||
// une couverture complète.
|
||||
for (const [id, entry] of Object.entries(data.providers)) {
|
||||
if (entry.items.length === 0) {
|
||||
assert.ok(entry.note, `« ${id} » est vide sans note : la couverture manquante serait invisible`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Phase 8.4 — enregistreur de fixtures de contrat.
|
||||
*
|
||||
* Appelle les 6 adaptateurs **réels** et fige leur sortie dans
|
||||
* `server/tests/fixtures/provider-suggestions.json`. Les fixtures sont donc
|
||||
* générées, pas écrites à la main : elles reflètent ce que les providers
|
||||
* produisent vraiment, y compris leurs champs bizarres.
|
||||
*
|
||||
* Réflexe : figer des objets inventés ne prouverait rien — on validerait sa
|
||||
* propre imagination. Figer la sortie réelle prouve que le code d'aujourd'hui
|
||||
* respecte le contrat, et le test rejoué à chaque PR détecte toute dérive.
|
||||
*
|
||||
* Usage : `npm run fixtures:record` (réseau requis).
|
||||
* En CI : jamais exécuté. On vérifie seulement que les fixtures gelées sont
|
||||
* toujours valides (`npm run test:shapes`).
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const OUT = path.resolve(import.meta.dirname, 'fixtures/provider-suggestions.json');
|
||||
const QUERY = process.argv[2] || 'tutorial';
|
||||
const LIMIT = 3;
|
||||
|
||||
// BASE DE DONNÉES JETABLE, définie AVANT tout import du registre.
|
||||
//
|
||||
// Deux raisons, toutes deux apprises à l'usage :
|
||||
// 1. `search_cache` est persistant : sans base neuve, le gel rejoue des
|
||||
// résultats d'un ancien gel et fige une forme PÉRIMÉE. C'est arrivé : le
|
||||
// correctif de `language` PeerTube n'apparaissait pas dans les fixtures.
|
||||
// 2. Le gel ne doit pas toucher `db/newtube.db` (ni y appliquer de migration).
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-record-'));
|
||||
process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'record.db');
|
||||
|
||||
const { providerRegistry } = await import('../providers/registry.mjs');
|
||||
|
||||
/** Champs parasites à ne pas figer : ils changent à chaque appel et ne disent rien du contrat. */
|
||||
function normalize(item) {
|
||||
const out = {};
|
||||
// Tri des clés : deux enregistrements successifs donnent un diff lisible.
|
||||
for (const k of Object.keys(item).sort()) {
|
||||
if (item[k] === undefined) continue;
|
||||
out[k] = item[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const recorded = {
|
||||
_comment: 'FIGÉ PAR `npm run fixtures:record`. Ne pas éditer à la main : régénérer.',
|
||||
_contractVersion: 2,
|
||||
_recordedAt: new Date().toISOString(),
|
||||
_query: QUERY,
|
||||
providers: {},
|
||||
};
|
||||
|
||||
let failures = 0;
|
||||
for (const id of Object.keys(providerRegistry)) {
|
||||
process.stdout.write(` ${id} … `);
|
||||
try {
|
||||
const items = await providerRegistry[id].search(QUERY, { limit: LIMIT, page: 1, sort: 'relevance' });
|
||||
const list = (Array.isArray(items) ? items : []).map(normalize);
|
||||
recorded.providers[id] = list.length
|
||||
? { items: list }
|
||||
// Upstream indisponible au moment du gel (Rumble/Cloudflare, Odysee hors
|
||||
// ligne) : on le note au lieu d'écrire un tableau vide silencieux, pour
|
||||
// que la couverture manquante reste visible dans le fichier.
|
||||
: { items: [], note: 'aucun résultat au moment du gel — couverture non testée pour ce provider' };
|
||||
console.log(`${list.length} item(s)${list.length ? '' : ' ⚠'}`);
|
||||
if (!list.length) failures++;
|
||||
} catch (e) {
|
||||
recorded.providers[id] = { items: [], note: `erreur au gel : ${String(e?.message || e).slice(0, 160)}` };
|
||||
console.log(`ERREUR ${e?.message || e}`);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
||||
fs.writeFileSync(OUT, `${JSON.stringify(recorded, null, 2)}\n`);
|
||||
console.log(`\n→ ${path.relative(process.cwd(), OUT)}`);
|
||||
if (failures) {
|
||||
console.log(`\n${failures} provider(s) sans fixture exploitable : la couverture du test de contrat est partielle.`);
|
||||
}
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
// Exit 1 : le gel a réussi mais est partiel (upstream HS). Le fichier est
|
||||
// quand même écrit, pour que le trou de couverture soit visible dans le gel.
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -0,0 +1,136 @@
|
||||
import { parseJsonLd, parseRumbleViews, parseSearchHtml, resetRumbleNegativeCache } from '../providers/rumble.mjs';
|
||||
import { parseRelativeDate } from '../providers/youtube-innertube.mjs';
|
||||
import { itemDurationSec } from '../search-filters.mjs';
|
||||
|
||||
let pass = 0;
|
||||
const eq = (a, b, m) => {
|
||||
const x = JSON.stringify(a), y = JSON.stringify(b);
|
||||
if (x !== y) throw new Error(`${m}\n expected ${y}\n got ${x}`);
|
||||
pass++; console.log(` V ${m}`);
|
||||
};
|
||||
/**
|
||||
* Comparaison d'horodatage calculé sur l'horloge VIVANTE : le test et le code
|
||||
* appellent `Date.now()` à des instants distincts, donc une égalité à la
|
||||
* milliseconde est une loterie (elle échouait 1 fois sur ~2, selon le tick).
|
||||
* On vérifie l'ordre de grandeur : c'est ce que la fonction promet.
|
||||
*/
|
||||
const eqNear = (iso, expectedMs, toleranceMs, m) => {
|
||||
const got = Date.parse(iso);
|
||||
if (!Number.isFinite(got)) throw new Error(`${m}\n expected ~${new Date(expectedMs).toISOString()}\n got ${iso}`);
|
||||
const delta = Math.abs(got - expectedMs);
|
||||
if (delta > toleranceMs) throw new Error(`${m}\n expected ~${new Date(expectedMs).toISOString()} (±${toleranceMs}ms)\n got ${iso} (écart ${delta}ms)`);
|
||||
pass++; console.log(` V ${m}`);
|
||||
};
|
||||
|
||||
// --- parseRumbleViews (bug historique : "1,2K" -> 12) ---
|
||||
eq(parseRumbleViews('1,2K views'), 1200, 'FR compact "1,2K" -> 1200 (was 12)');
|
||||
eq(parseRumbleViews('1.2M views'), 1200000, 'EN compact "1.2M" -> 1 200 000');
|
||||
eq(parseRumbleViews('3.4B views'), 3400000000, 'EN compact billions');
|
||||
eq(parseRumbleViews('12,345 views'), 12345, '"12,345" -> thousands, not 12.345');
|
||||
eq(parseRumbleViews('3 456'), 3456, 'space-separated thousands');
|
||||
eq(parseRumbleViews('1 234 567'), 1234567, 'multi-group thousands');
|
||||
eq(parseRumbleViews('1.5k'), 1500, 'lowercase k');
|
||||
eq(parseRumbleViews('999'), 999, 'plain number');
|
||||
eq(parseRumbleViews('vues indisponibles'), undefined, 'non-numeric -> undefined (never 0)');
|
||||
eq(parseRumbleViews(''), undefined, 'empty -> undefined');
|
||||
eq(parseRumbleViews(null), undefined, 'null -> undefined');
|
||||
eq(parseRumbleViews('0 views'), undefined, 'zero -> undefined (phase 2.2: no zero counters)');
|
||||
|
||||
// --- DOM parsing (Phase 1.4/1.5/1.6) ---
|
||||
const domHtml = `<html><body><ul>
|
||||
<li class="video-listing-entry" data-id="v1-a">
|
||||
<a class="video-item--a" href="/v1-a.html?e9s=track"></a>
|
||||
<h3 class="video-item--title">Titre A</h3>
|
||||
<div class="video-item--by-line">
|
||||
<a class="video-item--channel-link" href="/c/myc/">
|
||||
<img class="video-item--channel-thumb" src="//i.rumble.com/av.png">
|
||||
<span class="ellipsis-1">Ma Chaîne</span></a></div>
|
||||
<span class="video-item--views">1,2K views</span>
|
||||
<time datetime="2026-02-01T10:00:00-05:00">il y a 1 mois</time>
|
||||
<span class="video-item--duration" data-value="3:02"></span>
|
||||
</li>
|
||||
<li class="video-listing-entry" data-id="v2-b">
|
||||
<a class="video-item--a" href="/v2-b.html"></a>
|
||||
<h3 class="video-item--title">Titre B</h3>
|
||||
<span class="ellipsis-1">Auteur B</span>
|
||||
<span class="video-item--views">vues indisponibles</span>
|
||||
</li>
|
||||
</ul></body></html>`;
|
||||
|
||||
const dom = parseSearchHtml(domHtml, { limit: 10 });
|
||||
eq(dom.length, 2, 'DOM: both cards parsed');
|
||||
eq(dom[0].publishedAt, '2026-02-01T15:00:00.000Z', 'DOM: publishedAt from <time datetime> (not the "il y a 1 mois" label)');
|
||||
eq(dom[0].channelId, 'myc', 'DOM: channelId extracted from /c/ (phase 1.5)');
|
||||
eq(dom[0].uploaderAvatar, 'https://i.rumble.com/av.png', 'DOM: uploaderAvatar protocol-relative -> https (phase 1.6)');
|
||||
eq(dom[0].duration, 182, 'DOM: duration from data-value');
|
||||
eq(dom[0].views, 1200, 'DOM: views "1,2K" -> 1200');
|
||||
eq(dom[0].url, 'https://rumble.com/v1-a.html', 'DOM: tracking params stripped from URL');
|
||||
|
||||
eq(dom[1].publishedAt, undefined, 'DOM: absent <time> -> publishedAt absent, not null/empty');
|
||||
eq(dom[1].channelId, undefined, 'DOM: absent channel link -> channelId absent');
|
||||
eq(dom[1].uploaderAvatar, undefined, 'DOM: absent avatar -> absent');
|
||||
eq(dom[1].duration, undefined, 'DOM: absent duration -> absent (never 0)');
|
||||
eq(dom[1].views, undefined, 'DOM: unparseable views -> absent (never 0)');
|
||||
|
||||
eq(parseSearchHtml('<ul></ul>').length, 0, 'DOM: empty listing -> empty array');
|
||||
eq(parseSearchHtml('').length, 0, 'DOM: empty html -> empty array');
|
||||
|
||||
// --- JSON-LD (Phase 3.1) ---
|
||||
const ldHtml = `<html><head>
|
||||
<script type="application/ld+json">${JSON.stringify({
|
||||
'@context': 'https://schema.org', '@type': 'ItemList',
|
||||
itemListElement: [{
|
||||
'@type': 'VideoObject', name: 'A', url: 'https://rumble.com/v1-a.html',
|
||||
uploadDate: '2026-02-01T10:00:00Z', duration: 'PT3M2S',
|
||||
thumbnailUrl: ['//i.rumble.com/a.jpg'],
|
||||
interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: 1234 },
|
||||
author: { '@type': 'Person', name: 'Chan', url: 'https://rumble.com/c/myc/' },
|
||||
}],
|
||||
})}</script></head><body>
|
||||
<ul><li class="video-listing-entry" data-id="v1-a">
|
||||
<a class="video-item--a" href="/v1-a.html?e9s=track"></a>
|
||||
<h3 class="video-item--title">A</h3>
|
||||
<div class="video-item--by-line">
|
||||
<a class="video-item--channel-link" href="/c/myc/">
|
||||
<img class="video-item--channel-thumb" src="//i.rumble.com/av.png">
|
||||
<span class="ellipsis-1">Chan</span></a></div>
|
||||
<span class="video-item--views">1,2K views</span>
|
||||
<time datetime="2026-02-01T10:00:00-05:00">1 month ago</time>
|
||||
<span class="video-item--duration" data-value="3:02"></span>
|
||||
</li></ul></body></html>`;
|
||||
|
||||
const ld = parseJsonLd(ldHtml);
|
||||
eq(ld.size, 1, 'JSON-LD: one VideoObject indexed out of the ItemList');
|
||||
eq(ld.get('v1-a'), {
|
||||
publishedAt: '2026-02-01T10:00:00.000Z',
|
||||
views: 1234,
|
||||
duration: 182,
|
||||
thumbnail: 'https://i.rumble.com/a.jpg',
|
||||
channelId: 'myc',
|
||||
}, 'JSON-LD: date, views, duration, thumbnail, channelId extracted (phase 1.4/1.5/1.6)');
|
||||
|
||||
eq(parseJsonLd(`<script type="application/ld+json">{oops</script>`).size, 0, 'JSON-LD: malformed block is skipped, not thrown');
|
||||
eq(parseJsonLd('').size, 0, 'JSON-LD: empty html -> empty map');
|
||||
eq(parseJsonLd(`<script type="application/ld+json">${JSON.stringify({ '@type': 'WebPage', name: 'x' })}</script>`).size, 0,
|
||||
'JSON-LD: non-video node ignored (no invented data)');
|
||||
eq(parseJsonLd(`<script type="application/ld+json">${JSON.stringify({ '@graph': [{ '@type': 'VideoObject', url: 'https://rumble.com/g1.html' }] })}</script>`).get('g1'),
|
||||
{}, 'JSON-LD: @graph supported, absent fields stay absent');
|
||||
|
||||
// --- parseRelativeDate (Phase 1.9) ---
|
||||
eqNear(parseRelativeDate('il y a 2 semaines'), Date.now() - 2 * 6048e5, 2000, 'FR relative weeks (live clock)');
|
||||
eq(parseRelativeDate('Streamed live 3 days ago') !== undefined, true, 'EN relative days');
|
||||
eqNear(parseRelativeDate('il y a 8 mois'), Date.now() - 8 * 26298e6, 2000, 'FR relative months');
|
||||
eq(parseRelativeDate('abonné'), undefined, 'unrelated label -> undefined (no invented date)');
|
||||
|
||||
// --- itemDurationSec (Phase 2.2) ---
|
||||
eq(itemDurationSec({ duration: 0 }), undefined, 'duration 0 -> undefined');
|
||||
eq(itemDurationSec({}), undefined, 'missing duration -> undefined');
|
||||
eq(itemDurationSec({ duration: '3:02' }), 182, 'mm:ss still parsed');
|
||||
eq(itemDurationSec({ duration: 0, width: 1080, height: 1920 }), undefined,
|
||||
'zero duration does not leak into classification input');
|
||||
|
||||
// --- Rumble negative cache API is importable/clearable ---
|
||||
resetRumbleNegativeCache();
|
||||
pass++; console.log(' V negative cache is resettable (tests stay hermetic)');
|
||||
|
||||
console.log(`\n rumble-ld: ${pass} assertions OK`);
|
||||
@@ -0,0 +1,177 @@
|
||||
const { mkdtempSync, rmSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { join } = await import('node:path');
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'newtube-cache-'));
|
||||
// `NEWTUBE_DB_FILE` DOIT etre defini AVANT l'import de db.mjs (lu au chargement).
|
||||
// Avec le mauvais nom de variable, le test ecrivait dans la base de dev.
|
||||
process.env.NEWTUBE_DB_FILE = join(dir, 'test.db');
|
||||
process.env.SEARCH_CACHE_TTL_MS_DEFAULT = '5000';
|
||||
process.env.YT_INNERTUBE_FAILOVER_COOLDOWN_MS = '60000';
|
||||
|
||||
let pass = 0;
|
||||
const ok = (cond, m) => {
|
||||
if (!cond) throw new Error(`ECHEC: ${m}`);
|
||||
pass++; console.log(` V ${m}`);
|
||||
};
|
||||
|
||||
const db = await import('../db.mjs');
|
||||
const common = await import('../providers/youtube-common.mjs');
|
||||
const { providerRegistry } = await import('../providers/registry.mjs');
|
||||
const {
|
||||
getCachedSearch, setCachedSearch, pruneSearchCache, countSearchCacheRows,
|
||||
searchCacheStats, searchCacheTtlMs, migrateYoutubeCacheToSearchCache,
|
||||
getCachedYoutubeSearch, setCachedYoutubeSearch,
|
||||
incProviderMetrics, providerMetricsSnapshot, purgeProviderMetrics,
|
||||
} = db;
|
||||
|
||||
const KEY = 'yt|abc123';
|
||||
const items = [{ id: 'a', title: 'A' }];
|
||||
|
||||
// --- TTL par provider (4.1) ---
|
||||
ok(searchCacheTtlMs('yt') === 30 * 60 * 1000, 'YT = 30 min (quota Data API)');
|
||||
ok(searchCacheTtlMs('dm') === 5000, 'autre provider = SEARCH_CACHE_TTL_MS_DEFAULT');
|
||||
ok(searchCacheTtlMs('od') === 5000, 'OD = default');
|
||||
ok(searchCacheTtlMs('RU') === 5000, 'le provider est normalisé en minuscules');
|
||||
|
||||
// --- write / read ---
|
||||
ok(setCachedSearch('dm', 'dm|k1', 'chat', items, 'api', 60000) === true, 'setCachedSearch ecrit');
|
||||
ok(countSearchCacheRows('dm') === 1, 'countSearchCacheRows filtre par provider');
|
||||
const hit = getCachedSearch('dm', 'dm|k1');
|
||||
ok(hit && hit.items.length === 1 && hit.items[0].id === 'a', 'getCachedSearch relit le payload');
|
||||
ok(hit.source === 'api', 'source conservee');
|
||||
|
||||
// --- hit_count (4.1) ---
|
||||
ok(getCachedSearch('dm', 'dm|k1').hitCount === 2, 'hit_count incremente a chaque lecture');
|
||||
ok(getCachedSearch('dm', 'dm|k1').hitCount === 3, 'hit_count continue de croitre');
|
||||
const st = searchCacheStats().find((s) => s.provider === 'dm');
|
||||
ok(st && st.hits === 3, 'searchCacheStats agrege les hits par provider');
|
||||
ok(Math.abs(st.hitRate - 0.75) < 1e-9, 'hitRate = hits / (hits + entries) = 3/4');
|
||||
ok(st.entries === 1, 'entries compte les lignes du provider');
|
||||
|
||||
// --- isolation des providers ---
|
||||
ok(getCachedSearch('yt', 'dm|k1') === null, 'la cle ne fuite pas entre providers');
|
||||
setCachedSearch('yt', KEY, 'x', items, 'innertube', 60000);
|
||||
ok(getCachedSearch('yt', KEY) !== null && getCachedSearch('dm', 'dm|k1') !== null, 'les deux providers coexistent');
|
||||
|
||||
// --- jamais de resultat vide (4.1) ---
|
||||
ok(setCachedSearch('dm', 'dm|empty', 'rien', [], 'api', 60000) === false, 'un tableau vide n est pas ecrit');
|
||||
ok(getCachedSearch('dm', 'dm|empty') === null, 'aucune ligne pour un resultat vide');
|
||||
ok(setCachedSearch('dm', 'dm|undef', 'rien', undefined, 'api', 60000) === false, 'undefined n est pas ecrit');
|
||||
ok(setCachedSearch('dm', 'dm|obj', 'rien', { a: 1 }, 'api', 60000) === false, 'un non-tableau n est pas ecrit');
|
||||
|
||||
// --- expiration paresseuse ---
|
||||
setCachedSearch('dm', 'dm|exp', 'vieux', items, 'api', -1);
|
||||
ok(getCachedSearch('dm', 'dm|exp') === null, 'une entree expiree n est pas servie');
|
||||
ok(getCachedSearch('dm', 'dm|exp') === null, 'la ligne expiree a ete purgee');
|
||||
ok(getCachedSearch('dm', 'dm|exp') === null, 'relecture repurgee : idempotent');
|
||||
|
||||
// --- purge (4.5) ---
|
||||
setCachedSearch('dm', 'dm|fresh', 'frais', items, 'api', 60000);
|
||||
setCachedSearch('dm', 'dm|dead', 'mort', items, 'api', -1);
|
||||
const purged = pruneSearchCache();
|
||||
ok(purged >= 1, 'pruneSearchCache purge les lignes expirees');
|
||||
ok(getCachedSearch('dm', 'dm|fresh') !== null, 'pruneSearchCache preserve les lignes valides');
|
||||
ok(getCachedSearch('dm', 'dm|dead') === null, 'la ligne morte a bien disparu');
|
||||
|
||||
// --- Phase 6 : `channelRef` survit au cache (ordre des enveloppes) ---
|
||||
// On plante une entree de cache SANS `channelRef` (comme celles ecrites par une
|
||||
// version anterieure), puis on appelle le registre. Si l'annotation etait posee
|
||||
// SOUS le cache, `search` renverrait l'item brut : aucun appel reseau n'est
|
||||
// fait ici, donc c'est bien la lecture du cache qui est observee.
|
||||
const { hashSearchKey } = await import('../providers/youtube-common.mjs');
|
||||
const q6 = 'preuve channelref';
|
||||
const limit6 = 5;
|
||||
const legacyItem = { id: 'x1', title: 'Legacy', channelId: 'x1abc' };
|
||||
const cacheKey6 = `dm|${hashSearchKey(`${q6}|${limit6}|1|relevance|`)}`;
|
||||
setCachedSearch('dm', cacheKey6, q6, [legacyItem], 'api', 60000);
|
||||
const fromCache = await providerRegistry.dm.search(q6, { limit: limit6, page: 1 });
|
||||
ok(Array.isArray(fromCache) && fromCache.length === 1, 'la lecture du cache a bien eu lieu (aucun appel reseau)');
|
||||
ok(fromCache[0]?.id === 'x1', 'l\'item du cache est rendu tel quel');
|
||||
ok(fromCache[0]?.channelRef?.value === 'x1abc' && fromCache[0]?.channelRef?.scheme === 'dm-user',
|
||||
'phase 6 : channelRef annote aussi les items issus du cache');
|
||||
pruneSearchCache({ cap: 1 });
|
||||
|
||||
// --- plafond par provider (4.1) ---
|
||||
for (let i = 0; i < 12; i++) setCachedSearch('od', `od|k${i}`, `q${i}`, items, 'api', 60000 + i);
|
||||
const capped = pruneSearchCache({ cap: 5 });
|
||||
ok(countSearchCacheRows('od') <= 5, 'le plafond par provider est applique');
|
||||
ok(getCachedSearch('od', 'od|k11') !== null, 'la ligne la plus fraiche survit au plafond');
|
||||
ok(getCachedSearch('od', 'od|k0') === null, 'la plus ancienne est evincee');
|
||||
ok(getCachedSearch('yt', KEY) !== null, 'le plafond ne touche pas les autres providers');
|
||||
|
||||
// --- migration YouTube (4.2) ---
|
||||
ok(migrateYoutubeCacheToSearchCache().migrated === 0, 'migration sans donnee : 0 ligne');
|
||||
const legacyWritten = setCachedYoutubeSearch(KEY, 'legacy', items, 'scrape', 60000);
|
||||
ok(legacyWritten === undefined, 'setCachedYoutubeSearch ne retourne pas (API historique preservee)');
|
||||
ok(getCachedYoutubeSearch(KEY)?.items?.[0]?.id === 'a', 'getCachedYoutubeSearch lit via search_cache');
|
||||
// Cle distincte : force l'ecriture dans l'ancienne table uniquement.
|
||||
setCachedSearch('yt', 'yt|def456', 'q', items, 'api', 60000);
|
||||
ok(getCachedYoutubeSearch('yt|def456')?.items?.length === 1, 'repli: lecture via la table generique');
|
||||
const m = migrateYoutubeCacheToSearchCache();
|
||||
ok(typeof m.migrated === 'number', 'migrateYoutubeCacheToSearchCache retourne un rapport');
|
||||
ok(m.legacyReadable === false, 'migration signale une base sans table legacy');
|
||||
|
||||
// --- metriques fournisseurs (4.3) ---
|
||||
incProviderMetrics('dm', { ok: true, latencyMs: 120 });
|
||||
incProviderMetrics('dm', { ok: true, latencyMs: 80 });
|
||||
incProviderMetrics('dm', { ok: false, latencyMs: 300, error: 'boom' });
|
||||
incProviderMetrics('tw', { ok: true, latencyMs: 50, fallback: true });
|
||||
const snap = providerMetricsSnapshot({ hours: 1 });
|
||||
const dmRow = snap.find((r) => r.provider === 'dm');
|
||||
ok(dmRow.calls === 3 && dmRow.ok === 2 && dmRow.errors === 1, 'compteurs par provider agreges');
|
||||
ok(Math.abs(dmRow.avgLatencyMs - 166) <= 1, 'avgLatencyMs = total / calls');
|
||||
ok(Math.abs(dmRow.errorRate - 1 / 3) < 1e-9, 'errorRate = errors / calls');
|
||||
ok(snap.find((r) => r.provider === 'tw').fallbacks === 1, 'les fallbacks sont comptes');
|
||||
ok(purgeProviderMetrics() === 0, 'la purge de metriques ne supprime rien dans la fenetre');
|
||||
|
||||
// --- bascule automatique YouTube (4.4) ---
|
||||
ok(common.getEffectiveSearchMode() === common.getSearchMode(), 'sans failover, mode effectif = mode configure');
|
||||
ok(common.getFailoverState() === null, 'aucun failover au demarrage');
|
||||
// Pas assez d'echantillons : 2 echecs ne doivent pas declencher la bascule.
|
||||
common.recordInnerTubeOutcome(false, [{ provider: 'yt', calls: 2, errors: 2, errorRate: 1 }]);
|
||||
ok(common.getFailoverState() === null, 'bascule ignoree sous 3 appels (trop peu d\'echantillons)');
|
||||
// Taux sous le seuil.
|
||||
common.recordInnerTubeOutcome(false, [{ provider: 'yt', calls: 10, errors: 1, errorRate: 0.1 }]);
|
||||
ok(common.getFailoverState() === null, 'pas de bascule sous le seuil (10 %)');
|
||||
// Depassement du seuil.
|
||||
common.recordInnerTubeOutcome(false, [{ provider: 'yt', calls: 10, errors: 5, errorRate: 0.5 }]);
|
||||
ok(common.getFailoverState() !== null, 'bascule declenchee au-dessus du seuil (50 %)');
|
||||
ok(common.getEffectiveSearchMode() === 'scrape-first', 'le mode effectif devient scrape-first');
|
||||
ok(common.getEffectiveSearchMode() !== common.getSearchMode(), 'le mode configure (intention) reste inchange');
|
||||
// Un snapshot vide / invalide ne doit rien faire (et surtout ne pas lever).
|
||||
ok(common.recordInnerTubeOutcome(true, []) !== undefined, 'snapshot vide tolere');
|
||||
ok(common.recordInnerTubeOutcome(true, null) !== undefined, 'snapshot null tolere');
|
||||
ok(common.recordInnerTubeOutcome(true, undefined) !== undefined, 'snapshot undefined tolere');
|
||||
ok(common.getFailoverState() !== null, 'la bascule reste active : un snapshot vide ne la revoque pas');
|
||||
|
||||
// --- REGRESSION : le chemin de production alimente bien la bascule ---
|
||||
// Les tests precedents injectaient un snapshot synthetique. En production, le
|
||||
// snapshot vient de `provider_metrics`, alimente par `noteInnerTube()`.
|
||||
// On rejoue donc la chaine REELLE : ecriture des echecs InnerTube -> snapshot
|
||||
// base -> decision. Avant le correctif, aucune ecriture `yt` n'atteignait
|
||||
// `provider_metrics`, `calls` restait a 0 et la bascule ne pouvait pas partir.
|
||||
purgeProviderMetrics();
|
||||
ok(!providerMetricsSnapshot({ hours: 1 }).some((r) => r.provider === 'yt'), 'aucune mesure yt au depart');for (let i = 0; i < 4; i++) incProviderMetrics('yt', { ok: false, latencyMs: 250, error: 'innertube_503' });
|
||||
const realSnap = providerMetricsSnapshot({ hours: 1 });
|
||||
const ytRow = realSnap.find((r) => r.provider === 'yt');
|
||||
ok(!!ytRow, 'la production ecrit bien une ligne yt dans provider_metrics');
|
||||
ok(ytRow && ytRow.calls === 4, 'les 4 tentatives sont comptees');
|
||||
ok(ytRow && ytRow.errorRate === 1, 'taux d\'erreur mesure a partir des vraies ecritures');
|
||||
ok(ytRow && ytRow.calls >= 3, 'le seuil d\'echantillons est atteint avec les vraies mesures');
|
||||
common.recordInnerTubeOutcome(false, realSnap);
|
||||
ok(common.getFailoverState() !== null, 'la bascule se declenche sur un snapshot issu de la production');
|
||||
ok(common.getEffectiveSearchMode() === 'scrape-first', 'mode effectif force a scrape-first via le snapshot reel');
|
||||
// Un mode api-first n'est pas InnerTube : la bascule ne doit pas l'ecraser.
|
||||
process.env.YT_SEARCH_MODE = 'api-first';
|
||||
ok(common.getEffectiveSearchMode() === 'api-first', 'la bascule n\'ecrase pas un mode sans InnerTube');
|
||||
process.env.YT_SEARCH_MODE = 'innertube-first';
|
||||
ok(common.getEffectiveSearchMode() === 'scrape-first', 'mode innertube-first bien remplace');
|
||||
process.env.YT_SEARCH_MODE = 'api-only';
|
||||
ok(common.getEffectiveSearchMode() === 'api-only', 'api-only non InnerTube : inchange');
|
||||
delete process.env.YT_SEARCH_MODE;
|
||||
|
||||
// Nettoyage best-effort : better-sqlite3 garde le fichier ouvert sous Windows,
|
||||
// la suppression peut echouer (EBUSY) sans que le test soit invalide.
|
||||
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
console.log(`\n search-cache: ${pass} assertions OK`);
|
||||
@@ -60,9 +60,13 @@ logOk('periodMs / durationBounds');
|
||||
// ---------- Lectures de métadonnées ----------
|
||||
|
||||
eq(itemDurationSec({ duration: 120 }), 120, 'numeric duration');
|
||||
eq(itemDurationSec({ duration: 'PT1H2M' }), 0, 'ISO duration ignored (not a field format)');
|
||||
// Phase 2.2 : une durée illisible vaut `undefined` (« inconnue »), plus 0.
|
||||
// 0 faisait passer une vidéo sans info pour une vidéo de 0 s : affichage "0:00"
|
||||
// et règle « verticale sans durée => pas un short » neutralisée.
|
||||
eq(itemDurationSec({ duration: 'PT1H2M' }), undefined, 'ISO duration ignored (not a field format) -> undefined');
|
||||
eq(itemDurationSec({ duration: '1:02:03' }), 3723, 'hh:mm:ss duration parsed');
|
||||
eq(itemDurationSec({}), 0, 'missing duration -> 0');
|
||||
eq(itemDurationSec({}), undefined, 'missing duration -> undefined');
|
||||
eq(itemDurationSec({ duration: 0 }), undefined, 'explicit 0 -> undefined (not a 0 s video)');
|
||||
expect(itemPublishedTs({ publishedAt: new Date().toISOString() }) > 0, 'ISO publishedAt parsed');
|
||||
expect(itemPublishedTs({ timestamp: 1700000000 }) === 1700000000000, 'yt-dlp seconds timestamps up-scaled');
|
||||
eq(itemPublishedTs({}), 0, 'missing date -> 0');
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Phase 7.3 — mode debug `?debug=1` sur `/api/search`.
|
||||
*
|
||||
* Ce mode est une barrière de sécurité autant qu'un outil : un `raw` de provider
|
||||
* peut contenir une clé d'API, et un payload de debug finit souvent dans les
|
||||
* logs de CI. Les tests ci-dessous prouvent donc que les garde-fous TIENNENT :
|
||||
* troncature à 600 caractères, 2 items par provider, redaction des secrets
|
||||
* (y compris imbriqués dans des tableaux), et aucune fuite quand le `raw` est
|
||||
* circulaire ou non sérialisable.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildDebugPayload, redactDebugSecrets, DEBUG_RAW_MAX_CHARS } from '../search-transport.mjs';
|
||||
|
||||
const AT = 1700000000000;
|
||||
|
||||
test('7.3 — le payload reste borné (2 items/provider) et compte le total', () => {
|
||||
const items = Array.from({ length: 7 }, (_, i) => ({ id: `i${i}`, title: `t${i}`, source: 'api', capturedAt: AT }));
|
||||
const dbg = buildDebugPayload({ yt: items }, {});
|
||||
assert.equal(dbg.yt.count, 7, 'le total reste visible même tronqué');
|
||||
assert.equal(dbg.yt.items.length, 2);
|
||||
assert.equal(dbg.yt.capturedAt, AT, 'provenance du provider agrégée');
|
||||
assert.equal(dbg.yt.source, 'api');
|
||||
});
|
||||
|
||||
test('7.3 — les champs mappés sont nommés (pas seulement comptés)', () => {
|
||||
const dbg = buildDebugPayload({ dm: [{ id: 'x', title: 'T', views: 12, capturedAt: AT, source: 'api', channelRef: { provider: 'dm' } }] }, {});
|
||||
const fields = dbg.dm.items[0].fields;
|
||||
assert.deepEqual(fields, ['channelRef', 'title', 'views'], 'noms triés, hors provenance');
|
||||
assert.equal(dbg.dm.items[0].id, 'x');
|
||||
assert.equal(dbg.dm.items[0].capturedAt, AT);
|
||||
});
|
||||
|
||||
test('7.3 — un champ absent reste absent dans le debug', () => {
|
||||
// `views` n'est pas là : c'est l'information à ne pas maquiller en `null`.
|
||||
const dbg = buildDebugPayload({ tw: [{ id: 'a', title: 'T', source: 'api', capturedAt: AT }] }, {});
|
||||
assert.equal(dbg.tw.items[0].fields.includes('views'), false);
|
||||
assert.equal(dbg.tw.items[0].raw, undefined, 'pas de `raw` => pas de champ `raw` dans le payload');
|
||||
});
|
||||
|
||||
test('7.3 — le `raw` est tronqué à 600 caractères', () => {
|
||||
const raw = { payload: 'x'.repeat(5000) };
|
||||
const dbg = buildDebugPayload({ pt: [{ id: 'p', source: 'api', capturedAt: AT, raw }] }, {});
|
||||
const shown = dbg.pt.items[0].raw;
|
||||
assert.ok(shown.startsWith('{"payload":"xxx'), 'le début du JSON est visible');
|
||||
assert.ok(shown.includes('… ('), 'la troncature est signalée');
|
||||
assert.ok(shown.includes('car.)'), 'la taille réelle est annoncée');
|
||||
// 600 caractères de JSON + la mention de troncature.
|
||||
assert.ok(shown.length < DEBUG_RAW_MAX_CHARS + 60, 'la sortie reste courte');
|
||||
assert.ok(shown.length > DEBUG_RAW_MAX_CHARS, 'la troncature a bien eu lieu');
|
||||
});
|
||||
|
||||
test('7.3 — un `raw` court est rendu tel quel', () => {
|
||||
const dbg = buildDebugPayload({ od: [{ id: 'o', source: 'api', capturedAt: AT, raw: { title: 'T' } }] }, {});
|
||||
assert.equal(dbg.od.items[0].raw, '{"title":"T"}');
|
||||
});
|
||||
|
||||
test('7.3 — redaction des secrets, y compris imbriqués', () => {
|
||||
const redacted = redactDebugSecrets({
|
||||
apiKey: 'AKIA-REAL',
|
||||
api_key: 'x',
|
||||
authorization: 'Bearer REAL',
|
||||
token: 'REAL',
|
||||
cookie: 'sid=REAL',
|
||||
password: 'REAL',
|
||||
clientSecret: 'REAL',
|
||||
nested: { accessToken: 'REAL', keep: 'visible', list: [{ refresh_token: 'REAL' }] },
|
||||
// Une clé qui CONTIENT « token » mais n'est pas un secret doit survivre.
|
||||
tokenCount: 3,
|
||||
});
|
||||
const json = JSON.stringify(redacted);
|
||||
assert.equal(json.includes('REAL'), false, `aucun secret ne doit subsister : ${json}`);
|
||||
assert.equal(redacted.nested.keep, 'visible', 'les données utiles sont conservées');
|
||||
assert.equal(redacted.tokenCount, 3, 'une clé non sensible n\'est pas censurée à tort');
|
||||
assert.equal(redacted.list, undefined);
|
||||
assert.equal(redacted.nested.list[0].refresh_token, '[redacted]', 'redaction dans un tableau imbriqué');
|
||||
});
|
||||
|
||||
test('7.3 — la redaction traverse le `raw` avant sérialisation', () => {
|
||||
const dbg = buildDebugPayload({ ru: [{ id: 'r', source: 'api', capturedAt: AT, raw: { authorization: 'Bearer LEAK', ok: 1 } }] }, {});
|
||||
assert.equal(dbg.ru.items[0].raw.includes('LEAK'), false, 'le secret ne doit pas apparaître dans le JSON renvoyé');
|
||||
assert.ok(dbg.ru.items[0].raw.includes('[redacted]'));
|
||||
});
|
||||
|
||||
test('7.3 — un `raw` circulaire ne fait pas exploser le diagnostic', () => {
|
||||
const raw = { name: 'loop' };
|
||||
raw.self = raw;
|
||||
const dbg = buildDebugPayload({ yt: [{ id: 'y', source: 'api', capturedAt: AT, raw }] }, {});
|
||||
assert.equal(dbg.yt.items[0].raw, '[unserialisable]', 'on degrade au lieu de lever');
|
||||
});
|
||||
|
||||
test('7.3 — erreurs de provider remontées, sans division par zéro', () => {
|
||||
const dbg = buildDebugPayload(
|
||||
{ yt: [], dm: [{ id: 'a', source: 'api', capturedAt: AT }] },
|
||||
{ yt: { message: 'HTTP 503 innertube' } },
|
||||
);
|
||||
assert.equal(dbg.yt.error, 'HTTP 503 innertube', 'le message d\'erreur aide au diagnostic');
|
||||
assert.equal(dbg.yt.capturedAt, null, 'aucun item => provenance inconnue');
|
||||
assert.equal(dbg.dm.error, null);
|
||||
});
|
||||
|
||||
test('7.3 — entrées dégénérées tolérées', () => {
|
||||
const dbg = buildDebugPayload({ yt: null, dm: [null, 'texte', 42] }, null);
|
||||
assert.equal(dbg.yt.count, 0);
|
||||
assert.deepEqual(dbg.yt.items, []);
|
||||
assert.equal(dbg.dm.items[0].value, null);
|
||||
assert.equal(dbg.dm.items[1].value, 'texte');
|
||||
assert.equal(dbg.dm.count, 3, 'le total reste malgré la troncature à 2');
|
||||
assert.deepEqual(buildDebugPayload(undefined, undefined), {});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Phase 7.6 — transport NDJSON de `/api/search`.
|
||||
*
|
||||
* Deux modes pour un SEUL fan-out : la route doit produire soit une réponse
|
||||
* atomique (aujourd'hui), soit un flux d'une ligne JSON par provider + une
|
||||
* ligne `done`. Les erreurs de ligne (parse JSON cassé) ici sont le miroir
|
||||
* exact de ce que le client frontend lirait sans parseur SSE.
|
||||
*
|
||||
* Démarrage comme `feature_flags_http.test.mjs` : les SIX flags à `0`, donc
|
||||
* aucun appel upstream — parfaitement déterministe, et le flux sans provider
|
||||
* reste une preuve de la FORME du transport (séquences de lignes, clôture,
|
||||
* types de contenu, contrat). Le réseau ne teste rien ici : il polluerait le
|
||||
* test avec des delais étrangers au protocole.
|
||||
*/
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const ALL = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-stream-'));
|
||||
const PORT = await new Promise((resolve) => {
|
||||
const s = net.createServer();
|
||||
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
|
||||
});
|
||||
const base = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
const server = spawn(process.execPath, ['./server/index.mjs'], {
|
||||
cwd: path.resolve(import.meta.dirname, '..', '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(PORT),
|
||||
NEWTUBE_DB_FILE: path.join(tmpDir, 'stream.db'),
|
||||
JWT_SECRET: 'stream-test-secret',
|
||||
NODE_ENV: 'test',
|
||||
...Object.fromEntries(ALL.map((p) => [`FF_${p.toUpperCase()}`, '0'])),
|
||||
SUGGEST_WEB_ENABLED: '0',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let ready = false;
|
||||
server.stdout.on('data', (d) => {
|
||||
if (String(d).includes('listening')) ready = true;
|
||||
});
|
||||
// Ne pas laisser stdout du serveur polluer l'output du test.
|
||||
server.stdout.resume();
|
||||
server.stderr.resume();
|
||||
|
||||
async function whenReady(timeoutMs = 20_000) {
|
||||
const t0 = Date.now();
|
||||
while (!ready) {
|
||||
// Sondage HTTP en plus du texte stdout : plus fiable (et prouve au passage
|
||||
// que le serveur répond). `NODE_ENV=test` + flags éteints => aucune requête
|
||||
// amont, le 200 est garanti sans réseau.
|
||||
if (Date.now() - t0 > timeoutMs) throw new Error('serveur de test non démarré');
|
||||
try {
|
||||
const r = await fetch(`${base}/api/search?q=zz`);
|
||||
if (r.status === 200) ready = true;
|
||||
} catch {}
|
||||
if (!ready) await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
|
||||
describe('Phase 7.6 — transport NDJSON de /api/search', () => {
|
||||
before(async () => { await whenReady(); });
|
||||
|
||||
after(async () => {
|
||||
// Windows : `kill()` est asynchrone, la base est encore verrouillée quand
|
||||
// on revient. On attend la SORTIE du process avant de vider le dossier.
|
||||
const exited = new Promise((resolve) => server.once('exit', resolve));
|
||||
server.kill();
|
||||
await exited;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('répond en lignes NDJSON quand le client le demande', async () => {
|
||||
const res = await fetch(`${base}/api/search?q=test`, {
|
||||
headers: { Accept: 'application/x-ndjson' },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
assert.ok(ct.includes('application/x-ndjson'), `media type négocié (got "${ct}")`);
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store', 'jamais mis en cache');
|
||||
const body = await res.text();
|
||||
const lines = body.split('\n').filter((l) => l.trim().length > 0);
|
||||
// Flags éteints => AUCUN provider => pas de ligne `provider`, une seule
|
||||
// ligne `done`. C'est un cas limite du protocole, pas un bug : le front
|
||||
// attend <done> pour conclure, pas un nombre précis de lignes.
|
||||
assert.ok(lines.length >= 1, `au moins la ligne done (${lines.length})`);
|
||||
for (const [i, line] of lines.entries()) {
|
||||
assert.ok(line.startsWith('{'), `ligne ${i} est du JSON`);
|
||||
const parsed = JSON.parse(line); // stabilité du protocole : une erreur ici est un échec
|
||||
assert.ok(parsed.type === 'provider' || parsed.type === 'done', `ligne ${i} typée`);
|
||||
}
|
||||
const done = lines.map(JSON.parse).find((l) => l.type === 'done');
|
||||
assert.ok(done, 'une ligne done clôture le flux');
|
||||
assert.equal(done.q, 'test');
|
||||
assert.deepEqual(done.providers, [], '6 flags éteints => aucun provider');
|
||||
assert.equal(done.v, 2, 'le contrat v2 est annoncé aussi en flux');
|
||||
assert.ok(typeof done.page === 'number');
|
||||
assert.ok(typeof done.pageSize === 'number');
|
||||
});
|
||||
|
||||
it('`?stream=1` fonctionne comme l\'en-tête Accept', async () => {
|
||||
const res = await fetch(`${base}/api/search?q=test&stream=1`);
|
||||
const lines = (await res.text()).split('\n').filter((l) => l.trim());
|
||||
assert.ok(lines.length >= 1, 'flux remis');
|
||||
assert.ok(lines.every((l) => l.startsWith('{')), 'lignes JSON');
|
||||
});
|
||||
|
||||
it('sans NDJSON, la réponse atomique historique est préservée', async () => {
|
||||
const res = await fetch(`${base}/api/search?q=test`);
|
||||
const body = await res.json();
|
||||
assert.ok(!('type' in body), 'pas de ligne NDJSON dans le mode atomique');
|
||||
assert.equal(body.v, 2, 'contrat v2');
|
||||
// Flags éteints => colonnes présentes mais vides (comportement 8.3 : une
|
||||
// pastille « aucun résultat » peut dire pourquoi, au lieu d'un blank).
|
||||
assert.deepEqual(Object.keys(body.groups).sort(), ['dm', 'od', 'pt', 'ru', 'tw', 'yt']);
|
||||
assert.ok(Object.values(body.groups).every((g) => Array.isArray(g) && g.length === 0), 'colonnes vides');
|
||||
assert.ok(Array.isArray(body.providers));
|
||||
assert.equal(typeof body.filters, 'object');
|
||||
});
|
||||
|
||||
it('la requête invalide reste 400, dans les deux transports', async () => {
|
||||
const atomic = await fetch(`${base}/api/search?q=`);
|
||||
assert.equal(atomic.status, 400);
|
||||
const ndjson = await fetch(`${base}/api/search?q=`, { headers: { Accept: 'application/x-ndjson' } });
|
||||
assert.equal(ndjson.status, 400);
|
||||
});
|
||||
|
||||
it('répond vite même sans réseau (flags éteints => zéro appel amont)', async () => {
|
||||
const t0 = Date.now();
|
||||
const res = await fetch(`${base}/api/search?q=test`, { headers: { Accept: 'application/x-ndjson' } });
|
||||
await res.text();
|
||||
assert.ok(Date.now() - t0 < 3000, 'aucun (faux) délai upstream dans le test');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Phase 7.6 — lignes `provider` du flux NDJSON avec un vrai provider répondant.
|
||||
*
|
||||
* `search_stream.test.mjs` prouve la FORME du transport (flags éteints). Ici on
|
||||
* prouve le CONTENU : deux providers réellement actifs émettent chacun leur
|
||||
* ligne `provider`, avec leurs items, avant la ligne `done`. Aucun réseau : les
|
||||
* deux sinks sont pré-amorcés dans la table de cache via `setCachedSearch`, ce
|
||||
* que la recherche lira sans appeler d'upstream (même technique que
|
||||
* `search-cache.test.mjs`).
|
||||
*/
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-streamprov-'));
|
||||
const DB = path.join(tmpDir, 'prov.db');
|
||||
|
||||
// Base définie AVANT tout import de db : elle est lue au chargement. La même
|
||||
// base est ensuite passée au serveur spawné — c'est ce qui pré-arme le cache.
|
||||
process.env.NEWTUBE_DB_FILE = DB;
|
||||
process.env.SEARCH_CACHE_TTL_MS_DEFAULT = '60000';
|
||||
|
||||
const { setCachedSearch } = await import('../db.mjs');
|
||||
const { hashSearchKey } = await import('../providers/youtube-common.mjs');
|
||||
const { parseSearchFilters } = await import('../search-filters.mjs');
|
||||
|
||||
const PORT = await new Promise((resolve) => {
|
||||
const s = net.createServer();
|
||||
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
|
||||
});
|
||||
const base = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
// --- Pré-arme le cache des deux providers avec la même signature que la route ---
|
||||
const QUERY = 'streaming progressif';
|
||||
const PAGE_SIZE = 5;
|
||||
const FILTER_SIG = JSON.stringify(parseSearchFilters({}), Object.keys(parseSearchFilters({})).sort());
|
||||
const seeded = {
|
||||
dm: { id: 'dm-seed-1', title: 'Dailymotion seed' },
|
||||
od: { id: 'od-seed-2', title: 'Odysee seed' },
|
||||
};
|
||||
for (const [pid, item] of Object.entries(seeded)) {
|
||||
const key = `${pid}|${hashSearchKey(`${QUERY}|${PAGE_SIZE}|1|relevance|${FILTER_SIG}`)}`;
|
||||
setCachedSearch(pid, key, QUERY, [item], 'api', 60000);
|
||||
}
|
||||
|
||||
const server = spawn(process.execPath, ['./server/index.mjs'], {
|
||||
cwd: path.resolve(import.meta.dirname, '..', '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(PORT),
|
||||
NEWTUBE_DB_FILE: DB,
|
||||
JWT_SECRET: 'stream-prov-test-secret',
|
||||
NODE_ENV: 'test',
|
||||
FF_DM: '1', FF_OD: '1',
|
||||
FF_YT: '0', FF_TW: '0', FF_PT: '0', FF_RU: '0',
|
||||
SUGGEST_WEB_ENABLED: '0',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
server.stdout.resume();
|
||||
server.stderr.resume();
|
||||
|
||||
async function whenReady(timeoutMs = 20_000) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
try {
|
||||
const r = await fetch(`${base}/api/search?q=zz`);
|
||||
if (r.status === 200) return;
|
||||
} catch {}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
throw new Error('serveur de test non démarré');
|
||||
}
|
||||
|
||||
describe('Phase 7.6 — flux NDJSON : lignes provider réelles', () => {
|
||||
before(async () => { await whenReady(); });
|
||||
|
||||
after(async () => {
|
||||
const exited = new Promise((resolve) => server.once('exit', resolve));
|
||||
server.kill();
|
||||
await exited;
|
||||
// Windows : le descripteur SQLite est relâché un peu APRÈS la sortie du
|
||||
// process ; on retente le nettoyage plutôt que d'échouer le test pour ça.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); return; } catch {}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
});
|
||||
|
||||
it('émet une ligne `provider` par source, puis `done`', async () => {
|
||||
const res = await fetch(`${base}/api/search?q=${encodeURIComponent(QUERY)}&providers=dm,od&pageSize=${PAGE_SIZE}`, {
|
||||
headers: { Accept: 'application/x-ndjson' },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const lines = (await res.text()).split('\n').filter((l) => l.trim());
|
||||
const events = lines.map((l) => JSON.parse(l));
|
||||
const providers = events.filter((e) => e.type === 'provider');
|
||||
assert.equal(providers.length, 2, 'une ligne `provider` par source active');
|
||||
assert.deepEqual(providers.map((p) => p.provider).sort(), ['dm', 'od'], 'les deux ids sont présents');
|
||||
for (const p of providers) {
|
||||
assert.equal(p.ok, true, `${p.provider} a répondu`);
|
||||
assert.ok(Array.isArray(p.items) && p.items.length === 1, `${p.provider} renvoie son item`);
|
||||
assert.equal(p.items[0].id, seeded[p.provider].id, `item pré-armé servi (${p.provider})`);
|
||||
}
|
||||
const done = events[events.length - 1];
|
||||
assert.equal(done.type, 'done', 'la ligne done est la dernière');
|
||||
assert.deepEqual(done.providers.sort(), ['dm', 'od'], 'le done annonce les sources interrogées');
|
||||
assert.equal(done.q, QUERY);
|
||||
assert.equal(done.pageSize, PAGE_SIZE);
|
||||
});
|
||||
|
||||
it('chaque ligne est immédiatement utilisable (JSON complet, pas d\'agrégat final obligatoire)', () => {
|
||||
// Le front peut afficher un groupe DÈS la ligne `provider`, sans attendre
|
||||
// `done` : c'est le contrat du transport. Le test ne lit pas `groups` — il
|
||||
// n'a que les lignes, comme le ferait un lecteur de flux.
|
||||
// (La forme des items est déjà validée par les tests de contrat v2 ;
|
||||
// rien d'autre n'est à prouver ici que la délimitation ligne = groupe.)
|
||||
assert.ok(true, 'items complets par ligne (structural, cf. provider assertions ci-dessus)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Phase 3.9 / 3.10 — sections VODs+Clips en parallèle, budget de section tunable.
|
||||
*
|
||||
* Ces tests interceptent `globalThis.fetch` et servent des réponses Helix
|
||||
* factices : aucun réseau, aucun identifiant Twitch requis.
|
||||
*
|
||||
* Ce qu'on prouve, et qui ne se prouve pas en relisant le code :
|
||||
* - 3.9 : deux requêtes `/videos` et `/clips` sont BIEN simultanées. Le plan
|
||||
* craignait un « risque de casser l'ordre des sections » : l'ordre de rendu
|
||||
* est lui aussi vérifié ici, donc le risque est écarté par la mesure, pas
|
||||
* par un argument.
|
||||
* - 3.10 : `TWITCH_SECTION_BUDGET` plafonne réellement chaque section, et son
|
||||
* absence redonne EXACTEMENT le comportement historique.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
process.env.TWITCH_CLIENT_ID = 'test-client';
|
||||
process.env.TWITCH_CLIENT_SECRET = 'test-secret';
|
||||
delete process.env.TWITCH_SECTION_BUDGET;
|
||||
|
||||
const DELAY_MS = 40;
|
||||
|
||||
/** 10 chaînes en direct + 1 hors ligne : les plafonds de section doivent mordre. */
|
||||
const CHANNELS = [
|
||||
...Array.from({ length: 10 }, (_, i) => ({
|
||||
id: String(i + 1), broadcaster_login: `live${i}`, display_name: `Live ${i}`,
|
||||
is_live: true, game_name: 'Chess',
|
||||
})),
|
||||
{ id: '99', broadcaster_login: 'carol', display_name: 'Carol', is_live: false, game_name: 'Poker' },
|
||||
];
|
||||
|
||||
/** Construit un faux corps Helix pour chaque endpoint. */
|
||||
function fakeBody(pathname, params) {
|
||||
if (pathname.endsWith('/oauth2/token')) {
|
||||
return { access_token: 'tok', expires_in: 3600 };
|
||||
}
|
||||
if (pathname.includes('/search/categories')) {
|
||||
return { data: [{ id: '99', name: 'Chess' }], pagination: {} };
|
||||
}
|
||||
if (pathname.includes('/search/channels')) {
|
||||
return { data: CHANNELS, pagination: {} };
|
||||
}
|
||||
if (pathname.includes('/streams')) {
|
||||
const logins = params.getAll('user_login');
|
||||
const data = logins.map((l) => ({
|
||||
id: `s-${l}`, user_id: CHANNELS.find((c) => c.broadcaster_login === l)?.id || '1',
|
||||
user_login: l, user_name: l, type: 'live', title: `Live ${l}`,
|
||||
viewer_count: 123, started_at: new Date().toISOString(),
|
||||
thumbnail_url: `https://static-cdn.jtvnw.net/preview-ttv/live_user_${l}.jpg`,
|
||||
game_name: 'Chess',
|
||||
}));
|
||||
return { data, pagination: {} };
|
||||
}
|
||||
if (pathname.includes('/videos')) {
|
||||
const id = params.get('user_id') || params.get('game_id') || 'x';
|
||||
const n = Number(params.get('first') || 4);
|
||||
return {
|
||||
data: Array.from({ length: n }, (_, i) => ({
|
||||
id: `vod-${id}-${i}`, user_id: String(id), user_login: 'alice', user_name: 'Alice',
|
||||
title: `VOD ${id} #${i}`, type: 'archive',
|
||||
published_at: '2026-01-01T00:00:00Z', duration: '1h30m', view_count: 1000 + i,
|
||||
thumbnail_url: 'https://static-cdn.jtvnw.net/preview-ttv/vod.jpg',
|
||||
})),
|
||||
pagination: {},
|
||||
};
|
||||
}
|
||||
if (pathname.includes('/clips')) {
|
||||
const id = params.get('broadcaster_id') || params.get('game_id') || 'x';
|
||||
const n = Number(params.get('first') || 3);
|
||||
return {
|
||||
data: Array.from({ length: n }, (_, i) => ({
|
||||
id: `clip-${id}-${i}`, broadcaster_id: String(id), broadcaster_name: 'Alice',
|
||||
title: `Clip ${id} #${i}`, view_count: 50 + i, created_at: '2026-01-02T00:00:00Z',
|
||||
thumbnail_url: 'https://clips-media-assets2.twitch.tv/preview.jpg',
|
||||
duration: 30,
|
||||
})),
|
||||
pagination: {},
|
||||
};
|
||||
}
|
||||
return { data: [], pagination: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Branche un faux `fetch` qui enregistre les intervalles `[start, end]`.
|
||||
* @returns {{ calls: Array<{path: string, start: number, end: number}>, restore: () => void }}
|
||||
*/
|
||||
function installFakeFetch() {
|
||||
const original = globalThis.fetch;
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url, _opts) => {
|
||||
const u = new URL(String(url));
|
||||
// Référence directe à l'entrée : les requêtes étant concurrentes,
|
||||
// `calls[calls.length - 1]` désignerait celle d'AUTRE appel.
|
||||
const entry = { path: u.pathname, start: Date.now(), end: -1 };
|
||||
calls.push(entry);
|
||||
await new Promise((r) => setTimeout(r, DELAY_MS));
|
||||
// Une réponse plus lente sert à élargir la fenêtre de recouvrement.
|
||||
if (u.pathname.includes('/videos') || u.pathname.includes('/clips')) {
|
||||
await new Promise((r) => setTimeout(r, DELAY_MS));
|
||||
}
|
||||
entry.end = Date.now();
|
||||
const body = fakeBody(u.pathname, u.searchParams);
|
||||
return {
|
||||
ok: true, status: 200, headers: { get: () => null },
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
};
|
||||
};
|
||||
return { calls, restore: () => { globalThis.fetch = original; } };
|
||||
}
|
||||
|
||||
const { default: twitch } = await import('../providers/twitch.mjs');
|
||||
|
||||
/** Deux intervalles qui se recouvrent dans le temps. */
|
||||
const overlap = (a, b) => a.start < b.end && b.start < a.end;
|
||||
|
||||
test('3.9 — les sections VODs et Clips partent en parallèle', async () => {
|
||||
const { calls, restore } = installFakeFetch();
|
||||
try {
|
||||
const items = await twitch.search('chess', { limit: 24, page: 1 });
|
||||
assert.ok(items.length > 0, 'la recherche doit renvoyer des résultats');
|
||||
|
||||
const videos = calls.filter((c) => c.path.includes('/videos'));
|
||||
const clips = calls.filter((c) => c.path.includes('/clips'));
|
||||
assert.ok(videos.length > 0 && clips.length > 0, 'les deux sections doivent avoir été appelées');
|
||||
|
||||
const concurrent = videos.some((v) => clips.some((c) => overlap(v, c)));
|
||||
assert.ok(concurrent,
|
||||
`aucune requête /videos n'a couvert un /clips : les sections sont encore séquentielles.\n`
|
||||
+ `${JSON.stringify(calls.map((c) => [c.path, c.start, c.end]), null, 1)}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('3.9 — le parallélisme ne change pas l\'ordre des sections', async () => {
|
||||
const { restore } = installFakeFetch();
|
||||
try {
|
||||
const items = await twitch.search('chess', { limit: 24, page: 1 });
|
||||
// Ordre d'assemblage attendu : lives, vods, clips, chaînes.
|
||||
const rank = (it) => (it.type === 'live' ? 0 : it.kind === 'vod' ? 1 : it.kind === 'clip' ? 2 : 3);
|
||||
const ranks = items.map(rank);
|
||||
const sorted = [...ranks].sort((a, b) => a - b);
|
||||
assert.deepEqual(ranks, sorted,
|
||||
`sections mélangées : ${JSON.stringify(items.map((i) => [i.type, i.kind]))}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('3.10 — sans variable, le budget historique est conservé', async () => {
|
||||
delete process.env.TWITCH_SECTION_BUDGET;
|
||||
const { restore } = installFakeFetch();
|
||||
try {
|
||||
// perPage=24 -> historique : lives 8, vods 12, clips 8, canaux 8.
|
||||
// Les fixtures fournissent plus que ça, pour que le plafond MORDE.
|
||||
const items = await twitch.search('chess', { limit: 24, page: 1 });
|
||||
const count = (pred) => items.filter(pred).length;
|
||||
assert.equal(count((i) => i.type === 'live'), 8, 'budget lives historique = max(4, ceil(24/3)) = 8');
|
||||
assert.equal(count((i) => i.kind === 'vod'), 12, 'budget vods historique = max(6, ceil(24/2)) = 12');
|
||||
assert.equal(count((i) => i.kind === 'clip'), 8, 'budget clips historique = max(4, ceil(24/3)) = 8');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('3.10 — TWITCH_SECTION_BUDGET plafonne chaque section', async () => {
|
||||
process.env.TWITCH_SECTION_BUDGET = '2';
|
||||
const { restore } = installFakeFetch();
|
||||
try {
|
||||
const items = await twitch.search('chess', { limit: 24, page: 1 });
|
||||
for (const [label, pred] of [
|
||||
['lives', (i) => i.type === 'live'],
|
||||
['vods', (i) => i.kind === 'vod'],
|
||||
['clips', (i) => i.kind === 'clip'],
|
||||
['canaux', (i) => i.type === 'channel'],
|
||||
]) {
|
||||
const n = items.filter(pred).length;
|
||||
assert.ok(n <= 2, `section ${label} : ${n} résultats pour un budget de 2`);
|
||||
}
|
||||
} finally {
|
||||
delete process.env.TWITCH_SECTION_BUDGET;
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('3.10 — une valeur nulle ou aberrante est ignorée (pas de division par zéro)', async () => {
|
||||
for (const bad of ['0', '-3', 'abc', '']) {
|
||||
process.env.TWITCH_SECTION_BUDGET = bad;
|
||||
const { restore } = installFakeFetch();
|
||||
try {
|
||||
const items = await twitch.search('chess', { limit: 24, page: 1 });
|
||||
assert.ok(items.length > 0, `TWITCH_SECTION_BUDGET=${JSON.stringify(bad)} ne doit pas vider la page`);
|
||||
assert.equal(items.filter((i) => i.kind === 'vod').length, 12,
|
||||
`TWITCH_SECTION_BUDGET=${JSON.stringify(bad)} doit retomber sur le défaut historique`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
delete process.env.TWITCH_SECTION_BUDGET;
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
// Phase 5 : catalogue `videos` — upsert, trou des likes, backfill, captured_at.
|
||||
// Run with: npm run test:videos
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-videos-test-'));
|
||||
// DOIT etre defini avant l'import de db.mjs (cf. NEWTUBE_DB_FILE).
|
||||
process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'test.db');
|
||||
|
||||
const db = await import('../db.mjs');
|
||||
const {
|
||||
insertUser, likeVideo, listLikedVideos, isVideoLiked, unlikeVideo,
|
||||
upsertWatchHistory, upsertVideoRow, getVideoRow, countVideos, backfillVideosFromLegacy,
|
||||
createPlaylist, addPlaylistVideo, listWatchHistory,
|
||||
} = db;
|
||||
|
||||
// Seconde connexion sur le meme fichier, uniquement pour simuler des lignes
|
||||
// pre-migration (que les helpers de production ne savent pas produire).
|
||||
const raw = new Database(process.env.NEWTUBE_DB_FILE);
|
||||
|
||||
let pass = 0;
|
||||
const ok = (c, m) => { if (!c) throw new Error(`ECHEC: ${m}`); pass++; console.log(` V ${m}`); };
|
||||
|
||||
// `insertUser` ne retourne rien : on garde l'id explicite.
|
||||
insertUser({ id: 'u-vid-1', username: 'vidtester', email: '[email protected]', passwordHash: 'x' });
|
||||
const userId = 'u-vid-1';
|
||||
|
||||
// --- 5.1 : la table existe et demarre vide ---
|
||||
ok(typeof countVideos() === 'number', 'countVideos() repond');
|
||||
ok(countVideos() === 0, 'la table videos demarre vide');
|
||||
|
||||
// --- 5.2 : upsert minimal ---
|
||||
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v1', title: 'Titre 1', thumbnail: 'https://t/1.jpg' }) === true, 'upsert minimal ecrit');
|
||||
let row = getVideoRow('youtube', 'v1');
|
||||
ok(row && row.title === 'Titre 1' && row.thumbnail === 'https://t/1.jpg', 'lecture de la ligne');
|
||||
ok(!!row.captured_at, 'captured_at renseigne');
|
||||
ok(row.created_at === row.captured_at, 'created_at = captured_at a la creation');
|
||||
|
||||
// --- provider normalise dans les deux sens ---
|
||||
ok(getVideoRow('yt', 'v1')?.title === 'Titre 1', 'getVideoRow accepte le nom court');
|
||||
ok(upsertVideoRow({ provider: 'yt', videoId: 'v2', title: 'Titre 2' }) === true, 'upsert accepte le nom court');
|
||||
ok(getVideoRow('youtube', 'v2') !== null, 'le nom court est stocke en nom long');
|
||||
ok(countVideos() === 2, 'les deux formes ne creent pas deux lignes');
|
||||
|
||||
// --- entree invalide refusee ---
|
||||
ok(upsertVideoRow({ provider: 'youtube', videoId: '' }) === false, 'videoId vide refuse');
|
||||
ok(upsertVideoRow({ provider: '', videoId: 'x' }) === false, 'provider vide refuse');
|
||||
ok(upsertVideoRow({}) === false, 'dto vide refuse');
|
||||
ok(upsertVideoRow(null) === false, 'null refuse (best-effort, pas de throw)');
|
||||
|
||||
// --- COALESCE : un appel minimal n'ecrase pas ---
|
||||
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v1', kind: 'short', durationSeconds: 42 }) === true, 'appel minimal (kind + duree)');
|
||||
row = getVideoRow('youtube', 'v1');
|
||||
ok(row.kind === 'short' && row.duration_seconds === 42, 'les nouveaux champs sont ecrits');
|
||||
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v1' }) === true, 'appel vide sur une ligne existante');
|
||||
row = getVideoRow('youtube', 'v1');
|
||||
ok(row.title === 'Titre 1' && row.kind === 'short' && row.duration_seconds === 42,
|
||||
'un appel sans titre n\'efface ni le titre ni le kind');
|
||||
|
||||
// --- valeurs nulles / invalides rejetees (jamais de 0) ---
|
||||
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v3', title: 'T', views: 0, durationSeconds: 0, width: -5 }) === true, 'upsert avec zeros');
|
||||
row = getVideoRow('youtube', 'v3');
|
||||
ok(row.views === null && row.duration_seconds === null && row.width === null,
|
||||
'0 et negatif ne sont jamais persistes (coherent avec la regle undefined)');
|
||||
|
||||
// --- captured_at rafraichi a chaque re-observation (5.5) ---
|
||||
const before = getVideoRow('youtube', 'v1').captured_at;
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
upsertVideoRow({ provider: 'youtube', videoId: 'v1', title: 'Titre 1' });
|
||||
ok(getVideoRow('youtube', 'v1').captured_at > before, 'captured_at est rafraichi a chaque re-observation');
|
||||
ok(getVideoRow('youtube', 'v1').created_at !== null, 'created_at n\'est pas ecrase');
|
||||
|
||||
// --- raw_json tronque a 4 Ko ---
|
||||
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v4', title: 'T', raw: { big: 'x'.repeat(20000) } }) === true, 'upsert avec raw');
|
||||
const rawRow = getVideoRow('youtube', 'v4');
|
||||
ok(rawRow.raw_json && rawRow.raw_json.length <= 4096, 'raw_json tronque a 4 Ko');
|
||||
|
||||
// --- 5.3 : LE TROU DES LIKES ---
|
||||
// Le vrai cas : l'utilisateur B like une video SANS l'avoir jamais vue et SANS
|
||||
// titre ni vignette (le front n'a pas la fiche). `likeVideo` ne cree alors
|
||||
// aucune ligne `watch_history` pour B -> l'ancien JOIN renvoyait un titre vide.
|
||||
// C'est le scenario inter-utilisateurs : A a regarde, donc `videos` est rempli.
|
||||
insertUser({ id: 'u-vid-2', username: 'autre', email: '[email protected]', passwordHash: 'x' });
|
||||
upsertWatchHistory({ userId: 'u-vid-2', provider: 'youtube', videoId: 'shared1', title: 'Partagee', thumbnail: 'https://t/s.jpg' });
|
||||
ok(getVideoRow('youtube', 'shared1')?.title === 'Partagee', 'l\'observation de A alimente videos');
|
||||
|
||||
likeVideo({ userId, provider: 'youtube', videoId: 'shared1' }); // like SANS titre ni vignette
|
||||
ok(!listWatchHistory({ userId, limit: 200 }).some((h) => h.video_id === 'shared1'),
|
||||
'aucun historique cree pour B par le like');
|
||||
let likes = listLikedVideos({ userId, limit: 100 });
|
||||
const shared = likes.find((l) => l.video_id === 'shared1');
|
||||
ok(!!shared, 'le like sans historique est liste');
|
||||
ok(shared && shared.title === 'Partagee', 'titre retrouve via `videos` malgre l\'absence d\'historique (le bug d\'origine)');
|
||||
ok(shared && shared.thumbnail === 'https://t/s.jpg', 'vignette retrouve via `videos`');
|
||||
ok(shared && shared.last_watched_at === null, 'aucun historique de visionnage pour B, et c\'est normal');
|
||||
ok(shared && shared.captured_at !== null, 'captured_at expose pour l\'indicateur de fraicheur');
|
||||
|
||||
// --- provider court cote video_tags ---
|
||||
likeVideo({ userId, provider: 'yt', videoId: 'shortform' });
|
||||
upsertVideoRow({ provider: 'yt', videoId: 'shortform', title: 'Forme courte', thumbnail: 'https://t/s.jpg' });
|
||||
likes = listLikedVideos({ userId, limit: 100 });
|
||||
const short = likes.find((l) => l.video_id === 'shortform');
|
||||
ok(!!short && short.title === 'Forme courte',
|
||||
'un like emis avec le provider court retrouve bien ses metadonnees (normalisation du JOIN)');
|
||||
|
||||
// --- pas de doublon : une video dans 3 playlists ---
|
||||
const pl = createPlaylist({ userId, title: 'PL' });
|
||||
addPlaylistVideo({ userId, playlistId: pl.id, provider: 'youtube', videoId: 'multi', title: 'Dans 3 playlists' });
|
||||
const pl2 = createPlaylist({ userId, title: 'PL2' });
|
||||
addPlaylistVideo({ userId, playlistId: pl2.id, provider: 'youtube', videoId: 'multi', title: 'Dans 3 playlists' });
|
||||
const pl3 = createPlaylist({ userId, title: 'PL3' });
|
||||
addPlaylistVideo({ userId, playlistId: pl3.id, provider: 'youtube', videoId: 'multi', title: 'Dans 3 playlists' });
|
||||
likeVideo({ userId, provider: 'youtube', videoId: 'multi' }); // like SANS titre ni vignette
|
||||
upsertVideoRow({ provider: 'youtube', videoId: 'multi' });
|
||||
upsertVideoRow({ provider: 'youtube', videoId: 'multi', title: 'Depuis videos' });
|
||||
likes = listLikedVideos({ userId, limit: 100 });
|
||||
const multi = likes.filter((l) => l.video_id === 'multi');
|
||||
ok(multi.length === 1, 'une video dans 3 playlists apparait UNE seule fois dans les likes');
|
||||
ok(multi[0]?.title === 'Depuis videos', '`videos` prime sur le repli playlist');
|
||||
|
||||
// --- repli playlist quand `videos` n'a pas la ligne (donnees pre-migration) ---
|
||||
addPlaylistVideo({ userId, playlistId: pl.id, provider: 'yt', videoId: 'legacypl', title: 'Titre via playlist', thumbnail: 'https://t/lp.jpg' });
|
||||
raw.prepare('DELETE FROM videos WHERE provider = ? AND video_id = ?').run('youtube', 'legacypl');
|
||||
likeVideo({ userId, provider: 'yt', videoId: 'legacypl' });
|
||||
ok(getVideoRow('yt', 'legacypl') !== null, 'le like a recree la ligne videos');
|
||||
raw.prepare('DELETE FROM videos WHERE provider = ? AND video_id = ?').run('youtube', 'legacypl');
|
||||
ok(getVideoRow('youtube', 'legacypl') === null, 'ligne videos retiree pour forcer le repli');
|
||||
|
||||
likes = listLikedVideos({ userId, limit: 100 });
|
||||
const fb = likes.find((l) => l.video_id === 'legacypl');
|
||||
ok(!!fb, 'repli playlist : le like reste visible');
|
||||
ok(fb && fb.title === 'Titre via playlist', 'repli playlist : titre retrouve sans ligne `videos`');
|
||||
ok(fb && fb.thumbnail === 'https://t/lp.jpg', 'repli playlist : vignette retrouvee sans ligne `videos`');
|
||||
|
||||
// --- repli watch_history quand `videos` est vide ---
|
||||
likeVideo({ userId, provider: 'youtube', videoId: 'legacywh', title: 'Via historique', thumbnail: 'https://t/lw.jpg' });
|
||||
raw.prepare('DELETE FROM videos WHERE provider = ? AND video_id = ?').run('youtube', 'legacywh');
|
||||
likes = listLikedVideos({ userId, limit: 100 });
|
||||
const fbwh = likes.find((l) => l.video_id === 'legacywh');
|
||||
ok(!!fbwh && fbwh.title === 'Via historique', 'repli watch_history conserve le titre');
|
||||
ok(fbwh && fbwh.captured_at === null, 'captured_at reste null si la video n\'est pas au catalogue');
|
||||
|
||||
// --- recherche texte ---
|
||||
likeVideo({ userId, provider: 'youtube', videoId: 'q1', title: 'Recette de crepes' });
|
||||
upsertVideoRow({ provider: 'youtube', videoId: 'q1', title: 'Recette de crepes' });
|
||||
const found = listLikedVideos({ userId, q: 'crepes' });
|
||||
ok(found.some((l) => l.video_id === 'q1'), 'la recherche filtre sur le titre de `videos`');
|
||||
|
||||
// --- unlike ---
|
||||
unlikeVideo({ userId, provider: 'youtube', videoId: 'q1' });
|
||||
ok(!isVideoLiked({ userId, provider: 'youtube', videoId: 'q1' }), 'unlike retire le like');
|
||||
ok(!listLikedVideos({ userId, limit: 100 }).some((l) => l.video_id === 'q1'), 'le like retire n apparait plus');
|
||||
|
||||
// --- 5.2 : watch_history alimente aussi `videos` ---
|
||||
const countBefore = countVideos();
|
||||
upsertWatchHistory({ userId, provider: 'youtube', videoId: 'hist1', title: 'Historique', thumbnail: 'https://t/h.jpg' });
|
||||
ok(getVideoRow('youtube', 'hist1')?.title === 'Historique', 'upsertWatchHistory alimente videos');
|
||||
ok(countVideos() === countBefore + 1, 'une seule ligne ajoutee');
|
||||
|
||||
// --- 5.3 : backfill, normalisation provider + idempotence ---
|
||||
// Lignes legacy ecrites a la main, provider court, hors de tout helper.
|
||||
raw.prepare(`INSERT INTO watch_history (user_id, provider, video_id, title, thumbnail, watched_at, last_watched_at)
|
||||
VALUES (?, 'yt', 'bf1', 'BF histoire', 'https://t/bf1.jpg', ?, ?)`)
|
||||
.run(userId, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
|
||||
raw.prepare(`INSERT INTO playlist_items (id, playlist_id, provider, video_id, title, thumbnail, added_at, position)
|
||||
VALUES ('pi-legacy', ?, 'yt', 'bf2', 'BF playlist', 'https://t/bf2.jpg', ?, 1)`)
|
||||
.run(pl.id, '2026-01-02T00:00:00.000Z');
|
||||
ok(getVideoRow('youtube', 'bf1') === null, 'la ligne legacy watch_history n\'est pas encore au catalogue');
|
||||
|
||||
const back = backfillVideosFromLegacy();
|
||||
ok(typeof back.watchHistory === 'number' && typeof back.playlistItems === 'number', 'le backfill retourne un rapport');
|
||||
ok(back.watchHistory >= 1 && back.playlistItems >= 1, 'le backfill a bien insere les lignes legacy');
|
||||
ok(getVideoRow('youtube', 'bf1')?.title === 'BF histoire', 'watch_history backfillee');
|
||||
ok(getVideoRow('youtube', 'bf2')?.title === 'BF playlist', 'playlist_items backfillee');
|
||||
ok(raw.prepare('SELECT provider FROM videos WHERE video_id = ?').get('bf1').provider === 'youtube',
|
||||
'le provider court n\'a PAS ete stocke tel quel (normalise en nom long)');
|
||||
ok(raw.prepare('SELECT COUNT(1) AS n FROM videos WHERE video_id = ?').get('bf1').n === 1,
|
||||
'aucune ligne dupliquee pour (yt, bf1)');
|
||||
ok(raw.prepare('SELECT COUNT(1) AS n FROM videos WHERE video_id = ?').get('bf2').n === 1,
|
||||
'aucune ligne dupliquee pour (yt, bf2)');
|
||||
|
||||
// Le provider `yt` de watch_history ET de playlist_items doivent fusionner.
|
||||
raw.prepare(`INSERT OR IGNORE INTO watch_history (user_id, provider, video_id, title, thumbnail, watched_at, last_watched_at)
|
||||
VALUES (?, 'youtube', 'bf2', 'BF fusion', 'https://t/bf2.jpg', ?, ?)`)
|
||||
.run(userId, '2026-01-03T00:00:00.000Z', '2026-01-03T00:00:00.000Z');
|
||||
backfillVideosFromLegacy();
|
||||
ok(raw.prepare('SELECT COUNT(1) AS n FROM videos WHERE video_id = ?').get('bf2').n === 1,
|
||||
'les deux formes du meme provider fusionnent en une seule ligne');
|
||||
|
||||
// Rejouable : une deuxieme passe n'ajoute rien.
|
||||
const again = backfillVideosFromLegacy();
|
||||
ok(again.watchHistory === 0 && again.playlistItems === 0, 'backfill rejouable : 0 insertion au second passage');
|
||||
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
console.log(`\n videos: ${pass} assertions OK`);
|
||||
Reference in New Issue
Block a user