feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
CI / build-and-test (push) Successful in 14m43s
CI / build-and-test (push) Successful in 14m43s
7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
This commit is contained in:
+632
-16
@@ -21,9 +21,45 @@ if (!fs.existsSync(dbDir)) {
|
||||
}
|
||||
|
||||
// Create DB and enable FKs
|
||||
|
||||
/**
|
||||
* Garde-fou d'isolation (Phase 5).
|
||||
*
|
||||
* Tous les tests de `server/tests/` sont nommés `*.test.mjs` et DOIVENT
|
||||
* travailler sur une base temporaire via `NEWTUBE_DB_FILE`. Sans ce garde-fou,
|
||||
* une faute de frappe sur le nom de la variable (`NEW_TUBE_DB_PATH` au lieu de
|
||||
* `NEWTUBE_DB_FILE`) fait ouvrir la base de developpement en lecture-ecriture :
|
||||
* les fixtures du test atterrissent alors chez l'utilisateur, silencieusement.
|
||||
* C'est deja arrive (lignes de cache factices dans `db/newtube.db`).
|
||||
*
|
||||
* On refuse donc explicitement, avec un message qui donne la marche a suivre,
|
||||
* plutot que de laisser la corruption se produire.
|
||||
*/
|
||||
if (!overrideDbFile && process.argv.some((a) => /\.test\.mjs$/.test(a))) {
|
||||
throw new Error(
|
||||
`[db] Refus d'ouvrir la base de developpement (${dbFile}) depuis un test. `
|
||||
+ 'Definissez process.env.NEWTUBE_DB_FILE sur un chemin temporaire AVANT '
|
||||
+ "l'import de db.mjs (le nom exact est NEWTUBE_DB_FILE, sans \"NEW_TUBE_\").",
|
||||
);
|
||||
}
|
||||
|
||||
const db = new Database(dbFile);
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
/**
|
||||
* Chemin reel du fichier ouvert.
|
||||
*
|
||||
* Expose pour que les tests puissent AFFIRMER leur isolation au lieu de la
|
||||
* supposer : un test qui se trompe de nom de variable d'environnement
|
||||
* (`NEW_TUBE_DB_PATH` au lieu de `NEWTUBE_DB_FILE`) ouvre silencieusement la
|
||||
* base de dev et y ecrit ses fixtures. Cela a deja pollue `db/newtube.db` avec
|
||||
* des lignes de cache factices. Un `ok(getDbFile().includes('test'))` en tete de
|
||||
* fichier echoue bruyamment au lieu de corrompre la base de l'utilisateur.
|
||||
*/
|
||||
export function getDbFile() {
|
||||
return dbFile;
|
||||
}
|
||||
|
||||
// Run schema if present (first boot)
|
||||
if (schemaFile && fs.existsSync(schemaFile)) {
|
||||
try {
|
||||
@@ -76,6 +112,16 @@ if (schemaFile && fs.existsSync(schemaFile)) {
|
||||
// Idempotent migrations (IF NOT EXISTS / duplicate columns) can fail on
|
||||
// databases already up to date — tolerate and continue.
|
||||
console.warn(`[db] Migration ${file} reported an error:`, e?.message || e);
|
||||
} finally {
|
||||
// Une migration `BEGIN … COMMIT` qui échoue AU MILIEU (ex. un ALTER sur une
|
||||
// colonne déjà présente) laisse la transaction ouverte dans better-sqlite3.
|
||||
// Toutes les migrations SUIVANTES s'y exécutent alors « avec succès » puis
|
||||
// sont annulées à la fermeture du process : leurs tables disparaissent
|
||||
// sans trace alors que le journal affiche « applied ». On referme donc la
|
||||
// transaction résiduelle avant de poursuivre.
|
||||
if (db.inTransaction) {
|
||||
try { db.exec('ROLLBACK'); } catch {}
|
||||
}
|
||||
}
|
||||
// Record as applied even on tolerated errors so we don't re-run/re-warn each boot.
|
||||
try {
|
||||
@@ -102,6 +148,17 @@ if (schemaFile && fs.existsSync(schemaFile)) {
|
||||
const have2 = new Set(colsItems.map(c => c.name));
|
||||
if (!have2.has('thumbnail')) db.exec(`ALTER TABLE playlist_items ADD COLUMN thumbnail TEXT`);
|
||||
} catch {}
|
||||
// Phase 7.7 : bannière + description de chaîne. Ajoutés en JS plutôt que dans
|
||||
// un fichier de migration car `db/schema.sql` est réappliqué à CHAQUE boot :
|
||||
// un `ALTER TABLE … ADD COLUMN` en migration échouerait (colonne déjà
|
||||
// présente) sur une base neuve, et ce fichier échouant laissait la
|
||||
// transaction ouverte, annulant silencieusement les migrations suivantes.
|
||||
try {
|
||||
const colsChannels = db.prepare(`PRAGMA table_info(channels)`).all();
|
||||
const haveCh = new Set(colsChannels.map(c => c.name));
|
||||
if (!haveCh.has('banner_url')) db.exec(`ALTER TABLE channels ADD COLUMN banner_url TEXT`);
|
||||
if (!haveCh.has('description')) db.exec(`ALTER TABLE channels ADD COLUMN description TEXT`);
|
||||
} catch {}
|
||||
try {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS playlist_metrics (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -422,6 +479,154 @@ export function normalizeHistoryProvider(raw) {
|
||||
export function escapeLikePattern(raw) {
|
||||
return String(raw ?? '').replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
||||
}
|
||||
|
||||
// -------------------- Phase 5 : catalogue `videos` --------------------
|
||||
/**
|
||||
* `videos` est la **seule** table qui porte les metadonnees d'une video.
|
||||
* `watch_history` et `playlist_items` les denormalisent (phase 5.4) : cette
|
||||
* duplication est le bug qui fait qu'un like sans visionnage anterieur
|
||||
* s'affiche sans titre.
|
||||
*
|
||||
* Provider : la cle est toujours le **nom long** (`normalizeHistoryProvider`).
|
||||
* `video_tags.provider` est en revanche stocke tel quel par `likeVideo` — donc
|
||||
* court ou long selon ce qu'envoie le front. C'est exactement pour cela que
|
||||
* `listLikedVideos` normalise les deux cotes de la jointure.
|
||||
*/
|
||||
function ensureVideosTable() {
|
||||
try {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS videos (
|
||||
provider TEXT NOT NULL, video_id TEXT NOT NULL,
|
||||
title TEXT, thumbnail TEXT, duration_seconds INTEGER, views INTEGER,
|
||||
published_at TEXT, url TEXT, kind TEXT,
|
||||
channel_external_id TEXT, channel_name TEXT, channel_avatar_url TEXT,
|
||||
width INTEGER, height INTEGER,
|
||||
raw_json TEXT, captured_at TEXT NOT NULL, created_at TEXT,
|
||||
PRIMARY KEY (provider, video_id)
|
||||
);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_videos_captured ON videos(captured_at DESC);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_videos_channel ON videos(provider, channel_external_id);`);
|
||||
} catch {}
|
||||
}
|
||||
ensureVideosTable();
|
||||
|
||||
/** Expression SQL normalisant une colonne provider vers le nom long. */
|
||||
function providerLongExpr(col) {
|
||||
const whens = Object.entries(HISTORY_SHORT_TO_LONG).map(([s, l]) => `WHEN '${s}' THEN '${l}'`).join(' ');
|
||||
return `(CASE LOWER(${col}) ${whens} ELSE LOWER(${col}) END)`;
|
||||
}
|
||||
|
||||
const positiveInt = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.round(v) : null);
|
||||
const nonEmptyStr = (v) => (typeof v === 'string' && v.trim().length > 0 ? v.trim() : null);
|
||||
|
||||
/**
|
||||
* Ecrit (ou met a jour) une ligne `videos`.
|
||||
*
|
||||
* **Best-effort par contrat** : une erreur ici ne doit jamais faire echouer
|
||||
* l'ecriture fonctionnelle appelante (historique, like, playlist). D'ou le
|
||||
* try/catch qui englobe tout et retourne `false`.
|
||||
*
|
||||
* Deux invariants :
|
||||
* - `captured_at` est rafraichi a chaque re-observation (c'est l'indicateur de
|
||||
* fraicheur de la phase 5.1) ;
|
||||
* - les colonnes absentes ne sont **pas** ecrasees (`COALESCE`) : un appel
|
||||
* minimal (un like ne fournit que titre + vignette) ne doit pas effacer les
|
||||
* metadonnees vues lors d'une session de lecture complete.
|
||||
*
|
||||
* @returns {boolean} true si la ligne a ete ecrite
|
||||
*/
|
||||
export function upsertVideoRow(dto) {
|
||||
try {
|
||||
const provider = normalizeHistoryProvider(dto?.provider)
|
||||
|| String(dto?.provider || '').trim().toLowerCase();
|
||||
const videoId = String(dto?.videoId ?? dto?.video_id ?? '').trim();
|
||||
if (!provider || !videoId) return false;
|
||||
ensureVideosTable();
|
||||
const now = nowIso();
|
||||
// `raw_json` est un garde-fou de debug : tronque pour ne pas faire exploser
|
||||
// la base avec le payload d'une recherche.
|
||||
let rawJson = null;
|
||||
if (dto?.raw && typeof dto.raw === 'object') {
|
||||
try { rawJson = JSON.stringify(dto.raw).slice(0, 4096); } catch { rawJson = null; }
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT INTO videos (provider, video_id, title, thumbnail, duration_seconds, views,
|
||||
published_at, url, kind, channel_external_id, channel_name,
|
||||
channel_avatar_url, width, height, raw_json, captured_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider, video_id) DO UPDATE SET
|
||||
title = COALESCE(excluded.title, videos.title),
|
||||
thumbnail = COALESCE(excluded.thumbnail, videos.thumbnail),
|
||||
duration_seconds = COALESCE(excluded.duration_seconds, videos.duration_seconds),
|
||||
views = COALESCE(excluded.views, videos.views),
|
||||
published_at = COALESCE(excluded.published_at, videos.published_at),
|
||||
url = COALESCE(excluded.url, videos.url),
|
||||
kind = COALESCE(excluded.kind, videos.kind),
|
||||
channel_external_id = COALESCE(excluded.channel_external_id, videos.channel_external_id),
|
||||
channel_name = COALESCE(excluded.channel_name, videos.channel_name),
|
||||
channel_avatar_url = COALESCE(excluded.channel_avatar_url, videos.channel_avatar_url),
|
||||
width = COALESCE(excluded.width, videos.width),
|
||||
height = COALESCE(excluded.height, videos.height),
|
||||
raw_json = COALESCE(excluded.raw_json, videos.raw_json),
|
||||
captured_at = excluded.captured_at`,
|
||||
).run(
|
||||
provider, videoId,
|
||||
nonEmptyStr(dto?.title), nonEmptyStr(dto?.thumbnail),
|
||||
positiveInt(dto?.durationSeconds ?? dto?.duration), positiveInt(dto?.views),
|
||||
nonEmptyStr(dto?.publishedAt), nonEmptyStr(dto?.url), nonEmptyStr(dto?.kind),
|
||||
nonEmptyStr(dto?.channelExternalId ?? dto?.channelId), nonEmptyStr(dto?.channelName),
|
||||
nonEmptyStr(dto?.channelAvatarUrl),
|
||||
positiveInt(dto?.width), positiveInt(dto?.height),
|
||||
rawJson, now, now,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false; // best-effort : voir le contrat ci-dessus
|
||||
}
|
||||
}
|
||||
|
||||
/** Ligne `videos` pour un couple (provider, videoId), provider court ou long. */
|
||||
export function getVideoRow(provider, videoId) {
|
||||
try {
|
||||
const p = normalizeHistoryProvider(provider) || String(provider || '').trim().toLowerCase();
|
||||
ensureVideosTable();
|
||||
return db.prepare(`SELECT * FROM videos WHERE provider = ? AND video_id = ?`).get(p, String(videoId || '')) || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function countVideos() {
|
||||
try { ensureVideosTable(); return db.prepare(`SELECT COUNT(1) AS n FROM videos`).get()?.n || 0; } catch { return 0; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 5.3 — backfill depuis les tables denormalisees, **rejouable** (la
|
||||
* migration SQL le fait une fois au deploiement ; ceci permet de rattraper des
|
||||
* lignes ecrites entre le deploiement et l'arrivee du nouveau code).
|
||||
* `INSERT OR IGNORE` : n'ecrase jamais une ligne deja observee, qui est plus
|
||||
* riche que la source de backfill.
|
||||
*/
|
||||
export function backfillVideosFromLegacy() {
|
||||
const report = { watchHistory: 0, playlistItems: 0 };
|
||||
try {
|
||||
ensureVideosTable();
|
||||
// `provider` est selectionne puis NORMALISE : `playlist_items` et
|
||||
// `watch_history` conservent la forme recue (courte ou longue) selon l'appelant,
|
||||
// alors que `videos` est cle par le nom long. Sans cette normalisation, un
|
||||
// couple ('yt', 'id') et ('youtube', 'id') creeraient deux lignes et les
|
||||
// lectures normalisees enVerifieraient toujours une des deux.
|
||||
report.watchHistory = db.prepare(
|
||||
`INSERT OR IGNORE INTO videos (provider, video_id, title, thumbnail, captured_at, created_at)
|
||||
SELECT ${providerLongExpr('provider')}, video_id, title, thumbnail, last_watched_at, last_watched_at
|
||||
FROM watch_history WHERE video_id IS NOT NULL AND title IS NOT NULL`,
|
||||
).run().changes || 0;
|
||||
report.playlistItems = db.prepare(
|
||||
`INSERT OR IGNORE INTO videos (provider, video_id, title, thumbnail, captured_at, created_at)
|
||||
SELECT ${providerLongExpr('provider')}, video_id, title, thumbnail, added_at, added_at
|
||||
FROM playlist_items WHERE video_id IS NOT NULL AND title IS NOT NULL`,
|
||||
).run().changes || 0;
|
||||
} catch {}
|
||||
return report;
|
||||
}
|
||||
|
||||
export function insertSearchHistory({ userId, query, filters }) {
|
||||
const id = cryptoRandomId();
|
||||
const created_at = nowIso();
|
||||
@@ -492,6 +697,9 @@ export function upsertWatchHistory({ userId, provider, videoId, title, thumbnail
|
||||
last_watched_at=excluded.last_watched_at`).run(
|
||||
cryptoRandomId(), userId, normProvider, videoId, title || null, thumbnail || null, watched_at, progressSeconds, durationSeconds, (typeof lastPositionSeconds === 'number' ? lastPositionSeconds : null), now
|
||||
);
|
||||
// Phase 5.2 : alimente la table `videos` (best-effort, ne doit jamais faire
|
||||
// echouer l'ecriture de l'historique).
|
||||
upsertVideoRow({ provider: normProvider, videoId, title, thumbnail, durationSeconds });
|
||||
// Return the row id
|
||||
const row = db.prepare(`SELECT * FROM watch_history WHERE user_id = ? AND provider = ? AND video_id = ?`).get(userId, normProvider, videoId);
|
||||
return row;
|
||||
@@ -701,6 +909,11 @@ export function likeVideo({ userId, provider, videoId, title, thumbnail }) {
|
||||
db.prepare(`INSERT OR IGNORE INTO video_tags (user_id, provider, video_id, tag_id, created_at) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run(userId, provider, videoId, tagId, nowIso());
|
||||
|
||||
// Phase 5.2 : on alimente `videos` meme quand aucun titre/vignette n'est fourni.
|
||||
// C'est le cas qui produisait le trou des likes : la ligne `video_tags`
|
||||
// existait mais rien d'autre ne portait les metadonnees.
|
||||
upsertVideoRow({ provider, videoId, title, thumbnail });
|
||||
|
||||
// Also update the watch_history table with the title and thumbnail
|
||||
if (title || thumbnail) {
|
||||
upsertWatchHistory({ userId, provider, videoId, title, thumbnail });
|
||||
@@ -764,22 +977,47 @@ export function listLikedVideos({ userId, limit = 100, q }) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Récupérer les vidéos aimées avec les métadonnées de l'historique
|
||||
// Note: La colonne thumbnail n'existe pas dans la table watch_history
|
||||
// Phase 5.3 : les metadonnees viennent de `videos` (catalogue partage), avec
|
||||
// repli `watch_history` puis `playlist_items` pour les lignes anterieures a
|
||||
// la migration.
|
||||
//
|
||||
// Les deux cotes sont normalises : `video_tags.provider` est stocke tel quel
|
||||
// par `likeVideo` (court ou long selon le front) alors que `videos` est
|
||||
// cle par le nom long. Sans cette normalisation, un like emis avec `yt`
|
||||
// ne trouvait aucune ligne `youtube` — le bug d'origine.
|
||||
const hasQ = typeof q === 'string' && q.trim().length > 0;
|
||||
const like = `%${(q || '').trim()}%`;
|
||||
const vtProvider = providerLongExpr('vt.provider');
|
||||
const vProvider = providerLongExpr('v.provider');
|
||||
const whProvider = providerLongExpr('wh.provider');
|
||||
// Repli playlist en sous-requete correlee et NON en jointure : `playlist_items`
|
||||
// n'est unique que par (playlist_id, provider, video_id), donc une video
|
||||
// presente dans 3 playlists y apparaitrait 3 fois dans le resultat.
|
||||
const pliTitle = `(SELECT pi.title FROM playlist_items pi
|
||||
WHERE ${providerLongExpr('pi.provider')} = ${vtProvider}
|
||||
AND pi.video_id = vt.video_id AND pi.title IS NOT NULL
|
||||
ORDER BY pi.added_at ASC LIMIT 1)`;
|
||||
const pliThumb = `(SELECT pi.thumbnail FROM playlist_items pi
|
||||
WHERE ${providerLongExpr('pi.provider')} = ${vtProvider}
|
||||
AND pi.video_id = vt.video_id AND pi.thumbnail IS NOT NULL
|
||||
ORDER BY pi.added_at ASC LIMIT 1)`;
|
||||
const titleExpr = `COALESCE(v.title, wh.title, ${pliTitle}, '')`;
|
||||
const thumbExpr = `COALESCE(v.thumbnail, wh.thumbnail, ${pliThumb}, '')`;
|
||||
const base = `
|
||||
SELECT
|
||||
vt.provider,
|
||||
SELECT
|
||||
vt.provider,
|
||||
vt.video_id,
|
||||
vt.created_at,
|
||||
COALESCE(wh.title, '') AS title,
|
||||
COALESCE(wh.thumbnail, '') AS thumbnail,
|
||||
wh.last_watched_at AS last_watched_at
|
||||
${titleExpr} AS title,
|
||||
${thumbExpr} AS thumbnail,
|
||||
wh.last_watched_at AS last_watched_at,
|
||||
v.captured_at AS captured_at
|
||||
FROM video_tags vt
|
||||
LEFT JOIN videos v
|
||||
ON v.provider = ${vProvider} AND v.video_id = vt.video_id
|
||||
LEFT JOIN watch_history wh
|
||||
ON wh.user_id = vt.user_id
|
||||
AND wh.provider = vt.provider
|
||||
ON wh.user_id = vt.user_id
|
||||
AND ${whProvider} = ${vtProvider}
|
||||
AND wh.video_id = vt.video_id
|
||||
WHERE vt.user_id = ? AND vt.tag_id = ?
|
||||
`;
|
||||
@@ -788,12 +1026,14 @@ export function listLikedVideos({ userId, limit = 100, q }) {
|
||||
LIMIT ?
|
||||
`;
|
||||
const query = hasQ
|
||||
? `${base} AND (COALESCE(wh.title,'') LIKE ? OR vt.provider LIKE ? OR vt.video_id LIKE ?)
|
||||
? `${base} AND (${titleExpr} LIKE ? OR vt.provider LIKE ? OR vt.video_id LIKE ?)
|
||||
${orderLimit}`
|
||||
: `${base} ${orderLimit}`;
|
||||
|
||||
console.log('[listLikedVideos] Exécution de la requête:', query.replace(/\s+/g, ' ').trim());
|
||||
|
||||
|
||||
// La requete est devenue longue (normalisation provider x 3, sous-requetes
|
||||
// de repli) : la logger en entier poluait la sortie a chaque appel.
|
||||
console.log(`[listLikedVideos] ${hasQ ? 'recherche' : 'liste'} pour ${userId} (tag ${tag.id})`);
|
||||
|
||||
const rows = hasQ
|
||||
? db.prepare(query).all(userId, tag.id, like, like, like, limit)
|
||||
: db.prepare(query).all(userId, tag.id, limit);
|
||||
@@ -933,6 +1173,8 @@ export function addPlaylistVideo({ userId, playlistId, provider, videoId, title,
|
||||
const id = cryptoRandomId();
|
||||
db.prepare(`INSERT OR IGNORE INTO playlist_items (id, playlist_id, provider, video_id, title, thumbnail, added_at, position)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, playlistId, provider, videoId, title || null, thumbnail || null, now, position);
|
||||
// Phase 5.2 : un ajout a une playlist est une observation de la video.
|
||||
upsertVideoRow({ provider, videoId, title, thumbnail });
|
||||
recordPlaylistMetric({ userId, playlistId, action: 'add_video', meta: { provider, videoId } });
|
||||
// Return the row (if it existed we need to fetch by key)
|
||||
const row = db.prepare(`SELECT id, playlist_id AS playlistId, provider, video_id AS videoId, title, thumbnail, added_at AS addedAt, position
|
||||
@@ -1083,6 +1325,8 @@ export function channelRowToMeta(row) {
|
||||
title: row.title || null,
|
||||
handle: row.handle || null,
|
||||
avatarUrl: row.avatar_url || null,
|
||||
bannerUrl: row.banner_url || null,
|
||||
description: row.description || null,
|
||||
url: row.url || null,
|
||||
subsCount: typeof row.subs_count === 'number' ? row.subs_count : undefined,
|
||||
verified: row.verified == null ? undefined : Boolean(row.verified),
|
||||
@@ -1090,6 +1334,13 @@ export function channelRowToMeta(row) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Double défense : l'assainissement des URLs vit dans channel-registry, mais une
|
||||
* bannière stockée en base ne doit jamais pouvoir survivre à un futur appelant
|
||||
* qui oublierait `safeMeta`. Règle locale : http(s) ou rien. */
|
||||
function sanitizeBannerUrl(value) {
|
||||
return typeof value === 'string' && /^https?:\/\//i.test(value.trim()) ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function upsertChannelRow(meta) {
|
||||
if (!meta || !meta.provider || !meta.externalId) {
|
||||
throw new Error('invalid_channel_meta');
|
||||
@@ -1101,17 +1352,21 @@ export function upsertChannelRow(meta) {
|
||||
title: meta.title || null,
|
||||
handle: meta.handle || null,
|
||||
avatar_url: meta.avatarUrl || null,
|
||||
banner_url: sanitizeBannerUrl(meta.bannerUrl),
|
||||
description: typeof meta.description === 'string' && meta.description.trim() ? meta.description.trim() : null,
|
||||
url: meta.url || null,
|
||||
subs_count: typeof meta.subsCount === 'number' ? meta.subsCount : null,
|
||||
verified: meta.verified === undefined ? null : (meta.verified ? 1 : 0),
|
||||
last_refreshed_at: meta.lastRefreshedAt ? Number(meta.lastRefreshedAt) : now,
|
||||
};
|
||||
db.prepare(`INSERT INTO channels (provider, external_id, title, handle, avatar_url, url, subs_count, verified, last_refreshed_at)
|
||||
VALUES (@provider, @external_id, @title, @handle, @avatar_url, @url, @subs_count, @verified, @last_refreshed_at)
|
||||
db.prepare(`INSERT INTO channels (provider, external_id, title, handle, avatar_url, banner_url, description, url, subs_count, verified, last_refreshed_at)
|
||||
VALUES (@provider, @external_id, @title, @handle, @avatar_url, @banner_url, @description, @url, @subs_count, @verified, @last_refreshed_at)
|
||||
ON CONFLICT(provider, external_id) DO UPDATE SET
|
||||
title=excluded.title,
|
||||
handle=excluded.handle,
|
||||
avatar_url=excluded.avatar_url,
|
||||
banner_url=excluded.banner_url,
|
||||
description=excluded.description,
|
||||
url=excluded.url,
|
||||
subs_count=excluded.subs_count,
|
||||
verified=COALESCE(excluded.verified, channels.verified),
|
||||
@@ -1136,6 +1391,8 @@ export async function ensureChannelFresh(provider, externalId, fetcher, opts = {
|
||||
title: data?.title,
|
||||
handle: data?.handle,
|
||||
avatarUrl: data?.avatarUrl,
|
||||
bannerUrl: data?.bannerUrl,
|
||||
description: data?.description,
|
||||
url: data?.url,
|
||||
subsCount: data?.subsCount,
|
||||
verified: data?.verified,
|
||||
@@ -1383,7 +1640,47 @@ function ensureYoutubeCacheTables() {
|
||||
}
|
||||
ensureYoutubeCacheTables();
|
||||
|
||||
/**
|
||||
* Task 4.2 — migration de `youtube_search_cache` vers `search_cache`.
|
||||
*
|
||||
* La clé est identique (`hashSearchKey` + préfixe `yt|`) : on copie donc les
|
||||
* lignes telles quelles, sans retransformation. La table d'origine **n'est pas
|
||||
* supprimée** : elle reste lue en repli pendant une version, ce qui rend la
|
||||
* migration réversible (`DROP TABLE search_cache` suffit à revenir en arrière).
|
||||
*
|
||||
* `INSERT OR IGNORE` : ne jamais écraser une entrée plus fraîche déjà présente
|
||||
* dans `search_cache` (une base peut avoir servi les deux tables).
|
||||
*/
|
||||
export function migrateYoutubeCacheToSearchCache() {
|
||||
try {
|
||||
ensureYoutubeCacheTables();
|
||||
ensureSearchCacheTables();
|
||||
const hasOld = db.prepare(`SELECT COUNT(1) AS n FROM youtube_search_cache`).get()?.n || 0;
|
||||
if (hasOld === 0) return { migrated: 0, remaining: 0, legacyReadable: false };
|
||||
const res = db.prepare(
|
||||
`INSERT OR IGNORE INTO search_cache (cache_key, provider, q, payload_json, item_count, source, hit_count, created_at, expires_at)
|
||||
SELECT q_hash, 'yt', q, payload_json,
|
||||
CASE WHEN json_valid(payload_json) THEN json_array_length(payload_json) ELSE 0 END,
|
||||
source, 0, created_at, expires_at
|
||||
FROM youtube_search_cache WHERE expires_at > ?`,
|
||||
).run(Date.now());
|
||||
const migrated = res.changes || 0;
|
||||
const remaining = db.prepare(`SELECT COUNT(1) AS n FROM youtube_search_cache WHERE expires_at > ?`).get(Date.now())?.n || 0;
|
||||
return { migrated, remaining, legacyReadable: true };
|
||||
} catch { return { migrated: 0, remaining: 0, legacyReadable: false, error: true }; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lecture YouTube. Essaie `search_cache` (table générique, phase 4) puis
|
||||
* **replie** sur `youtube_search_cache` : une base upgradeée depuis une version
|
||||
* antérieure peut n'avoir que des lignes dans l'ancienne table tant que la
|
||||
* migration n'a pas tourné.
|
||||
*/
|
||||
export function getCachedYoutubeSearch(qHash) {
|
||||
try {
|
||||
const generic = getCachedSearch('yt', qHash);
|
||||
if (generic) return { items: generic.items, source: generic.source };
|
||||
} catch {}
|
||||
try {
|
||||
ensureYoutubeCacheTables();
|
||||
const row = db.prepare(`SELECT payload_json AS payload, source, expires_at AS exp FROM youtube_search_cache WHERE q_hash = ?`).get(qHash);
|
||||
@@ -1396,7 +1693,10 @@ export function getCachedYoutubeSearch(qHash) {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Écrit YouTube : table générique en primaire, ancienne table en repli. */
|
||||
export function setCachedYoutubeSearch(qHash, q, items, source, ttlMs) {
|
||||
const written = setCachedSearch('yt', qHash, q, items, source, ttlMs);
|
||||
if (written) return;
|
||||
try {
|
||||
ensureYoutubeCacheTables();
|
||||
const now = Date.now();
|
||||
@@ -1411,7 +1711,8 @@ export function setCachedYoutubeSearch(qHash, q, items, source, ttlMs) {
|
||||
}
|
||||
|
||||
export function pruneYoutubeCache() {
|
||||
try { db.prepare(`DELETE FROM youtube_search_cache WHERE expires_at <= ?`).run(Date.now()); } catch {}
|
||||
// Phase 4.5 : la purge ne délaisse plus l'ancienne table derrière.
|
||||
return pruneSearchCache();
|
||||
}
|
||||
|
||||
export function incYoutubeMetrics({ scrapeCalls = 0, apiCalls = 0, quotaUnits = 0 } = {}) {
|
||||
@@ -1438,6 +1739,321 @@ export function countYoutubeCacheRows() {
|
||||
try { return db.prepare(`SELECT COUNT(1) AS n FROM youtube_search_cache`).get()?.n || 0; } catch { return 0; }
|
||||
}
|
||||
|
||||
// -------------------- Phase 4.1 : cache de recherche générique --------------------
|
||||
/**
|
||||
* Cache de recherche **générique**, multi-fournisseur.
|
||||
*
|
||||
* Remplace `youtube_search_cache` (task 4.2) sans le supprimer : les anciennes
|
||||
* fonctions YouTube restent des surcouches de compatibilité et lisent encore
|
||||
* l'ancienne table en repli.
|
||||
*
|
||||
* Choix de conception (cf. `docs/plan-phases-catalogue-classification.md`) :
|
||||
* - la clé reprend **exactement** le format YouTube `hashSearchKey`
|
||||
* (`<provider>|<sha256(q|perPage|page|sort|filtersCacheKey)>`) : une ligne
|
||||
* existante peut être migrée telle quelle, et la migration est réversible ;
|
||||
* - `hit_count` est incrémenté **à chaque lecture** (y compris les lectures
|
||||
* expirées) pour mesurer le ratio cache/total via `/api/providers/metrics` ;
|
||||
* - un résultat **vide n'est jamais persisté** : une page vide transitoire
|
||||
* (continuation expirée, raté réseau partiel) ne doit pas empoisonner le
|
||||
* cache pendant 5-30 min.
|
||||
*/
|
||||
const SEARCH_CACHE_MAX_ROWS = 2000;
|
||||
|
||||
/** TTL par fournisseur. YT garde 30 min (quota Data API), les autres 5 min. */
|
||||
export function searchCacheTtlMs(provider) {
|
||||
const p = String(provider || '').toLowerCase();
|
||||
if (p === 'yt') {
|
||||
const override = Number(process.env.SEARCH_CACHE_TTL_MS_YT);
|
||||
if (Number.isFinite(override) && override > 0) return override;
|
||||
return 30 * 60 * 1000;
|
||||
}
|
||||
const perProvider = Number(process.env[`SEARCH_CACHE_TTL_MS_${p.toUpperCase()}`]);
|
||||
if (Number.isFinite(perProvider) && perProvider > 0) return perProvider;
|
||||
const general = Number(process.env.SEARCH_CACHE_TTL_MS_DEFAULT);
|
||||
if (Number.isFinite(general) && general > 0) return general;
|
||||
return 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
function ensureSearchCacheTables() {
|
||||
try {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS search_cache (
|
||||
cache_key TEXT NOT NULL, provider TEXT NOT NULL, q TEXT NOT NULL DEFAULT '',
|
||||
payload_json TEXT NOT NULL, item_count INTEGER NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT 'api', hit_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (cache_key, provider)
|
||||
);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_search_cache_exp ON search_cache(expires_at);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_search_cache_prov ON search_cache(provider, expires_at);`);
|
||||
} catch {}
|
||||
}
|
||||
ensureSearchCacheTables();
|
||||
|
||||
/**
|
||||
* Lecture cache + bump du `hit_count`.
|
||||
* @returns {{items: any[], source: string, hitCount: number}|null}
|
||||
*/
|
||||
export function getCachedSearch(provider, cacheKey) {
|
||||
const p = String(provider || '').toLowerCase();
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
const row = db.prepare(
|
||||
`SELECT payload_json AS payload, source, expires_at AS exp, hit_count AS hits, created_at AS createdAt
|
||||
FROM search_cache WHERE cache_key = ? AND provider = ?`,
|
||||
).get(String(cacheKey), p);
|
||||
if (!row) return null;
|
||||
const hits = Number(row.hits || 0) + 1;
|
||||
try { db.prepare(`UPDATE search_cache SET hit_count = ? WHERE cache_key = ? AND provider = ?`).run(hits, String(cacheKey), p); } catch {}
|
||||
if (Date.now() >= Number(row.exp || 0)) {
|
||||
// Expiration paresseuse : la ligne est comptabilisée puis purgée.
|
||||
try { db.prepare(`DELETE FROM search_cache WHERE cache_key = ? AND provider = ?`).run(String(cacheKey), p); } catch {}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const items = JSON.parse(String(row.payload || '[]'));
|
||||
if (!Array.isArray(items)) return null;
|
||||
// `createdAt` = moment où l'amont a réellement été interrogé. C'est la
|
||||
// SEULE date de capture honnête pour un hit de cache : `Date.now()`
|
||||
// mentirait en annonçant « à l'instant » une réponse vieille de 4 min.
|
||||
return { items, source: row.source, hitCount: hits, createdAt: Number(row.createdAt || 0) || null };
|
||||
} catch { return null; }
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Écriture. Un tableau vide est ignoré (voir en-tête de section). */
|
||||
export function setCachedSearch(provider, cacheKey, q, items, source, ttlMs) {
|
||||
const p = String(provider || '').toLowerCase();
|
||||
if (!Array.isArray(items) || items.length === 0) return false;
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
const now = Date.now();
|
||||
// Un TTL explicitement fourni est respecte tel quel (y compris 0 ou
|
||||
// negatif = « deja expire », utile pour vider un cache en test) ; seule
|
||||
// l'absence de valeur (undefined/NaN) retombe sur le TTL du provider.
|
||||
const ttl = Number.isFinite(ttlMs) ? ttlMs : searchCacheTtlMs(p);
|
||||
db.prepare(
|
||||
`INSERT INTO search_cache (cache_key, provider, q, payload_json, item_count, source, hit_count, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
ON CONFLICT(cache_key, provider) DO UPDATE SET q=excluded.q, payload_json=excluded.payload_json,
|
||||
item_count=excluded.item_count, source=excluded.source, created_at=excluded.created_at,
|
||||
expires_at=excluded.expires_at`,
|
||||
) .run(String(cacheKey), p, String(q || '').slice(0, 300), JSON.stringify(items), items.length, String(source || 'api'), now, now + ttl);
|
||||
// Uniquement le plafond : la purge des lignes expirees est un travail de
|
||||
// fond (intervalle 10 min, cf. 4.5) et ne doit pas coutir un DELETE par
|
||||
// ecriture.
|
||||
enforceSearchCacheCap(p, SEARCH_CACHE_MAX_ROWS);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3.12 — persistance des `pageToken` de contenu de chaîne.
|
||||
*
|
||||
* L'API YouTube pagine avec des jetons opaques, pas des numéros de page : pour
|
||||
* atteindre la page N il faut le jeton produit en chargeant la page N-1. Ces
|
||||
* jetons vivaient dans une Map **process-local**, ce qui avait deux conséquences
|
||||
* réelles :
|
||||
* 1. un redémarrage ou un passage derrière un load-balancer repart de la page 1 ;
|
||||
* 2. au-delà de 500 clés, la Map faisait `clear()` — donc la pagination était
|
||||
* réinitialisée SANS AUCUN SIGNE, silencieusement.
|
||||
*
|
||||
* On garde la Map comme cache L1 (rapide, et évite un accès SQLite par lecture)
|
||||
* et on déporte la persistance dans `search_cache` (L2), ce qui couvre le
|
||||
* redémarrage et le scale horizontal. Un éviction du L1 ne perd plus rien.
|
||||
*
|
||||
*.Namespace de provider distinct (`yt_tokens`) : ces lignes ne sont pas des
|
||||
* résultats de recherche, et les mélanger aux lignes `yt` fausserait le plafond
|
||||
* par fournisseur et les statistiques de cache.
|
||||
*/
|
||||
const PAGE_TOKEN_PROVIDER = 'yt_tokens';
|
||||
const PAGE_TOKEN_TTL_MS = 5 * 60e3;
|
||||
|
||||
export function getCachedPageTokens(cacheKey) {
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
const row = db.prepare(
|
||||
`SELECT payload_json AS payload, expires_at AS exp FROM search_cache WHERE cache_key = ? AND provider = ?`,
|
||||
).get(String(cacheKey), PAGE_TOKEN_PROVIDER);
|
||||
if (!row) return null;
|
||||
if (Number(row.exp || 0) <= Date.now()) {
|
||||
// Expiré : on ne le sert pas, et on le retire pour ne pas le relire.
|
||||
try { db.prepare(`DELETE FROM search_cache WHERE cache_key = ? AND provider = ?`).run(String(cacheKey), PAGE_TOKEN_PROVIDER); } catch {}
|
||||
return null;
|
||||
}
|
||||
const tokens = JSON.parse(row.payload);
|
||||
return Array.isArray(tokens) ? tokens : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function setCachedPageTokens(cacheKey, tokens, ttlMs = PAGE_TOKEN_TTL_MS) {
|
||||
if (!Array.isArray(tokens) || tokens.length === 0) return false;
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
const now = Date.now();
|
||||
const ttl = Number.isFinite(ttlMs) ? ttlMs : PAGE_TOKEN_TTL_MS;
|
||||
// Normalisation ICI, à la frontière de persistance, et pas chez l'appelant :
|
||||
// `JSON.stringify` transforme un trou (`undefined`) en `null`, et un `filter`
|
||||
// décalerait les index — le jeton de la page 2 se retrouverait à l'index 0
|
||||
// et la page 3 renverrait celui de la page 4. Une pagination silencieusement
|
||||
// décalée est indétectable en production ; on garantit donc l'indexation à
|
||||
// l'écriture, pour tout appelant présent ou futur. `''` reste falsy, donc
|
||||
// la lecture la traite comme un jeton absent.
|
||||
const dense = Array.from({ length: tokens.length }, (_, i) => {
|
||||
const t = tokens[i];
|
||||
return typeof t === 'string' && t.length > 0 ? t : '';
|
||||
});
|
||||
db.prepare(
|
||||
`INSERT INTO search_cache (cache_key, provider, q, payload_json, item_count, source, hit_count, created_at, expires_at)
|
||||
VALUES (?, ?, '', ?, ?, 'page_token', 0, ?, ?)
|
||||
ON CONFLICT(cache_key, provider) DO UPDATE SET payload_json=excluded.payload_json,
|
||||
item_count=excluded.item_count, created_at=excluded.created_at, expires_at=excluded.expires_at`,
|
||||
).run(String(cacheKey), PAGE_TOKEN_PROVIDER, JSON.stringify(dense), dense.length, now, now + ttl);
|
||||
enforceSearchCacheCap(PAGE_TOKEN_PROVIDER, 500);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Vide les jetons persistés (tests, et invalidation manuelle). */
|
||||
export function clearCachedPageTokens() {
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
db.prepare(`DELETE FROM search_cache WHERE provider = ?`).run(PAGE_TOKEN_PROVIDER);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Plafond par fournisseur : purge par `expires_at DESC`, donc les plus
|
||||
* fraîches survivent (identique au plafond historique de `youtube_search_cache`).
|
||||
*/
|
||||
function enforceSearchCacheCap(provider, cap = SEARCH_CACHE_MAX_ROWS) {
|
||||
try {
|
||||
db.prepare(
|
||||
`DELETE FROM search_cache WHERE provider = ? AND cache_key NOT IN (
|
||||
SELECT cache_key FROM search_cache WHERE provider = ? ORDER BY expires_at DESC LIMIT ?)`,
|
||||
).run(String(provider), String(provider), Math.max(1, cap));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge de fond : supprime les lignes expirees, puis applique le plafond.
|
||||
* Appelee par l'intervalle de 10 min au boot (task 4.5).
|
||||
*/
|
||||
export function pruneSearchCache({ cap = SEARCH_CACHE_MAX_ROWS } = {}) {
|
||||
let purged = 0;
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
purged = db.prepare(`DELETE FROM search_cache WHERE expires_at <= ?`).run(Date.now()).changes || 0;
|
||||
} catch {}
|
||||
try {
|
||||
for (const r of db.prepare(`SELECT DISTINCT provider FROM search_cache`).all()) {
|
||||
enforceSearchCacheCap(r.provider, cap);
|
||||
}
|
||||
} catch {}
|
||||
return purged;
|
||||
}
|
||||
|
||||
export function countSearchCacheRows(provider) {
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
const p = provider ? String(provider).toLowerCase() : null;
|
||||
const row = p
|
||||
? db.prepare(`SELECT COUNT(1) AS n FROM search_cache WHERE provider = ?`).get(p)
|
||||
: db.prepare(`SELECT COUNT(1) AS n FROM search_cache`).get();
|
||||
return row?.n || 0;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
/** Ratio hit/total par fournisseur — alimenta `/api/providers/metrics`. */
|
||||
export function searchCacheStats() {
|
||||
try {
|
||||
ensureSearchCacheTables();
|
||||
return db.prepare(
|
||||
`SELECT provider, COUNT(1) AS entries, COALESCE(SUM(hit_count), 0) AS hits,
|
||||
COALESCE(SUM(item_count), 0) AS items
|
||||
FROM search_cache GROUP BY provider`,
|
||||
).all().map((r) => ({ ...r, hitRate: r.hits + r.entries > 0 ? r.hits / (r.hits + r.entries) : 0 }));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// -------------------- Phase 4.3 : metriques fournisseurs --------------------
|
||||
/**
|
||||
* Compteurs par fenetre horaire et par fournisseur. Generalise `youtube_metrics`
|
||||
* (qui reste en place pour le quota Data API, granularite jour).
|
||||
*
|
||||
* Volontairement **fin** : une ligne par (heure, provider), purgee au-dela de
|
||||
* `PROVIDER_METRICS_RETENTION_H` (defaut 24 h). La bascule automatique (4.4) lit
|
||||
* la fenetre d'1 h ; le cache lit `hit_count` de `search_cache`.
|
||||
*/
|
||||
function ensureProviderMetricsTable() {
|
||||
try {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS provider_metrics (
|
||||
hour TEXT NOT NULL, provider TEXT NOT NULL,
|
||||
calls INTEGER NOT NULL DEFAULT 0, ok_calls INTEGER NOT NULL DEFAULT 0,
|
||||
errors INTEGER NOT NULL DEFAULT 0, fallback_calls INTEGER NOT NULL DEFAULT 0,
|
||||
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT, updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (hour, provider)
|
||||
);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_provider_metrics_hour ON provider_metrics(hour);`);
|
||||
} catch {}
|
||||
}
|
||||
ensureProviderMetricsTable();
|
||||
|
||||
/** Incrémente les compteurs d'un appel fournisseur terminé. */
|
||||
export function incProviderMetrics(provider, { ok = true, latencyMs = 0, fallback = false, error = null } = {}) {
|
||||
try {
|
||||
ensureProviderMetricsTable();
|
||||
const p = String(provider || 'unknown').toLowerCase();
|
||||
const now = new Date();
|
||||
const hour = now.toISOString().slice(0, 13); // AAAA-MM-JJTHH
|
||||
const lat = Number.isFinite(latencyMs) && latencyMs > 0 ? Math.min(Math.round(latencyMs), 600000) : 0;
|
||||
db.prepare(
|
||||
`INSERT INTO provider_metrics (hour, provider, calls, ok_calls, errors, fallback_calls, total_latency_ms, last_error, updated_at)
|
||||
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(hour, provider) DO UPDATE SET
|
||||
calls = calls + 1,
|
||||
ok_calls = ok_calls + excluded.ok_calls,
|
||||
errors = errors + excluded.errors,
|
||||
fallback_calls = fallback_calls + excluded.fallback_calls,
|
||||
total_latency_ms = total_latency_ms + excluded.total_latency_ms,
|
||||
last_error = COALESCE(excluded.last_error, provider_metrics.last_error),
|
||||
updated_at = excluded.updated_at`,
|
||||
).run(hour, p, ok ? 1 : 0, ok ? 0 : 1, fallback ? 1 : 0, lat, error ? String(error).slice(0, 500) : null, now.toISOString());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Instantané par fournisseur sur la fenetre demandee (defaut 1 h). */
|
||||
export function providerMetricsSnapshot({ hours = 1 } = {}) {
|
||||
try {
|
||||
ensureProviderMetricsTable();
|
||||
const since = new Date(Date.now() - Math.max(1, hours) * 3600e3).toISOString().slice(0, 13);
|
||||
const rows = db.prepare(
|
||||
`SELECT provider, COALESCE(SUM(calls),0) AS calls, COALESCE(SUM(ok_calls),0) AS okCalls,
|
||||
COALESCE(SUM(errors),0) AS errors, COALESCE(SUM(fallback_calls),0) AS fallbacks,
|
||||
COALESCE(SUM(total_latency_ms),0) AS totalLatencyMs
|
||||
FROM provider_metrics WHERE hour >= ? GROUP BY provider`,
|
||||
).all(since);
|
||||
return rows.map((r) => ({
|
||||
provider: r.provider,
|
||||
calls: r.calls,
|
||||
ok: r.okCalls,
|
||||
errors: r.errors,
|
||||
fallbacks: r.fallbacks,
|
||||
avgLatencyMs: r.calls > 0 ? Math.round(r.totalLatencyMs / r.calls) : 0,
|
||||
errorRate: r.calls > 0 ? r.errors / r.calls : 0,
|
||||
}));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export function purgeProviderMetrics() {
|
||||
try {
|
||||
const hours = Math.max(1, Number(process.env.PROVIDER_METRICS_RETENTION_H || 24));
|
||||
const before = new Date(Date.now() - hours * 3600e3).toISOString().slice(0, 13);
|
||||
return db.prepare(`DELETE FROM provider_metrics WHERE hour < ?`).run(before).changes || 0;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
// -------------------- OAuth : connexions Google / Twitch (import favoris/abos) --------------------
|
||||
function ensureOAuthTables() {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user