From 655061ba2c4b48dffdac249f946c3b9bcde96e6c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 11:49:56 -0400 Subject: [PATCH] feat(preferences): section langues a telecharger (defaut fr+en), transcripts filtres cote serveur et watch --- .env.example | 6 +- .../20250926_add_download_languages.sql | 3 + db/schema.sql | 1 + docker-compose/.env | 12 + docker-compose/.env.example | 9 + docker-compose/docker-compose.local.yml | 11 + docker-compose/docker-compose.yml | 11 + package-lock.json | 2 +- server/db.mjs | 66 ++- server/index.mjs | 92 +++- server/providers/dailymotion.mjs | 27 ++ server/providers/registry.mjs | 1 + server/providers/twitch.mjs | 411 +++++++++++++++--- src/app/search/adapters/dm.ts | 3 +- src/app/search/adapters/od.ts | 3 +- src/app/search/adapters/pt.ts | 3 +- src/app/search/adapters/ru.ts | 3 +- src/app/search/adapters/tw.ts | 27 +- src/app/search/adapters/yt.ts | 3 +- src/app/search/api.v1.ts | 2 + src/app/search/models.ts | 4 +- src/app/search/search.service.ts | 10 +- .../search-result-grid.component.html | 19 +- .../search-result-grid.component.ts | 11 + .../video-card/video-card.component.html | 18 +- .../video-card/video-card.component.ts | 32 +- src/app/shared/models/video-item.model.ts | 5 + .../preferences/preferences.component.html | 23 + .../preferences/preferences.component.ts | 49 +++ .../channel-video-grid.component.html | 26 +- .../channel-video-grid.component.ts | 42 +- src/components/home/home.component.ts | 14 +- .../search/search-box.component.html | 33 +- src/components/search/search.component.html | 105 ++++- src/components/search/search.component.ts | 336 +++++++++++--- .../themes/provider-theme-page.component.html | 207 +++++++-- .../themes/provider-theme-page.component.ts | 314 +++++++++++-- .../themes/theme-page.component.html | 14 +- src/components/themes/theme-page.component.ts | 93 +++- src/components/watch/watch.component.ts | 71 ++- src/models/video.model.ts | 1 + src/services/i18n.service.ts | 6 + src/services/telemetry.service.ts | 4 +- src/services/user.service.ts | 1 + src/services/youtube-api.service.ts | 260 +++++++++-- todo.md | 4 + 46 files changed, 2018 insertions(+), 380 deletions(-) create mode 100644 db/migrations/20250926_add_download_languages.sql diff --git a/.env.example b/.env.example index dea25cd..1992ca9 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,11 @@ YT_TRANSCRIPT_SOURCE=innertube-first YT_SCRAPE_TTL_MS=1800000 YT_DLP_TIMEOUT_MS=20000 # YT_DLP_PATH=/usr/local/bin/yt-dlp -# Anti-ban : cookies exportés (volume :ro en Docker) + PO-Token + proxy sortant +# Anti-ban : cookies exportés (volume :ro en Docker) + PO-Token + proxy sortant. +# Les cookies (session résidentielle de confiance exportée depuis votre +# navigateur, format Netscape) sont utilisés par les appels yt-dlp +# (transcripts, downloads) : c'est le déblocage le plus fiable quand +# YouTube sert du vide/429 aux IPs de datacenter sur timedtext. # YT_COOKIES_FILE=/cookies/youtube.txt # YT_PO_TOKEN= # YT_EGRESS_PROXY=http://proxy:8080 diff --git a/db/migrations/20250926_add_download_languages.sql b/db/migrations/20250926_add_download_languages.sql new file mode 100644 index 0000000..9599fdd --- /dev/null +++ b/db/migrations/20250926_add_download_languages.sql @@ -0,0 +1,3 @@ +-- Langues de sous-titres/transcripts à télécharger (JSON array, ex. '["fr","en"]'). +-- NULL = défaut ['fr','en'] (aucune autre langue affichée/téléchargée sans activation explicite). +ALTER TABLE user_preferences ADD COLUMN download_languages TEXT; diff --git a/db/schema.sql b/db/schema.sql index 8496b45..565645b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS user_preferences ( language TEXT DEFAULT 'en', default_provider TEXT, default_providers TEXT, + download_languages TEXT, theme TEXT DEFAULT 'system', video_quality TEXT DEFAULT 'auto', region TEXT, diff --git a/docker-compose/.env b/docker-compose/.env index c4d29d3..fb90dc4 100644 --- a/docker-compose/.env +++ b/docker-compose/.env @@ -17,3 +17,15 @@ NODE_ENV=production TZ=America/Montreal DOWNLOAD_PROVIDERS=youtube,dailymotion,twitch,peertube,odysee,rumble DOWNLOAD_MAX_CONCURRENT=2 +# --- YouTube hybride (Steps 17-18) --- +YT_SEARCH_MODE=innertube-first +YT_SCRAPE_TTL_MS=1800000 +YT_DLP_TIMEOUT_MS=20000 +YT_INNERTUBE_GL=FR +YT_INNERTUBE_HL=fr +YT_TRANSCRIPT_SOURCE=innertube-first +YT_RELATED_TTL_MS=3600000 +# Anti-ban optionnel (laisser vide si non utilisé) +# YT_COOKIES_FILE=/cookies/youtube.txt +# YT_PO_TOKEN= +# YT_EGRESS_PROXY= diff --git a/docker-compose/.env.example b/docker-compose/.env.example index 9484fc4..758d5a2 100644 --- a/docker-compose/.env.example +++ b/docker-compose/.env.example @@ -15,6 +15,15 @@ TWITCH_CLIENT_SECRET=votre_client_secret_twitch_ici # Configuration du cache (en millisecondes) YT_CACHE_TTL_MS=3600000 # 1 heure par défaut +# --- YouTube hybride (Steps 17-18) --- +YT_SEARCH_MODE=innertube-first +YT_SCRAPE_TTL_MS=1800000 +YT_DLP_TIMEOUT_MS=20000 +YT_INNERTUBE_GL=FR +YT_INNERTUBE_HL=fr +YT_TRANSCRIPT_SOURCE=innertube-first +YT_RELATED_TTL_MS=3600000 + # --- YouTube hybride : InnerTube (sans clé) > scrape yt-dlp > API officielle --- # Mode : innertube-first (défaut, 0 quota, pagination illimitée) | innertube-only # | scrape-first | api-first | scrape-only | api-only diff --git a/docker-compose/docker-compose.local.yml b/docker-compose/docker-compose.local.yml index 8b4b83a..3186471 100644 --- a/docker-compose/docker-compose.local.yml +++ b/docker-compose/docker-compose.local.yml @@ -27,6 +27,17 @@ services: - TWITCH_CLIENT_SECRET=${TWITCH_CLIENT_SECRET} - DOWNLOAD_PROVIDERS=${DOWNLOAD_PROVIDERS:-youtube,dailymotion,twitch,peertube,odysee,rumble} - DOWNLOAD_MAX_CONCURRENT=${DOWNLOAD_MAX_CONCURRENT:-2} + # --- YouTube hybride (Steps 17-18) : innertube > scrape yt-dlp > API --- + - YT_SEARCH_MODE=${YT_SEARCH_MODE:-innertube-first} + - YT_SCRAPE_TTL_MS=${YT_SCRAPE_TTL_MS:-1800000} + - YT_DLP_TIMEOUT_MS=${YT_DLP_TIMEOUT_MS:-20000} + - YT_INNERTUBE_GL=${YT_INNERTUBE_GL:-FR} + - YT_INNERTUBE_HL=${YT_INNERTUBE_HL:-fr} + - YT_TRANSCRIPT_SOURCE=${YT_TRANSCRIPT_SOURCE:-innertube-first} + - YT_RELATED_TTL_MS=${YT_RELATED_TTL_MS:-3600000} + - YT_COOKIES_FILE=${YT_COOKIES_FILE:-} + - YT_PO_TOKEN=${YT_PO_TOKEN:-} + - YT_EGRESS_PROXY=${YT_EGRESS_PROXY:-} - TZ=${TZ:-America/Montreal} volumes: # SQLite : volume nommé (locking FS bind monté Windows peu fiable) diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 18f18cf..dcf09a0 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -21,6 +21,17 @@ services: - TWITCH_CLIENT_SECRET=${TWITCH_CLIENT_SECRET} - DOWNLOAD_PROVIDERS=${DOWNLOAD_PROVIDERS:-youtube,dailymotion,twitch,peertube,odysee,rumble} - DOWNLOAD_MAX_CONCURRENT=${DOWNLOAD_MAX_CONCURRENT:-2} + # --- YouTube hybride (Steps 17-18) --- + - YT_SEARCH_MODE=${YT_SEARCH_MODE:-innertube-first} + - YT_SCRAPE_TTL_MS=${YT_SCRAPE_TTL_MS:-1800000} + - YT_DLP_TIMEOUT_MS=${YT_DLP_TIMEOUT_MS:-20000} + - YT_INNERTUBE_GL=${YT_INNERTUBE_GL:-FR} + - YT_INNERTUBE_HL=${YT_INNERTUBE_HL:-fr} + - YT_TRANSCRIPT_SOURCE=${YT_TRANSCRIPT_SOURCE:-innertube-first} + - YT_RELATED_TTL_MS=${YT_RELATED_TTL_MS:-3600000} + - YT_COOKIES_FILE=${YT_COOKIES_FILE:-} + - YT_PO_TOKEN=${YT_PO_TOKEN:-} + - YT_EGRESS_PROXY=${YT_EGRESS_PROXY:-} - TZ=${TZ} volumes: - ${DIR_NEWTUBE}/assets:/app/assets diff --git a/package-lock.json b/package-lock.json index 14e9534..0777106 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "rxjs": "^7.8.2", "tailwindcss": "latest", "youtube-dl-exec": "^3.0.0", - "youtubei.js": "^18.1.0", + "youtubei.js": "18.1.0", "zone.js": "~0.15.1" }, "devDependencies": { diff --git a/server/db.mjs b/server/db.mjs index 83f09f5..fce1fb0 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -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 }) { diff --git a/server/index.mjs b/server/index.mjs index aae2b39..8c106f4 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -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) { diff --git a/server/providers/dailymotion.mjs b/server/providers/dailymotion.mjs index 7b1eea4..4b49a94 100644 --- a/server/providers/dailymotion.mjs +++ b/server/providers/dailymotion.mjs @@ -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} + */ + 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; diff --git a/server/providers/registry.mjs b/server/providers/registry.mjs index 5576623..dc478e2 100644 --- a/server/providers/registry.mjs +++ b/server/providers/registry.mjs @@ -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} search + * @property {(q: string, opts?: { limit?: number }) => Promise} [suggest] * @property {(externalId: string, ctx?: any) => Promise + 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>} + * @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= -> erreur player. - // On les étiquette 'live' / 'channel' pour forcer l'embed ?channel=. - 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 []; diff --git a/src/app/search/adapters/dm.ts b/src/app/search/adapters/dm.ts index d539298..716cd46 100644 --- a/src/app/search/adapters/dm.ts +++ b/src/app/search/adapters/dm.ts @@ -13,6 +13,7 @@ export class DmAdapter implements ProviderAdapter { if (params.pageToken) httpParams = httpParams.set('page', params.pageToken); if (params.sort) httpParams = httpParams.set('sort', params.sort); const res = await firstValueFrom(this.http.get(`/api/search`, { params: httpParams })); + const providerError = typeof res?.errors?.dm?.message === 'string' ? res.errors.dm.message : undefined; const items: VideoItem[] = (res?.groups?.dm || []).map((it: any) => ({ id: it.id, provider: 'dailymotion', @@ -25,6 +26,6 @@ export class DmAdapter implements ProviderAdapter { channelExternalId: it.channelId || undefined, channelAvatarUrl: it.uploaderAvatar || undefined, })); - return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0 }; + return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0, providerError }; } } diff --git a/src/app/search/adapters/od.ts b/src/app/search/adapters/od.ts index f6966a1..d9b33d0 100644 --- a/src/app/search/adapters/od.ts +++ b/src/app/search/adapters/od.ts @@ -12,6 +12,7 @@ export class OdAdapter implements ProviderAdapter { if (params.pageToken) httpParams = httpParams.set('page', params.pageToken); if (params.sort) httpParams = httpParams.set('sort', params.sort); const res = await firstValueFrom(this.http.get(`/api/search`, { params: httpParams })); + const providerError = typeof res?.errors?.od?.message === 'string' ? res.errors.od.message : undefined; const items: VideoItem[] = (res?.groups?.od || []).map((it: any) => { let slug: string | undefined; try { @@ -35,6 +36,6 @@ export class OdAdapter implements ProviderAdapter { channelExternalId: it.uploaderName || undefined, } as VideoItem; }); - return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0 }; + return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0, providerError }; } } diff --git a/src/app/search/adapters/pt.ts b/src/app/search/adapters/pt.ts index 0027a50..811ebd5 100644 --- a/src/app/search/adapters/pt.ts +++ b/src/app/search/adapters/pt.ts @@ -12,6 +12,7 @@ export class PtAdapter implements ProviderAdapter { if (params.pageToken) httpParams = httpParams.set('page', params.pageToken); if (params.sort) httpParams = httpParams.set('sort', params.sort); const res = await firstValueFrom(this.http.get(`/api/search`, { params: httpParams })); + const providerError = typeof res?.errors?.pt?.message === 'string' ? res.errors.pt.message : undefined; const items: VideoItem[] = (res?.groups?.pt || []).map((it: any) => { // PeerTube identifie une chaîne par `instance|channel` : l'instance est // dérivée de l'URL vidéo, le nom de chaîne de `channelId`. @@ -32,6 +33,6 @@ export class PtAdapter implements ProviderAdapter { channelExternalId, }; }); - return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0 }; + return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0, providerError }; } } diff --git a/src/app/search/adapters/ru.ts b/src/app/search/adapters/ru.ts index b529dd4..2f0ddf9 100644 --- a/src/app/search/adapters/ru.ts +++ b/src/app/search/adapters/ru.ts @@ -12,6 +12,7 @@ export class RuAdapter implements ProviderAdapter { if (params.pageToken) httpParams = httpParams.set('page', params.pageToken); if (params.sort) httpParams = httpParams.set('sort', params.sort); const res = await firstValueFrom(this.http.get(`/api/search`, { params: httpParams })); + const providerError = typeof res?.errors?.ru?.message === 'string' ? res.errors.ru.message : undefined; const items: VideoItem[] = (res?.groups?.ru || []).map((it: any) => ({ id: it.id, provider: 'rumble', @@ -22,6 +23,6 @@ export class RuAdapter implements ProviderAdapter { viewCount: typeof it.views === 'number' ? it.views : undefined, publishedAt: it.publishedAt, })); - return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0 }; + return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0, providerError }; } } diff --git a/src/app/search/adapters/tw.ts b/src/app/search/adapters/tw.ts index 2e0b864..5e579e0 100644 --- a/src/app/search/adapters/tw.ts +++ b/src/app/search/adapters/tw.ts @@ -12,29 +12,38 @@ export class TwAdapter implements ProviderAdapter { if (params.pageToken) httpParams = httpParams.set('page', params.pageToken); if (params.sort) httpParams = httpParams.set('sort', params.sort); const res = await firstValueFrom(this.http.get(`/api/search`, { params: httpParams })); + const providerError = typeof res?.errors?.tw?.message === 'string' ? res.errors.tw.message : undefined; const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => { - // /api/search (tw) ne retourne que des chaînes : le player exige le login - // (?channel=), jamais l'id numérique. Sans `channel`, /watch tente - // ?video= et Twitch répond par une erreur. + // /api/search (tw) renvoie un mixte façon directory : lives, VODs, clips, chaînes. + // Seuls les items 'live' / 'channel' utilisent l'embed ?channel= ; + // VODs (id numérique) et clips (slug) ont leur propre lecture. const login: string | undefined = it.channelExternalId || it.channelHandle || undefined; const kind = String(it?.type || '').toLowerCase(); - const isChannel = kind === 'channel' || kind === 'live' || Boolean(login); + const clipKind = String(it?.kind || '').toLowerCase(); + const isClip = clipKind === 'clip'; + const isChannelLike = kind === 'channel' || kind === 'live'; return { id: it.id, provider: 'twitch', title: it.title, channelName: it.uploaderName, - durationSec: it.duration, + durationSec: typeof it.duration === 'number' ? it.duration : undefined, thumbnailUrl: it.thumbnail, viewCount: typeof it.views === 'number' ? it.views : undefined, publishedAt: it.publishedAt, channelExternalId: login, channelAvatarUrl: it.uploaderAvatar || undefined, - // Login pour l'embed ?channel=... (video-card -> watchQueryParams). - channel: isChannel ? login : undefined, + // Login pour l'embed ?channel=... (video-card -> watchQueryParams), + // uniquement pour les chaînes / lives — jamais pour VODs ni clips. + channel: isChannelLike && !isClip ? login : undefined, + // Statut conservé pour le filtre "Type" (live / vidéo / clip / chaîne). + type: typeof it.type === 'string' ? it.type : undefined, + isLive: typeof it.isLive === 'boolean' ? it.isLive : undefined, + kind: typeof it.kind === 'string' ? it.kind : undefined, + game: typeof it.game === 'string' ? it.game : undefined, } as VideoItem; - }); - return { items, total: Array.isArray(res?.groups?.tw) ? res.groups.tw.length : 0 }; + }).filter((v: VideoItem) => v.id != null && String(v.id).length > 0 && (v.thumbnailUrl || v.channelAvatarUrl)); + return { items, total: items.length, providerError }; } } diff --git a/src/app/search/adapters/yt.ts b/src/app/search/adapters/yt.ts index 6fc4058..3c578e8 100644 --- a/src/app/search/adapters/yt.ts +++ b/src/app/search/adapters/yt.ts @@ -12,6 +12,7 @@ export class YtAdapter implements ProviderAdapter { if (params.pageToken) httpParams = httpParams.set('page', params.pageToken); if (params.sort) httpParams = httpParams.set('sort', params.sort); const res = await firstValueFrom(this.http.get(`/api/search`, { params: httpParams })); + const providerError = typeof res?.errors?.yt?.message === 'string' ? res.errors.yt.message : undefined; const items: VideoItem[] = (res?.groups?.yt || []).map((it: any) => ({ id: it.id, provider: 'youtube', @@ -25,6 +26,6 @@ export class YtAdapter implements ProviderAdapter { // (l'avatar est résolu via /api/channels, l'API search n'en fournit pas). channelExternalId: it.channelExternalId || it.channelId || undefined, })); - return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0 }; + return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0, providerError }; } } diff --git a/src/app/search/api.v1.ts b/src/app/search/api.v1.ts index 4be5a46..794991d 100644 --- a/src/app/search/api.v1.ts +++ b/src/app/search/api.v1.ts @@ -18,4 +18,6 @@ export interface SearchResponseV1 { page?: number; pageSize?: number; sort?: 'relevance' | 'date' | 'views'; + /** Per-provider backend failures, keyed by provider id. */ + errors?: Partial>; } diff --git a/src/app/search/models.ts b/src/app/search/models.ts index 5dd5472..c3ac80f 100644 --- a/src/app/search/models.ts +++ b/src/app/search/models.ts @@ -18,6 +18,8 @@ export interface SearchResult { items: T[]; total?: number; nextPageToken?: string; // or page: string|number + /** Backend per-provider failure (e.g. quota), surfaced for UI notice. */ + providerError?: string; } export interface ProviderSearchParams { @@ -26,7 +28,7 @@ export interface ProviderSearchParams { sort?: 'relevance'|'date'|'views'|'duration'; time?: 'today'|'7d'|'30d'|'all'; length?: 'short'|'medium'|'long'|'all'; - type?: 'video'|'live'|'shorts'|'all'; + type?: 'video'|'live'|'shorts'|'short'|'recorded'|'channel'|'all'; } export interface ProviderAdapter { diff --git a/src/app/search/search.service.ts b/src/app/search/search.service.ts index fe34a7d..c60b7d9 100644 --- a/src/app/search/search.service.ts +++ b/src/app/search/search.service.ts @@ -64,9 +64,17 @@ export class SearchService { acc[pid as ProviderId] = result.items; return acc; }, {} as Record); + // Surface per-provider backend failures (e.g. YouTube quota) for UI notice. + const errors: Partial> = {}; + for (const [pid, result] of Object.entries(byProvider)) { + if (result?.providerError) errors[pid as ProviderId] = result.providerError; + } const providers = Object.keys(byProvider) as ProviderId[]; - const resp: SearchResponse = { q: this.q$.value, providers, groups }; + const resp: SearchResponse = { + q: this.q$.value, providers, groups, + ...(Object.keys(errors).length > 0 ? { errors } : {}), + }; return resp; }), shareReplay(1) diff --git a/src/app/shared/components/search-result-grid/search-result-grid.component.html b/src/app/shared/components/search-result-grid/search-result-grid.component.html index 2477a57..e196c05 100644 --- a/src/app/shared/components/search-result-grid/search-result-grid.component.html +++ b/src/app/shared/components/search-result-grid/search-result-grid.component.html @@ -1,14 +1,17 @@ -
- - - - - - + +
+ + +
-
+ +
+ +
+ +

