feat(preferences): section langues a telecharger (defaut fr+en), transcripts filtres cote serveur et watch
CI / build-and-test (push) Successful in 13m50s

This commit is contained in:
2026-09-26 11:49:56 -04:00
parent 1b0a7eb3ce
commit 655061ba2c
46 changed files with 2018 additions and 380 deletions
+61 -5
View File
@@ -123,6 +123,7 @@ if (schemaFile && fs.existsSync(schemaFile)) {
const colsPrefs = db.prepare(`PRAGMA table_info(user_preferences)`).all();
const have = new Set(colsPrefs.map(c => c.name));
if (!have.has('default_providers')) db.exec(`ALTER TABLE user_preferences ADD COLUMN default_providers TEXT`);
if (!have.has('download_languages')) db.exec(`ALTER TABLE user_preferences ADD COLUMN download_languages TEXT`);
} catch {}
})();
@@ -144,11 +145,45 @@ export function insertUser({ id, username, email, passwordHash }) {
db.prepare(`INSERT INTO users (id, username, email, password_hash, is_active, created_at, updated_at)
VALUES (@id, @username, @email, @passwordHash, 1, @ts, @ts)`).run({ id, username, email, passwordHash, ts });
// Default preferences row (default_providers NULL = no multi-provider preference yet)
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, default_providers, theme, video_quality, region, version, updated_at)
VALUES (@id, 'en', 'youtube', NULL, 'system', 'auto', 'US', 1, @ts)`).run({ id, ts });
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, default_providers, download_languages, theme, video_quality, region, version, updated_at)
VALUES (@id, 'en', 'youtube', NULL, @dl, 'system', 'auto', 'US', 1, @ts)`).run({ id, ts, dl: JSON.stringify(DEFAULT_DOWNLOAD_LANGUAGES) });
}// Sanitize a defaultProviders list: keep only known provider ids.
const KNOWN_PROVIDER_IDS = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
// Langues de sous-titres/transcripts proposées dans les préférences.
// Défaut : français + anglais uniquement.
export const DEFAULT_DOWNLOAD_LANGUAGES = ['fr', 'en'];
export const SUPPORTED_DOWNLOAD_LANGUAGES = [
'fr', 'en', 'es', 'de', 'it', 'pt', 'nl', 'pl',
'ru', 'ar', 'zh', 'ja', 'hi', 'ko',
];
function sanitizeDownloadLanguages(value) {
let arr = value;
// Accept JSON string (from DB) or array (from API patch)
if (typeof arr === 'string') {
try { arr = JSON.parse(arr); } catch { arr = []; }
}
if (!Array.isArray(arr)) arr = [];
const cleaned = arr
.map(v => String(v || '').trim().toLowerCase().replace(/_/g, '-').split('-')[0])
.filter(v => /^[a-z]{2,3}$/.test(v) && SUPPORTED_DOWNLOAD_LANGUAGES.includes(v));
// Deduplicate, preserve order
return Array.from(new Set(cleaned));
}
function parseDownloadLanguagesForApi(stored) {
// NULL (ancienne ligne) = défaut fr+en ; '[]' explicite = aucune langue.
if (stored == null) return [...DEFAULT_DOWNLOAD_LANGUAGES];
try {
const parsed = JSON.parse(stored);
if (!Array.isArray(parsed)) return [...DEFAULT_DOWNLOAD_LANGUAGES];
return sanitizeDownloadLanguages(parsed);
} catch {
return [...DEFAULT_DOWNLOAD_LANGUAGES];
}
}
function sanitizeDefaultProviders(value) {
let arr = value;
// Accept JSON string (from DB) or array (from API patch)
@@ -169,6 +204,11 @@ export function upsertPreferences(userId, patch) {
if (patch.defaultProviders !== undefined) {
defaultProviders = sanitizeDefaultProviders(patch.defaultProviders);
}
// Normalize the download/subtitle languages if provided (fr+en par défaut)
let downloadLanguages = undefined;
if (patch.downloadLanguages !== undefined) {
downloadLanguages = sanitizeDownloadLanguages(patch.downloadLanguages);
}
const current = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(userId);
if (!current) {
@@ -178,23 +218,33 @@ export function upsertPreferences(userId, patch) {
default_providers: defaultProviders !== undefined
? (defaultProviders.length ? JSON.stringify(defaultProviders) : null)
: null,
download_languages: downloadLanguages !== undefined
? JSON.stringify(downloadLanguages)
: JSON.stringify(DEFAULT_DOWNLOAD_LANGUAGES),
theme: patch.theme ?? 'system',
video_quality: patch.videoQuality ?? 'auto',
region: patch.region ?? 'US',
version: 1,
};
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, default_providers, theme, video_quality, region, version, updated_at)
VALUES (@userId, @language, @default_provider, @default_providers, @theme, @video_quality, @region, @version, @updated_at)`)
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, default_providers, download_languages, theme, video_quality, region, version, updated_at)
VALUES (@userId, @language, @default_provider, @default_providers, @download_languages, @theme, @video_quality, @region, @version, @updated_at)`)
.run({ userId, ...merged, updated_at: nowIso() });
} else {
const storedList = current.default_providers;
const nextList = defaultProviders !== undefined
? (defaultProviders.length ? JSON.stringify(defaultProviders) : null)
: storedList;
const storedDl = Object.prototype.hasOwnProperty.call(current, 'download_languages')
? current.download_languages
: undefined;
const nextDl = downloadLanguages !== undefined
? JSON.stringify(downloadLanguages)
: (storedDl !== undefined ? storedDl : JSON.stringify(DEFAULT_DOWNLOAD_LANGUAGES));
const merged = {
language: patch.language ?? current.language,
default_provider: patch.defaultProvider ?? current.default_provider,
default_providers: nextList,
download_languages: nextDl,
theme: patch.theme ?? current.theme,
video_quality: patch.videoQuality ?? current.video_quality,
region: patch.region ?? current.region,
@@ -203,6 +253,7 @@ export function upsertPreferences(userId, patch) {
};
db.prepare(`UPDATE user_preferences
SET language=@language, default_provider=@default_provider, default_providers=@default_providers,
download_languages=@download_languages,
theme=@theme, video_quality=@video_quality, region=@region, version=@version, updated_at=@updated_at
WHERE user_id=@userId`)
.run({ userId, ...merged });
@@ -211,6 +262,7 @@ export function upsertPreferences(userId, patch) {
export function getPreferences(userId) {
return db.prepare(`SELECT language, default_provider AS defaultProvider, default_providers AS defaultProviders,
download_languages AS downloadLanguages,
theme, video_quality AS videoQuality, region, version, updated_at
FROM user_preferences WHERE user_id = ?`).get(userId);
}
@@ -222,7 +274,11 @@ export function getPreferencesForApi(userId) {
if (prefs.defaultProviders) {
try { list = JSON.parse(prefs.defaultProviders); } catch { list = null; }
}
return { ...prefs, defaultProviders: Array.isArray(list) ? list : null };
return {
...prefs,
defaultProviders: Array.isArray(list) ? list : null,
downloadLanguages: parseDownloadLanguagesForApi(prefs.downloadLanguages),
};
}
export function insertSession({ id, userId, refreshTokenHash, isRemember, userAgent, deviceInfo, ip, expiresAt }) {
+79 -13
View File
@@ -81,6 +81,8 @@ import {
resetActiveDownloadJobs,
countActiveDownloadJobs,
sumCompletedDownloadBytes,
SUPPORTED_DOWNLOAD_LANGUAGES as SUPPORTED_DL_LANGS,
DEFAULT_DOWNLOAD_LANGUAGES as DEFAULT_DL_LANGS,
} from './db.mjs';
import { getChannelAdapter, setTwitchTokenProvider } from './providers/channel-registry.mjs';
import { fetchChannelContent } from './providers/channel-content.mjs';
@@ -2589,6 +2591,55 @@ const transcriptLimiter = rateLimit({
/** Providers known NOT to expose subtitle tracks via yt-dlp (no transcript possible). */
const TRANSCRIPT_UNSUPPORTED_PROVIDERS = new Set(['twitch', 'odysee', 'rumble']);
/** Langues de transcripts autorisées : défaut fr+en, jamais de téléchargement hors liste. */
function sanitizeAllowedTranscriptLangs(raw) {
const supported = Array.isArray(SUPPORTED_DL_LANGS) && SUPPORTED_DL_LANGS.length
? SUPPORTED_DL_LANGS
: ['fr', 'en'];
const fallback = Array.isArray(DEFAULT_DL_LANGS) && DEFAULT_DL_LANGS.length
? DEFAULT_DL_LANGS
: ['fr', 'en'];
let arr = raw;
if (typeof arr === 'string') arr = arr.split(',');
if (!Array.isArray(arr)) return [...fallback];
const cleaned = arr
.map(v => String(v || '').trim().toLowerCase().replace(/_/g, '-').split('-')[0])
.filter(v => /^[a-z]{2,3}$/.test(v) && supported.includes(v));
return Array.from(new Set(cleaned));
}
function transcriptPrimary(lang) {
return String(lang || '').trim().toLowerCase().replace(/_/g, '-').split('-')[0];
}
/** Ne jamais exposer ni télécharger une langue non activée dans les préférences. */
function filterTranscriptLanguages(languages, allowed) {
const set = new Set((allowed || []).map(transcriptPrimary).filter(Boolean));
return (Array.isArray(languages) ? languages : []).filter(l => set.has(transcriptPrimary(l)));
}
/** Résout les langues autorisées : `?langs=` explicite > préférence user (JWT) > défaut fr,en. */
function resolveTranscriptAllowedLangs(req) {
const fromQuery = sanitizeAllowedTranscriptLangs(req.query?.langs);
const queryAsked = req.query?.langs !== undefined;
if (queryAsked) return fromQuery;
try {
const hdr = req.headers?.['authorization'] || '';
const [, token] = String(hdr).split(' ');
if (token) {
const payload = jwt.verify(token, JWT_SECRET);
const uid = payload?.sub;
if (uid) {
const prefs = getPreferencesForApi(uid);
if (prefs && Array.isArray(prefs.downloadLanguages)) {
return sanitizeAllowedTranscriptLangs(prefs.downloadLanguages);
}
}
}
} catch {}
return sanitizeAllowedTranscriptLangs(undefined);
}
/** Download subtitles via yt-dlp (handles YouTube impersonation + 429-prone
* translated tracks). Tries `langs` in order, returns the first non-empty
* parsed lines with the language that worked, or null. Bounded by a timeout
@@ -2630,9 +2681,10 @@ async function transcriptViaYtDlp(url, langs, netOpts = {}) {
return null;
};
try {
// Ne télécharger QUE les langues autorisées (aucun ajout forcé hors préférence).
const wanted = Array.from(new Set((langs || []).map((l) => String(l || '').split('-')[0]).filter(Boolean)));
if (!wanted.includes('en')) wanted.push('en');
const subLangs = wanted.slice(0, 4).join(',');
if (!wanted.length) return null;
const subLangs = wanted.slice(0, 6).join(',');
const child = youtubedl(url, {
writeSub: true,
writeAutoSub: true,
@@ -2689,12 +2741,21 @@ async function fetchTimedTextLines(track) {
r.get('/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
try {
const { provider, videoId } = req.params;
const lang = String(req.query.lang || 'fr').slice(0, 12) || 'fr';
// Langues autorisées par les préférences (défaut fr,en) : rien d'autre
// n'est affiché ni téléchargé.
const allowed = resolveTranscriptAllowedLangs(req);
let lang = String(req.query.lang || allowed[0] || 'fr').slice(0, 12) || 'fr';
if (!allowed.map(transcriptPrimary).includes(transcriptPrimary(lang))) {
lang = allowed[0] || 'fr';
}
const normalized = normalizeTranscriptProvider(provider);
if (!normalized || !videoId) {
return res.status(400).json({ available: false, error: 'invalid_provider_or_video' });
}
const cacheKey = `transcript:${normalized}:${videoId}:${lang.toLowerCase()}`;
if (!allowed.length) {
return res.json({ lang: null, available: false, languages: [], lines: [], reason: 'no_subtitles' });
}
const cacheKey = `transcript:${normalized}:${videoId}:${lang.toLowerCase()}:${[...allowed].sort().join(',')}`;
const cached = transcriptCacheGet(cacheKey);
if (cached) return res.json(cached);
// Source de découverte des pistes : InnerTube d'abord pour YouTube
@@ -2739,11 +2800,12 @@ r.get('/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
}
}
const { track, languages, lang: chosenLang } = pickTrack(meta, lang);
if (!track) {
const visibleLanguages = filterTranscriptLanguages(languages, allowed);
if (!track || !allowed.map(transcriptPrimary).includes(transcriptPrimary(chosenLang))) {
// Definitive absence: distinguish "provider never exposes subtitles"
// (twitch/odysee/rumble) from "this video has none" — cacheable 200s.
const reason = TRANSCRIPT_UNSUPPORTED_PROVIDERS.has(normalized) ? 'provider_unsupported' : 'no_subtitles';
const empty = { lang: null, available: false, languages: languages || [], lines: [], reason };
const empty = { lang: null, available: false, languages: visibleLanguages, lines: [], reason };
transcriptCacheSet(cacheKey, empty);
return res.json(empty);
}
@@ -2757,11 +2819,14 @@ r.get('/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
let lines = [];
let workedLang = chosenLang;
let sawRateLimit = false;
const baseCands = normalized === 'youtube'
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)
: orderedTracks(meta, lang).slice(0, 5);
: orderedTracks(meta, lang).slice(0, 5));
// Server-side auto-translation only towards an allowed target language.
const candidates = normalized === 'youtube'
? baseCands.concat(translatedFallbacks(baseCands, lang)).slice(0, 12)
? baseCands.concat(keepAllowed(translatedFallbacks(baseCands, lang))).slice(0, 12)
: baseCands;
for (const cand of candidates) {
let attempt = 0;
@@ -2792,9 +2857,10 @@ r.get('/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
if (!lines || lines.length === 0) {
// Direct timedtext fetches failed (429 / Sorry pages / impersonation):
// let yt-dlp download the subtitles instead (handles all of the above).
// Uniquement les langues autorisées.
try {
const viaDlp = await transcriptViaYtDlp(transcriptUrl, [lang, chosenLang].filter(Boolean), ytdlpNetOpts());
if (viaDlp && viaDlp.lines && viaDlp.lines.length) {
const viaDlp = await transcriptViaYtDlp(transcriptUrl, [lang, chosenLang].filter(l => allowedSet.has(transcriptPrimary(l))), ytdlpNetOpts());
if (viaDlp && viaDlp.lines && viaDlp.lines.length && allowedSet.has(transcriptPrimary(viaDlp.lang))) {
lines = viaDlp.lines;
if (viaDlp.lang) workedLang = viaDlp.lang;
}
@@ -2807,7 +2873,7 @@ r.get('/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
// never cached as "no subtitles".
return res.status(502).json({
available: false,
languages: languages || [],
languages: visibleLanguages,
lines: [],
error: 'transcript_temporarily_unavailable',
retryable: true,
@@ -2815,7 +2881,7 @@ r.get('/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
});
}
lines = dedupeTranscriptLines(lines);
const result = { lang: workedLang, available: true, languages: languages || [], lines };
const result = { lang: workedLang, available: true, languages: visibleLanguages, lines };
transcriptCacheSet(cacheKey, result);
return res.json(result);
} catch (e) {
+27
View File
@@ -47,6 +47,33 @@ const handler = {
console.error('Dailymotion search error:', error);
return [];
}
},
/**
* Typeahead suggestions for Dailymotion (no native suggest API):
* reuses search titles, deduped. Step 15 — never throws.
* @param {string} q
* @param {{ limit?: number }} [opts]
* @returns {Promise<string[]>}
*/
async suggest(q, opts) {
const limit = Math.min(20, Math.max(1, Number(opts?.limit || 10)));
const query = String(q || '').trim();
if (query.length < 2) return [];
try {
const items = await handler.search(query, { limit });
const seen = new Set();
const out = [];
for (const it of items || []) {
const t = String(it?.title || '').trim();
if (!t || seen.has(t.toLowerCase())) continue;
seen.add(t.toLowerCase());
out.push(t);
if (out.length >= limit) break;
}
return out;
} catch {
return [];
}
}
};
export default handler;
+1
View File
@@ -22,6 +22,7 @@ import channelRegistry from './channel-registry.mjs';
* @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 }) => Promise<string[]>} [suggest]
* @property {(externalId: string, ctx?: any) => Promise<any=} } [fetchChannelById]
*/
+344 -67
View File
@@ -1,16 +1,16 @@
/**
* Twitch provider using Helix API.
*
* Fix (2026-09): the App Access Token is now cached process-wide (they last
* ~60 days) instead of being fetched on every search, and both the token
* request and the Helix call retry once with a short backoff — the earlier
* single-shot behaviour made the provider intermittently return [] when the
* token endpoint or Helix hiccuped (observed on container cold starts).
* Missing TWITCH_CLIENT_ID/SECRET now fail fast with a clear error instead of
* calling the OAuth endpoint with undefined credentials.
* Search mixte façon directory Twitch (category/: lives + videos + clips) :
* - chaînes via /helix/search/channels (type 'channel' hors-ligne, 'live' en direct)
* - lives via /helix/streams enrichis (viewer_count, game_name, started_at)
* - VODs via /helix/videos par broadcaster (+ par game_id quand la requête
* matche une catégorie Twitch, ex. "sports")
* - clips via /helix/clips par broadcaster (+ par game_id le cas échéant)
*
* Token applicatif caché process-wide + retries (cf. historique).
*/
// Process-wide app access token cache
let cachedToken = null; // { token, clientId, expiresAtMs }
function sleep(ms) {
@@ -52,7 +52,6 @@ async function getAppAccessToken(force = false) {
}),
});
if (!authResponse.ok) {
// 400/403 = bad credentials: do not retry
if (authResponse.status === 400 || authResponse.status === 401 || authResponse.status === 403) {
throw Object.assign(new Error(`twitch_auth_rejected_${authResponse.status}`), { code: 'AUTH_REJECTED' });
}
@@ -63,7 +62,6 @@ async function getAppAccessToken(force = false) {
cachedToken = {
token: access_token,
clientId,
// refresh 24h before actual expiry
expiresAtMs: Date.now() + Math.max(3600, Number(expires_in || 3600) - 86_400) * 1000,
};
return cachedToken;
@@ -76,99 +74,378 @@ async function getAppAccessToken(force = false) {
throw lastErr || new Error('twitch_auth_failed');
}
async function helixSearchChannels(q, first, after, token, clientId) {
function helixHeaders(token, clientId) {
return { 'Client-ID': clientId, 'Authorization': `Bearer ${token}` };
}
/** GET Helix avec retry + refresh token sur 401. Retourne le JSON décodé. */
async function helixGet(path, params, auth, { retries = 2 } = {}) {
let { token, clientId } = auth;
let lastErr = null;
for (let attempt = 0; attempt < 3; attempt++) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const params = new URLSearchParams({ query: q, first: String(first) });
if (after) params.set('after', after);
const response = await fetchWithTimeout(
`https://api.twitch.tv/helix/search/channels?${params.toString()}`,
{
headers: {
'Client-ID': clientId,
'Authorization': `Bearer ${token}`,
},
},
);
if (response.status === 401) {
// token expired/revoked server-side: force refresh once, then retry
const qs = params ? `?${params.toString()}` : '';
const resp = await fetchWithTimeout(`https://api.twitch.tv/helix${path}${qs}`, {
headers: helixHeaders(token, clientId),
});
if (resp.status === 401 && attempt < retries) {
const fresh = await getAppAccessToken(true);
return { data: [], cursor: null, token: fresh.token, retry: true };
token = fresh.token;
clientId = fresh.clientId;
auth.token = token;
auth.clientId = clientId;
continue;
}
if (!response.ok) throw new Error(`twitch_api_error_${response.status}`);
const data = await response.json();
return { data: Array.isArray(data.data) ? data.data : [], cursor: data?.pagination?.cursor || null };
if (!resp.ok) throw new Error(`twitch_api_error_${resp.status}`);
return await resp.json();
} catch (e) {
lastErr = e;
if (attempt < 2) await sleep(400 * (attempt + 1));
if (attempt < retries) await sleep(350 * (attempt + 1));
}
}
throw lastErr || new Error('twitch_api_failed');
}
function thumbStream(raw, w = 640, h = 360) {
const s = String(raw || '');
if (!s) return 'https://static-cdn.jtvnw.net/ttv-static/404_preview-640x360.jpg';
return s.replace('{width}x{height}', `${w}x${h}`).replace('{width}', String(w)).replace('{height}', String(h));
}
function thumbVod(raw) {
const s = String(raw || '');
if (!s) return undefined;
// VODs: motif "%{width}x%{height}"
return s.replace('%{width}', '640').replace('%{height}', '360');
}
function parseTwitchDurationToSeconds(raw) {
// Formats Helix videos : "2h13m5s" / "13m5s" / "45s"
const s = String(raw || '').trim();
if (!s) return 0;
if (/^\d+(\.\d+)?$/.test(s)) return Math.round(Number(s));
let total = 0;
const h = /(\d+)h/.exec(s);
const m = /(\d+)m/.exec(s);
const sec = /(\d+)s/.exec(s);
if (h) total += Number(h[1]) * 3600;
if (m) total += Number(m[1]) * 60;
if (sec) total += Number(sec[1]);
return total;
}
function mapChannel(item) {
const login = item.broadcaster_login || item.user_login || undefined;
const live = Boolean(item.is_live);
const thumb = String(item.thumbnail_url || '');
const avatar = thumb || undefined;
return {
title: item.title || item.display_name || login || '',
id: item.id,
url: login ? `https://www.twitch.tv/${login}` : undefined,
thumbnail: thumb || undefined,
uploaderName: item.display_name || item.broadcaster_name || undefined,
channelId: item.id || undefined,
channelHandle: login,
channelExternalId: login,
uploaderAvatar: avatar,
type: live ? 'live' : 'channel',
isLive: live,
views: undefined,
publishedAt: item.started_at || undefined,
game: item.game_name || item.game || undefined,
};
}
function mapStream(s) {
const login = s.user_login || s.broadcaster_login || undefined;
return {
title: s.title || s.user_name || login || '',
// id = login : le player embed une chaîne via ?channel=<login>
id: login || s.id,
url: login ? `https://www.twitch.tv/${login}` : undefined,
thumbnail: thumbStream(s.thumbnail_url, 640, 360),
uploaderName: s.user_name || undefined,
channelId: s.user_id || undefined,
channelHandle: login,
channelExternalId: login,
uploaderAvatar: thumbStream(s.thumbnail_url, 70, 70),
type: 'live',
isLive: true,
views: typeof s.viewer_count === 'number' ? s.viewer_count : undefined,
publishedAt: s.started_at || undefined,
game: s.game_name || undefined,
language: s.language || undefined,
};
}
function mapVod(v) {
const login = v.user_login || v.broadcaster_login || undefined;
return {
title: v.title || '',
id: v.id,
url: v.url || (v.id ? `https://www.twitch.tv/videos/${v.id}` : undefined),
thumbnail: thumbVod(v.thumbnail_url),
uploaderName: v.user_name || v.broadcaster_name || undefined,
channelId: v.user_id || undefined,
channelHandle: login,
channelExternalId: login,
uploaderAvatar: undefined,
type: 'video',
kind: 'vod',
isLive: false,
duration: parseTwitchDurationToSeconds(v.duration),
views: typeof v.view_count === 'number' ? v.view_count : undefined,
publishedAt: v.created_at || v.published_at || undefined,
game: v.game_name || undefined,
language: v.language || undefined,
};
}
function mapClip(c) {
return {
title: c.title || '',
id: c.id,
url: c.url || `https://clips.twitch.tv/${c.id}`,
thumbnail: thumbVod(c.thumbnail_url),
uploaderName: c.broadcaster_name || c.creator_name || undefined,
channelId: c.broadcaster_id || undefined,
channelHandle: undefined,
channelExternalId: undefined,
uploaderAvatar: undefined,
type: 'video',
kind: 'clip',
isLive: false,
duration: typeof c.duration === 'number' ? Math.round(c.duration) : Number(c.duration || 0) || undefined,
views: typeof c.view_count === 'number' ? c.view_count : undefined,
publishedAt: c.created_at || undefined,
game: undefined,
language: c.language || undefined,
};
}
/** Catégorie Twitch la plus pertinente pour une requête (ex. "sports" -> Sports). */
async function findCategoryId(q, auth) {
try {
const data = await helixGet('/search/categories', new URLSearchParams({ query: q, first: '5' }), auth, { retries: 1 });
const list = Array.isArray(data?.data) ? data.data : [];
if (!list.length) return null;
const needle = String(q || '').trim().toLowerCase();
const exact = list.find((c) => String(c?.name || '').toLowerCase() === needle);
return (exact || list[0])?.id || null;
} catch {
return null;
}
}
const handler = {
id: 'tw',
label: 'Twitch',
/**
* Recherche mixte : lives + VODs + clips + chaînes.
* @param {string} q
* @param {{ limit: number, page?: number }} opts
* @returns {Promise<Array<any>>}
* @param {{ limit: number, page?: number, sort?: string }} opts
*/
async search(q, opts) {
const { limit = 10, page = 1 } = opts || {};
const { limit = 24, page = 1, sort = 'relevance' } = opts || {};
const query = String(q || '').trim();
if (!query) return [];
try {
const appToken = await getAppAccessToken();
const perPage = Math.min(Math.max(1, Number(limit || 10)), 100);
const auth = { token: appToken.token, clientId: appToken.clientId };
const perPage = Math.min(Math.max(1, Number(limit || 24)), 50);
const targetPage = Math.max(1, Number(page || 1));
// 1) Chaînes (paginées : la pagination repose sur ce curseur).
let after = '';
let currentPage = 1;
let lastData = [];
let channelsData = [];
let channelsCursor = null;
while (currentPage <= targetPage) {
let res = await helixSearchChannels(query, perPage, after, appToken.token, appToken.clientId);
// One forced retry with a fresh token when Helix rejects the cached one
if (res.retry) {
res = await helixSearchChannels(query, perPage, after, res.token, appToken.clientId);
}
const params = new URLSearchParams({ query, first: String(Math.min(20, perPage)) });
if (after) params.set('after', after);
const data = await helixGet('/search/channels', params, auth);
const list = Array.isArray(data?.data) ? data.data : [];
channelsCursor = data?.pagination?.cursor || null;
if (currentPage === targetPage) {
lastData = res.data;
channelsData = list;
break;
}
const nextCursor = res.cursor;
if (!nextCursor) {
lastData = [];
if (!channelsCursor) {
channelsData = [];
break;
}
after = String(nextCursor);
after = String(channelsCursor);
currentPage++;
}
// NOTE: helix/search/channels ne retourne QUE des chaînes (jamais des VODs).
// Les chaînes hors-ligne étaient étiquetées type 'video', ce qui faisait
// croire au frontend à une VOD jouable via ?video=<id> -> erreur player.
// On les étiquette 'live' / 'channel' pour forcer l'embed ?channel=<login>.
return lastData.map((item) => {
const login = item.broadcaster_login || item.user_login || undefined;
const thumb = item.thumbnail_url || '';
return {
title: item.title || item.display_name,
id: item.id,
url: login ? `https://www.twitch.tv/${login}` : undefined,
thumbnail: thumb ? thumb.replace('{width}x{height}', '440x248') : undefined,
uploaderName: item.display_name,
channelId: item.id || undefined,
channelHandle: login,
channelExternalId: login,
uploaderAvatar: thumb ? thumb.replace('{width}x{height}', '70x70') : undefined,
type: item.is_live ? 'live' : 'channel',
isLive: Boolean(item.is_live),
};
});
const liveChannels = channelsData.filter((c) => c?.is_live);
const userIds = [...new Set(channelsData.map((c) => c?.id).filter(Boolean))].slice(0, 8);
const logins = [...new Set(channelsData.map((c) => c?.broadcaster_login || c?.user_login).filter(Boolean))].slice(0, 20);
// 2) Catégorie éventuelle (thèmes génériques : sports, music...) + streams enrichis en parallèle.
const [categoryId, streamsByLogin] = await Promise.all([
targetPage === 1 ? findCategoryId(query, auth) : Promise.resolve(null),
(async () => {
if (!logins.length) return new Map();
try {
const params = new URLSearchParams();
logins.forEach((l) => params.append('user_login', l));
params.set('first', String(Math.min(100, logins.length)));
const data = await helixGet('/streams', params, auth, { retries: 1 });
const map = new Map();
for (const s of Array.isArray(data?.data) ? data.data : []) {
const key = String(s?.user_login || '').toLowerCase();
if (key) map.set(key, s);
}
return map;
} catch {
return new Map();
}
})(),
]);
// Lives : streams enrichis des chaînes live + top streams de la catégorie si dispo.
let lives = [];
for (const c of liveChannels) {
const login = String(c?.broadcaster_login || c?.user_login || '').toLowerCase();
const s = login ? streamsByLogin.get(login) : null;
if (s) lives.push(mapStream(s));
else lives.push(mapChannel(c));
}
if (targetPage === 1 && categoryId) {
try {
const data = await helixGet('/streams', new URLSearchParams({ game_id: String(categoryId), first: '12' }), auth, { retries: 1 });
const extra = (Array.isArray(data?.data) ? data.data : []).map(mapStream);
const seen = new Set(lives.map((v) => String(v.id).toLowerCase()));
for (const v of extra) {
if (!seen.has(String(v.id).toLowerCase())) {
seen.add(String(v.id).toLowerCase());
lives.push(v);
}
}
} catch {}
}
// 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).
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) => {
try {
const data = await helixGet(
'/videos',
new URLSearchParams({ user_id: String(uid), first: '4', 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({ 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 {}
// 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) => {
try {
const data = await helixGet(
'/clips',
new URLSearchParams({ broadcaster_id: String(uid), first: '3', started_at: since }),
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 {}
// 5) Assemblage façon directory : lives, vidéos, clips, chaînes (hors-ligne).
const channels = channelsData.map(mapChannel);
const offlineChannels = channels.filter((c) => c.type === 'channel');
const liveFallbacks = channels.filter((c) => c.type === 'live' && !lives.some((l) => String(l.id).toLowerCase() === String(c.channelHandle || c.id).toLowerCase()));
const allLives = [...lives, ...liveFallbacks];
const byDateDesc = (a, b) => Date.parse(b.publishedAt || '') - Date.parse(a.publishedAt || '');
const byViewsDesc = (a, b) => Number(b.views || 0) - Number(a.views || 0);
if (sort === 'date') {
vods = [...vods].sort(byDateDesc);
clips = [...clips].sort(byDateDesc);
} else if (sort === 'views') {
vods = [...vods].sort(byViewsDesc);
clips = [...clips].sort(byViewsDesc);
}
// 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 merged = [...takeLives, ...takeVods, ...takeClips, ...takeChannels];
// Déduplique par id (les lives login vs chaînes id numérique ne collisionnent pas).
const seen = new Set();
const out = [];
for (const item of merged) {
const key = `${item.type}:${String(item.id)}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
} catch (error) {
console.error('Twitch search error:', error?.message || error);
return [];