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:
@@ -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