Aucun résultat

Essayez une autre recherche ou sélectionnez un autre fournisseur.

diff --git a/src/app/shared/components/search-result-grid/search-result-grid.component.ts b/src/app/shared/components/search-result-grid/search-result-grid.component.ts index 21ab5fb..df5f359 100644 --- a/src/app/shared/components/search-result-grid/search-result-grid.component.ts +++ b/src/app/shared/components/search-result-grid/search-result-grid.component.ts @@ -13,10 +13,21 @@ import { VideoCardSkeletonComponent } from '../video-card-skeleton/video-card-sk }) export class SearchResultGridComponent { @Input() videos: VideoItem[] = []; + // Initial load (no videos yet) -> full skeleton grid. @Input() loading = false; + // Continuation load (videos already shown) -> keep grid, append trailing skeletons. + @Input() loadingMore = false; @Input() skeletons = 12; get skeletonItems() { return new Array(this.skeletons); } + + get moreSkeletonItems() { + return new Array(4); + } + + trackById(_index: number, v: VideoItem): string { + return String((v && (v.id || (v as any).videoId || (v as any).url)) ?? _index); + } } diff --git a/src/app/shared/components/video-card/video-card.component.html b/src/app/shared/components/video-card/video-card.component.html index ed7ff83..459c1a1 100644 --- a/src/app/shared/components/video-card/video-card.component.html +++ b/src/app/shared/components/video-card/video-card.component.html @@ -1,21 +1,31 @@
- +
- +
+ LIVE + CLIP + CHAÎNE +
+ {{ video.durationSec | duration }} + + {{ video.viewCount | number }} spectateurs +

