feat(mcp): serveur MCP stdio 7 outils + guide API/MCP + couverture tests
CI / build-and-test (push) Successful in 14m3s

- mcp/server.mjs : search, suggest, details, trending, transcript,
  transcript_text, health_check (client fin vers API REST)
- docs/API_MCP_GUIDE.md : reference complete REST + MCP + transcript
- tests : server/tests/api_coverage.test.mjs (17 cas HTTP),
  mcp/tools.test.mjs (7 outils sur stub), scripts test:mcp/test:api
- fix(db): DELETE playlist 500 FK -> metrique avant delete + cleanup
  enfants + recordPlaylistMetric tolerant (observabilite)
This commit is contained in:
2026-09-27 18:39:26 -04:00
parent d0d1c07535
commit 1812b86dd4
8 changed files with 1025 additions and 13 deletions
+19 -8
View File
@@ -771,14 +771,20 @@ export function listLikedVideos({ userId, limit = 100, q }) {
}
// -------------------- Playlists --------------------
// Metrics are observability-only: they must never break the core operation
// (e.g. a playlist DELETE must stay 204 even if the metrics insert fails).
export function recordPlaylistMetric({ userId, playlistId, action, meta }) {
const id = cryptoRandomId();
const created_at = nowIso();
const meta_json = meta ? JSON.stringify(meta) : null;
db.prepare(`INSERT INTO playlist_metrics (id, user_id, playlist_id, action, meta_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)`)
.run(id, userId, playlistId, action, meta_json, created_at);
return { id, created_at };
try {
const id = cryptoRandomId();
const created_at = nowIso();
const meta_json = meta ? JSON.stringify(meta) : null;
db.prepare(`INSERT INTO playlist_metrics (id, user_id, playlist_id, action, meta_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)`)
.run(id, userId, playlistId, action, meta_json, created_at);
return { id, created_at };
} catch {
return null;
}
}
export function createPlaylist({ userId, title, description, thumbnail, isPrivate = true }) {
@@ -864,8 +870,13 @@ export function deletePlaylist({ userId, id }) {
const cur = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(id);
if (!cur) return { removed: false };
if (cur.user_id !== userId) return 'forbidden';
// Record BEFORE the row disappears: playlist_metrics.playlist_id references
// playlists(id), so inserting after the DELETE violates the FK constraint.
try { recordPlaylistMetric({ userId, playlistId: id, action: 'delete' }); } catch {}
// Explicit child cleanup (works even on old DBs whose FK lacks ON DELETE CASCADE).
try { db.prepare(`DELETE FROM playlist_items WHERE playlist_id = ?`).run(id); } catch {}
try { db.prepare(`DELETE FROM playlist_metrics WHERE playlist_id = ?`).run(id); } catch {}
const info = db.prepare(`DELETE FROM playlists WHERE id = ?`).run(id);
recordPlaylistMetric({ userId, playlistId: id, action: 'delete' });
return { removed: (info.changes || 0) > 0 };
}
+12 -5
View File
@@ -3387,6 +3387,10 @@ async function transcriptViaYtDlp(url, langs, netOpts = {}) {
noWarnings: true,
noCheckCertificates: true,
noPlaylist: true,
// Espace les requêtes sous-titres : les rafales déclenchent des 429
// YouTube que les retries immédiats ne font qu'aggraver.
sleepRequests: 2,
sleepSubtitles: 5,
...netOpts,
output: path.join(dir, '%(id)s'),
});
@@ -3515,11 +3519,11 @@ r.get('/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
const allowedSet = new Set(allowed.map(transcriptPrimary));
const keepAllowed = (cands) => (cands || []).filter(c => allowedSet.has(transcriptPrimary(c.lang)));
const baseCands = keepAllowed(normalized === 'youtube'
? firstPerLanguage(orderedTracks(meta, lang), 10)
? firstPerLanguage(orderedTracks(meta, lang), 6)
: orderedTracks(meta, lang).slice(0, 5));
// Server-side auto-translation only towards an allowed target language.
const candidates = normalized === 'youtube'
? baseCands.concat(keepAllowed(translatedFallbacks(baseCands, lang))).slice(0, 12)
? baseCands.concat(keepAllowed(translatedFallbacks(baseCands, lang))).slice(0, 8)
: baseCands;
for (const cand of candidates) {
let attempt = 0;
@@ -3534,11 +3538,12 @@ r.get('/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
} catch (e) {
const msg = String(e?.message || e);
console.warn('[transcript] track fetch failed:', cand.lang, msg);
// One retry after a short pause on transient 429s.
if (msg.includes(':429') && attempt === 0) {
// Backoff exponentiel + jitter sur 429 (buckets YouTube à la minute) :
// 1 retry @1.5s ne suffit pas quand l'IP est limitée.
if (msg.includes(':429') && attempt < 2) {
sawRateLimit = true;
attempt += 1;
await sleep(1500);
await sleep([2000, 8000][attempt - 1] + Math.floor(Math.random() * 1000));
continue;
}
if (msg.includes(':429')) sawRateLimit = true;
@@ -3546,6 +3551,8 @@ r.get('/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
}
}
if (lines.length) break;
// Refroidissement entre candidats quand YouTube limite (évite d'aggraver le 429).
if (sawRateLimit) await sleep(700);
}
if (!lines || lines.length === 0) {
// Direct timedtext fetches failed (429 / Sorry pages / impersonation):
+194
View File
@@ -0,0 +1,194 @@
// Couverture HTTP des routes API non couvertes par les autres suites :
// health, search/suggest (400 + fallback), transcript (400), trending (400 + shape),
// ai/status, openapi, auth (register/login/me), preferences, playlists CRUD,
// likes, history search/watch, subscriptions, telemetry, download jobs.
// Offline-safe : aucune assertion sur le contenu provider externe, uniquement
// les formes stables (status, clés JSON). yt-dlp n'est jamais invoqué avec succès.
// Run: npm run test:api
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-api-cov-'));
const dbPath = path.join(tmpDir, 'cov.db');
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: dbPath, JWT_SECRET: 'cov-test-secret', NODE_ENV: 'test' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let logs = '';
server.stdout.on('data', (d) => { logs += d; });
server.stderr.on('data', (d) => { logs += d; });
async function waitUp(timeout = 25000) {
const t0 = Date.now();
while (Date.now() - t0 < timeout) {
try { const r = await fetch(`${base}/api/health`); if (r.status < 500) return true; } catch {}
await new Promise((r) => setTimeout(r, 300));
}
return false;
}
const up = await waitUp();
assert.ok(up, `API ne démarre pas:\n${logs.slice(-3000)}`);
const J = async (url, opts = {}) => {
const r = await fetch(url, opts);
const body = await r.json().catch(() => ({}));
return { status: r.status, body };
};
const auth = (t) => ({ Authorization: `Bearer ${t}` });
const uniq = (p) => `${p}_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
let token;
const user = uniq('covuser');
before(async () => {
const reg = await J(`${base}/api/auth/register`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: user, password: 'Passw0rd!' }),
});
assert.ok([201, 409].includes(reg.status), `register ${reg.status}`);
const login = await J(`${base}/api/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: user, password: 'Passw0rd!' }),
});
assert.equal(login.status, 200);
assert.ok(login.body.accessToken);
token = login.body.accessToken;
});
after(() => { try { server.kill(); } catch {} });
describe('lecture publique / santé', () => {
it('GET /api/health -> ok', async () => {
const r = await J(`${base}/api/health`);
assert.equal(r.status, 200); assert.equal(r.body.status, 'ok');
});
it('GET /healthz expose youtube.mode sans secret', async () => {
const r = await J(`${base}/healthz`);
assert.equal(r.status, 200);
assert.equal(r.body.status, 'ok');
assert.ok(r.body.youtube?.mode);
assert.ok(!JSON.stringify(r.body).includes('GEMINI'));
});
it('GET /api/search?q=x -> 400', async () => {
const r = await J(`${base}/api/search?q=x`);
assert.equal(r.status, 400);
});
it('GET /api/search providers inconnus -> fallback registre', async () => {
const r = await J(`${base}/api/search?q=test&providers=xx,yy&pageSize=2`);
assert.equal(r.status, 200);
assert.ok(r.body.providers.length >= 6);
assert.ok(r.body.groups && typeof r.body.groups === 'object');
});
it('GET /api/search/suggest?q=x -> 400', async () => {
const r = await J(`${base}/api/search/suggest?q=x`);
assert.equal(r.status, 400);
});
it('GET /api/transcript provider inconnu -> 400 available:false', async () => {
const r = await J(`${base}/api/transcript/bogus/vid`);
assert.equal(r.status, 400);
assert.equal(r.body.available, false);
});
it('GET /api/details provider inconnu -> 500 details_failed (sans yt-dlp)', async () => {
const r = await J(`${base}/api/details/bogus/vid`);
assert.equal(r.status, 500);
assert.equal(r.body.error, 'details_failed');
});
it('GET /api/trending provider non-yt -> 400', async () => {
const r = await J(`${base}/api/trending?provider=dm`);
assert.equal(r.status, 400);
});
it('GET /api/trending?provider=yt -> shape {provider,items[]}', async () => {
const r = await J(`${base}/api/trending?provider=yt&limit=2`);
assert.equal(r.status, 200);
assert.equal(r.body.provider, 'yt');
assert.ok(Array.isArray(r.body.items));
});
it('GET /api/ai/status -> {ready:boolean}', async () => {
const r = await J(`${base}/api/ai/status`);
assert.equal(r.status, 200);
assert.equal(typeof r.body.ready, 'boolean');
});
it('GET /api/openapi.json documente playlists', async () => {
const r = await J(`${base}/api/openapi.json`);
assert.equal(r.status, 200);
assert.ok(r.body.paths?.['/playlists']);
});
});
describe('auth + espace utilisateur', () => {
it('register/login 400 sans password, 401 mauvais password', async () => {
const r1 = await J(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: user }) });
assert.equal(r1.status, 400);
const r2 = await J(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: user, password: 'mauvais' }) });
assert.equal(r2.status, 401);
});
it('protégées sans token -> 401', async () => {
for (const u of ['/api/user/me', '/api/playlists', '/api/user/likes', '/api/subscriptions', '/api/download/jobs']) {
const r = await J(`${base}${u}`);
assert.equal(r.status, 401, u);
}
});
it('GET /api/user/me + preferences round-trip', async () => {
const me = await J(`${base}/api/user/me`, { headers: auth(token) });
assert.equal(me.status, 200); assert.ok(me.body.id);
const p = await J(`${base}/api/user/preferences`, { headers: auth(token) });
assert.equal(p.status, 200);
const w = await J(`${base}/api/user/preferences`, { method: 'PATCH', headers: { ...auth(token), 'content-type': 'application/json' }, body: JSON.stringify({ defaultProviders: ['yt'] }) });
assert.equal(w.status, 200);
});
it('playlists CRUD complet', async () => {
const h = { ...auth(token), 'content-type': 'application/json' };
const c = await J(`${base}/api/playlists`, { method: 'POST', headers: h, body: JSON.stringify({ title: 'Cov' }) });
assert.equal(c.status, 201); const id = c.body.id; assert.ok(id);
const bad = await J(`${base}/api/playlists`, { method: 'POST', headers: h, body: JSON.stringify({}) });
assert.equal(bad.status, 400);
const g = await J(`${base}/api/playlists/${id}`, { headers: auth(token) });
assert.equal(g.status, 200);
const add = await J(`${base}/api/playlists/${id}/videos`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'v1', title: 'T', thumbnail: 'http://x/t.jpg' }) });
assert.equal(add.status, 201);
const del = await J(`${base}/api/playlists/${id}/videos/v1?provider=youtube`, { method: 'DELETE', headers: auth(token) });
assert.equal(del.status, 200);
const u = await J(`${base}/api/playlists/${id}`, { method: 'PUT', headers: h, body: JSON.stringify({ title: 'Cov2' }) });
assert.equal(u.status, 200); assert.equal(u.body.title, 'Cov2');
const rm = await fetch(`${base}/api/playlists/${id}`, { method: 'DELETE', headers: auth(token) });
assert.equal(rm.status, 204);
});
it('likes + history search/watch', async () => {
const h = { ...auth(token), 'content-type': 'application/json' };
// title + thumbnail fournis -> pas d'appel yt-dlp d'enrichissement (offline-safe)
const like = await J(`${base}/api/user/likes`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'v1', title: 'T', thumbnail: 'http://x/t.jpg' }) });
assert.ok([200, 201].includes(like.status));
const st = await J(`${base}/api/user/likes/status?provider=youtube&videoId=v1`, { headers: auth(token) });
assert.equal(st.status, 200);
const hs = await J(`${base}/api/user/history/search`, { method: 'POST', headers: h, body: JSON.stringify({ query: 'cov' }) });
assert.ok([200, 201].includes(hs.status));
const ls = await J(`${base}/api/user/history/search`, { headers: auth(token) });
assert.equal(ls.status, 200);
const hw = await J(`${base}/api/user/history/watch`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'v1' }) });
assert.ok([200, 201].includes(hw.status));
const lw = await J(`${base}/api/user/history/watch`, { headers: auth(token) });
assert.equal(lw.status, 200);
});
it('subscriptions + telemetry + download jobs', async () => {
const h = { ...auth(token), 'content-type': 'application/json' };
const sub = await J(`${base}/api/subscriptions`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'yt', externalId: 'ch1', title: 'C' }) });
assert.ok([200, 201].includes(sub.status));
const ls = await J(`${base}/api/subscriptions`, { headers: auth(token) });
assert.equal(ls.status, 200);
const tel = await J(`${base}/api/telemetry/events`, { method: 'POST', headers: h, body: JSON.stringify({ event: 'search_submit' }) });
assert.ok([200, 201].includes(tel.status));
const dj = await J(`${base}/api/download/jobs`, { headers: auth(token) });
assert.equal(dj.status, 200);
});
});