feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
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:
2026-09-30 07:57:11 -04:00
parent 37681c4f53
commit 665a0f0ebd
87 changed files with 8850 additions and 550 deletions
+632 -16
View File
@@ -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
View File
@@ -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 ---
+149 -51
View File
@@ -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);
}
+179
View File
@@ -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;
}
+120 -20
View File
@@ -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;
+97
View File
@@ -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 };
}
+13 -1
View File
@@ -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 } : {}),
};
+27
View File
@@ -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 } : {}),
+276 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+78
View File
@@ -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;
+45 -1
View File
@@ -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
View File
@@ -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();
+13 -3
View File
@@ -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;
}
+96
View File
@@ -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;
}
+103
View File
@@ -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');
});
});
+278
View File
@@ -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 {}
});
+188
View File
@@ -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`);
+202
View File
@@ -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, []);
}
});
});
+112
View File
@@ -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
View File
@@ -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"
}
}
}
+103
View File
@@ -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)}`);
+105
View File
@@ -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 };
+84
View File
@@ -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 {}
});
+144
View File
@@ -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 {}
});
+165
View File
@@ -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})`);
+111
View File
@@ -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`);
}
}
});
+86
View File
@@ -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);
+136
View File
@@ -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`);
+177
View File
@@ -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`);
+6 -2
View File
@@ -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');
+110
View File
@@ -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), {});
});
+142
View File
@@ -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)');
});
});
+202
View File
@@ -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;
});
+197
View File
@@ -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`);