feat(preferences): section langues a telecharger (defaut fr+en), transcripts filtres cote serveur et watch
CI / build-and-test (push) Successful in 13m50s
CI / build-and-test (push) Successful in 13m50s
This commit is contained in:
+61
-5
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user