{{ video.title }}

-
- {{ video.viewCount | number }} vues +
+ {{ video.viewCount | number }} vues + Chaîne Twitch + {{ $any(video).game }} {{ video.publishedAt | date:'shortDate' }}
diff --git a/src/app/shared/components/video-card/video-card.component.ts b/src/app/shared/components/video-card/video-card.component.ts index 7abe88e..366811d 100644 --- a/src/app/shared/components/video-card/video-card.component.ts +++ b/src/app/shared/components/video-card/video-card.component.ts @@ -21,10 +21,40 @@ export class VideoCardComponent { buildQueryParams(v: VideoItem): Record { const qp: any = { p: v.provider }; if (v.provider === 'odysee' && v.slug) qp.slug = v.slug; - if (v.provider === 'twitch' && v.channel) qp.channel = v.channel; + // Twitch : chaînes/lives via ?channel=, clips via ?clip=. + if (v.provider === 'twitch') { + const kind = String((v as any)?.type || '').toLowerCase(); + const clipKind = String((v as any)?.kind || '').toLowerCase(); + if (clipKind === 'clip') qp.clip = v.id; + else if ((kind === 'live' || kind === 'channel') && v.channel) qp.channel = v.channel; + } return qp; } + get isTwitchLive(): boolean { + const v: any = this.video; + return v?.provider === 'twitch' && (v?.isLive === true || String(v?.type || '').toLowerCase() === 'live'); + } + + get isTwitchChannel(): boolean { + const v: any = this.video; + return v?.provider === 'twitch' && String(v?.type || '').toLowerCase() === 'channel' && v?.isLive !== true; + } + + get isTwitchClip(): boolean { + const v: any = this.video; + return v?.provider === 'twitch' && String(v?.kind || '').toLowerCase() === 'clip'; + } + + get showDuration(): boolean { + const d = Number((this.video as any)?.durationSec ?? NaN); + return Number.isFinite(d) && d > 0; + } + + get providerId(): string { + return String((this.video as any)?.provider || ''); + } + get channelProviderId(): ProviderId | null { const map: Record = { youtube: 'yt', diff --git a/src/app/shared/models/video-item.model.ts b/src/app/shared/models/video-item.model.ts index 049e294..98fc6df 100644 --- a/src/app/shared/models/video-item.model.ts +++ b/src/app/shared/models/video-item.model.ts @@ -15,4 +15,9 @@ export interface VideoItem { // Provider-specific routing hints slug?: string; // Odysee slug when available channel?: string; // Twitch channel when item represents a channel/live + // Live status (only providers exposing it, e.g. Twitch, set these) + type?: string; // e.g. 'live' | 'channel' | 'video' + isLive?: boolean; // true when actually broadcasting now + kind?: string; // e.g. Twitch 'vod' | 'clip' + game?: string; // e.g. Twitch game/category name } diff --git a/src/components/account/preferences/preferences.component.html b/src/components/account/preferences/preferences.component.html index fee6dd6..e2afeeb 100644 --- a/src/components/account/preferences/preferences.component.html +++ b/src/components/account/preferences/preferences.component.html @@ -50,6 +50,29 @@
+ + +
+
+

{{ 'preferences.download_languages' | t : 'Langues à télécharger' }}

+

{{ 'preferences.download_languages_hint' | t : 'Seules les langues activées sont affichées et téléchargées (sous-titres/transcriptions). Français et anglais par défaut.' }}

+
+
+
+ +
+

{{ 'preferences.download_languages_empty' | t : 'Sélectionnez au moins une langue.' }}

+
+
diff --git a/src/components/account/preferences/preferences.component.ts b/src/components/account/preferences/preferences.component.ts index 36c0b24..2f71fac 100644 --- a/src/components/account/preferences/preferences.component.ts +++ b/src/components/account/preferences/preferences.component.ts @@ -35,6 +35,27 @@ export class PreferencesComponent { themes = ['system', 'light', 'dark', 'black', 'blue']; qualities = ['auto', '720p', '1080p']; + // Langues de sous-titres/transcripts à télécharger (défaut fr+en). + // Seules les langues activées sont affichées et téléchargées. + readonly DEFAULT_DOWNLOAD_LANGUAGES = ['fr', 'en']; + downloadLanguageOptions: Array<{ code: string; label: string }> = [ + { code: 'fr', label: 'Français' }, + { code: 'en', label: 'English' }, + { code: 'es', label: 'Español' }, + { code: 'de', label: 'Deutsch' }, + { code: 'it', label: 'Italiano' }, + { code: 'pt', label: 'Português' }, + { code: 'nl', label: 'Nederlands' }, + { code: 'pl', label: 'Polski' }, + { code: 'ru', label: 'Русский' }, + { code: 'ar', label: 'العربية' }, + { code: 'zh', label: '中文' }, + { code: 'ja', label: '日本語' }, + { code: 'hi', label: 'हिन्दी' }, + { code: 'ko', label: '한국어' }, + ]; + downloadLanguages = signal([...this.DEFAULT_DOWNLOAD_LANGUAGES]); + // PeerTube instances management newPeerTubeInstance = signal(''); peerTubeInstances = this.instances.peerTubeInstances; // signal @@ -51,6 +72,12 @@ export class PreferencesComponent { // apply language immediately if (p.language) this.i18n.setLanguage(p.language); if (p.defaultProvider) this.defaultProvider.set(p.defaultProvider as Provider); + if (Array.isArray(p.downloadLanguages)) { + const cleaned = p.downloadLanguages + .map(v => String(v || '').trim().toLowerCase().split('-')[0]) + .filter(v => this.downloadLanguageOptions.some(o => o.code === v)); + this.downloadLanguages.set(Array.from(new Set(cleaned))); + } if (p.theme) this.theme.set(p.theme); if (p.videoQuality) this.videoQuality.set(p.videoQuality); if (p.region) this.region.set(p.region); @@ -61,14 +88,36 @@ export class PreferencesComponent { } catch {} } + isDownloadLanguageEnabled(code: string): boolean { + return this.downloadLanguages().includes(code); + } + + toggleDownloadLanguage(code: string): void { + const current = [...this.downloadLanguages()]; + const idx = current.indexOf(code); + if (idx >= 0) current.splice(idx, 1); + else current.push(code); + // Garde l'ordre du catalogue pour une persistance stable + const order = new Map(this.downloadLanguageOptions.map((o, i) => [o.code, i])); + current.sort((a, b) => (order.get(a) ?? 99) - (order.get(b) ?? 99)); + this.downloadLanguages.set(current); + this.error.set(null); + } + async onSave(ev: Event) { ev.preventDefault(); + const langs = this.downloadLanguages(); + if (!langs.length) { + this.error.set('Sélectionnez au moins une langue à télécharger.'); + return; + } this.saving.set(true); this.notice.set(null); try { const patch: Partial = { language: this.language(), defaultProvider: this.defaultProvider(), + downloadLanguages: [...langs], theme: this.theme(), videoQuality: this.videoQuality(), region: this.region(), diff --git a/src/components/channel/channel-video-grid/channel-video-grid.component.html b/src/components/channel/channel-video-grid/channel-video-grid.component.html index 43a1b92..3a5429f 100644 --- a/src/components/channel/channel-video-grid/channel-video-grid.component.html +++ b/src/components/channel/channel-video-grid/channel-video-grid.component.html @@ -1,5 +1,5 @@ - -
+ +
@@ -13,19 +13,19 @@
-
+

{{ emptyMessage }}

Essayez un autre onglet ou revenez plus tard.

- -
- + +
+
-
- + - -