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:
+5
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -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": {
|
||||
|
||||
+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 [];
|
||||
|
||||
@@ -13,6 +13,7 @@ export class DmAdapter implements ProviderAdapter<VideoItem> {
|
||||
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<any>(`/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<VideoItem> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export class OdAdapter implements ProviderAdapter<VideoItem> {
|
||||
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<any>(`/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<VideoItem> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export class PtAdapter implements ProviderAdapter<VideoItem> {
|
||||
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<any>(`/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<VideoItem> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export class RuAdapter implements ProviderAdapter<VideoItem> {
|
||||
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<any>(`/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<VideoItem> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,29 +12,38 @@ export class TwAdapter implements ProviderAdapter<VideoItem> {
|
||||
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<any>(`/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=<login>), jamais l'id numérique. Sans `channel`, /watch tente
|
||||
// ?video=<id> 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=<login> ;
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export class YtAdapter implements ProviderAdapter<VideoItem> {
|
||||
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<any>(`/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<VideoItem> {
|
||||
// (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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Record<ProviderId, string>>;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface SearchResult<T = SearchItem> {
|
||||
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<T = SearchItem> {
|
||||
|
||||
@@ -64,9 +64,17 @@ export class SearchService {
|
||||
acc[pid as ProviderId] = result.items;
|
||||
return acc;
|
||||
}, {} as Record<ProviderId, VideoItem[]>);
|
||||
// Surface per-provider backend failures (e.g. YouTube quota) for UI notice.
|
||||
const errors: Partial<Record<ProviderId, string>> = {};
|
||||
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)
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<ng-container *ngIf="loading">
|
||||
<app-video-card-skeleton *ngFor="let _ of skeletonItems"></app-video-card-skeleton>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!loading">
|
||||
<app-video-card *ngFor="let video of videos" [video]="video"></app-video-card>
|
||||
<!-- Videos already loaded: keep them mounted during continuation loads (no scroll jump). -->
|
||||
<div *ngIf="videos.length > 0" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<app-video-card *ngFor="let video of videos; trackBy: trackById" [video]="video"></app-video-card>
|
||||
<ng-container *ngIf="loadingMore">
|
||||
<app-video-card-skeleton *ngFor="let _ of moreSkeletonItems"></app-video-card-skeleton>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading && videos.length === 0" class="flex flex-col items-center justify-center gap-4 rounded-lg border border-zinc-800 bg-zinc-900/50 p-8 text-center">
|
||||
<!-- Initial load only: skeleton grid while the first page arrives. -->
|
||||
<div *ngIf="videos.length === 0 && loading" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<app-video-card-skeleton *ngFor="let _ of skeletonItems"></app-video-card-skeleton>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading && !loadingMore && videos.length === 0" class="flex flex-col items-center justify-center gap-4 rounded-lg border border-zinc-800 bg-zinc-900/50 p-8 text-center">
|
||||
<h3 class="text-lg font-semibold text-white">Aucun résultat</h3>
|
||||
<p class="text-zinc-400">Essayez une autre recherche ou sélectionnez un autre fournisseur.</p>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<a [routerLink]="['/watch', video.id]" [queryParams]="buildQueryParams(video)" [state]="{ video }" class="group flex flex-col gap-2">
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-zinc-800">
|
||||
<img [src]="video.thumbnailUrl" [alt]="video.title" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
|
||||
<img [src]="video.thumbnailUrl" [alt]="video.title" loading="lazy" decoding="async" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
|
||||
<div class="absolute top-1 right-1">
|
||||
<app-provider-badge [provider]="video.provider"></app-provider-badge>
|
||||
</div>
|
||||
<span *ngIf="video.durationSec !== undefined" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
||||
<div class="absolute top-1 left-1 flex gap-1">
|
||||
<span *ngIf="isTwitchLive" class="rounded bg-purple-600 px-1.5 py-0.5 text-[11px] font-bold text-white">LIVE</span>
|
||||
<span *ngIf="isTwitchClip" class="rounded bg-red-600 px-1.5 py-0.5 text-[11px] font-bold text-white">CLIP</span>
|
||||
<span *ngIf="isTwitchChannel" class="rounded bg-slate-700/90 px-1.5 py-0.5 text-[11px] font-bold text-slate-200 ring-1 ring-slate-500/50">CHAÎNE</span>
|
||||
</div>
|
||||
<span *ngIf="showDuration" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
||||
{{ video.durationSec | duration }}
|
||||
</span>
|
||||
<span *ngIf="isTwitchLive && video.viewCount" class="absolute bottom-1 left-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
|
||||
{{ video.viewCount | number }} spectateurs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-semibold text-zinc-100 line-clamp-2" [title]="video.title">
|
||||
{{ video.title }}
|
||||
</h3>
|
||||
<div class="text-xs text-zinc-400 flex gap-2">
|
||||
<span *ngIf="video.viewCount">{{ video.viewCount | number }} vues</span>
|
||||
<div class="text-xs text-zinc-400 flex flex-wrap gap-x-2 gap-y-0.5">
|
||||
<span *ngIf="video.viewCount && !isTwitchLive">{{ video.viewCount | number }} vues</span>
|
||||
<span *ngIf="isTwitchChannel">Chaîne Twitch</span>
|
||||
<span *ngIf="$any(video).game">{{ $any(video).game }}</span>
|
||||
<span *ngIf="video.publishedAt">{{ video.publishedAt | date:'shortDate' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,10 +21,40 @@ export class VideoCardComponent {
|
||||
buildQueryParams(v: VideoItem): Record<string, any> {
|
||||
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=<login>, clips via ?clip=<slug>.
|
||||
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<VideoItem['provider'], ProviderId> = {
|
||||
youtube: 'yt',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -50,6 +50,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Langues à télécharger (sous-titres/transcripts) -->
|
||||
<div class="bg-slate-800/50 rounded-xl border border-slate-700 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-700">
|
||||
<h3 class="text-lg font-semibold text-slate-100">{{ 'preferences.download_languages' | t : 'Langues à télécharger' }}</h3>
|
||||
<p class="text-sm text-slate-400">{{ '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.' }}</p>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
<label *ngFor="let opt of downloadLanguageOptions"
|
||||
class="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 cursor-pointer hover:border-slate-500">
|
||||
<input type="checkbox"
|
||||
class="h-4 w-4 accent-red-600"
|
||||
[checked]="isDownloadLanguageEnabled(opt.code)"
|
||||
(change)="toggleDownloadLanguage(opt.code)"
|
||||
[name]="'dl-' + opt.code" />
|
||||
<span class="text-sm text-slate-200">{{ opt.label }}</span>
|
||||
<span class="text-xs text-slate-500 uppercase">{{ opt.code }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p *ngIf="downloadLanguages().length === 0" class="mt-3 text-sm text-red-300">{{ 'preferences.download_languages_empty' | t : 'Sélectionnez au moins une langue.' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- PeerTube Instances (below general) -->
|
||||
|
||||
@@ -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<string[]>([...this.DEFAULT_DOWNLOAD_LANGUAGES]);
|
||||
|
||||
// PeerTube instances management
|
||||
newPeerTubeInstance = signal<string>('');
|
||||
peerTubeInstances = this.instances.peerTubeInstances; // signal<string[]>
|
||||
@@ -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<UserPreferences> = {
|
||||
language: this.language(),
|
||||
defaultProvider: this.defaultProvider(),
|
||||
downloadLanguages: [...langs],
|
||||
theme: this.theme(),
|
||||
videoQuality: this.videoQuality(),
|
||||
region: this.region(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- Loading initial : squelettes -->
|
||||
<div *ngIf="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<!-- Loading initial : squelettes (uniquement si rien n'est encore affiché) -->
|
||||
<div *ngIf="loading && videos.length === 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<app-video-card-skeleton *ngFor="let _ of [0,1,2,3,4,5,6,7]"></app-video-card-skeleton>
|
||||
</div>
|
||||
|
||||
@@ -13,19 +13,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div *ngIf="!loading && !error && videos.length === 0" class="flex flex-col items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-10 text-center">
|
||||
<div *ngIf="!loading && !loadingMore && !error && videos.length === 0" class="flex flex-col items-center gap-2 rounded-xl border border-zinc-800 bg-zinc-900/50 p-10 text-center">
|
||||
<h3 class="text-lg font-semibold text-white">{{ emptyMessage }}</h3>
|
||||
<p class="text-sm text-zinc-400">Essayez un autre onglet ou revenez plus tard.</p>
|
||||
</div>
|
||||
|
||||
<!-- Grille vidéos classiques -->
|
||||
<div *ngIf="!loading && !error && videos.length > 0 && layout === 'video'" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<app-video-card *ngFor="let video of videos" [video]="video"></app-video-card>
|
||||
<!-- Grille vidéos classiques (reste montée pendant le chargement suivant) -->
|
||||
<div *ngIf="!error && videos.length > 0 && layout === 'video'" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<app-video-card *ngFor="let video of videos; trackBy: trackById" [video]="video"></app-video-card>
|
||||
</div>
|
||||
|
||||
<!-- Grille shorts verticale 9:16 -->
|
||||
<div *ngIf="!loading && !error && videos.length > 0 && layout === 'short'" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||
<a *ngFor="let video of videos" [routerLink]="['/watch', video.id]" [queryParams]="{ p: video.provider }"
|
||||
<div *ngIf="!error && videos.length > 0 && layout === 'short'" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||
<a *ngFor="let video of videos; trackBy: trackById" [routerLink]="['/watch', video.id]" [queryParams]="{ p: video.provider }"
|
||||
class="group overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
|
||||
<div class="aspect-[9/16] w-full overflow-hidden bg-zinc-800">
|
||||
<img [src]="video.thumbnailUrl" [alt]="video.title" loading="lazy" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
|
||||
@@ -37,10 +37,14 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Sentinelle infinite scroll -->
|
||||
<div *ngIf="hasMore && !loading && !error" #sentinel class="flex justify-center py-6" aria-hidden="true">
|
||||
<div *ngIf="loadingMore" class="flex items-center gap-2 text-sm text-zinc-400">
|
||||
<!-- Standard infinite scroll : sentinelle partagée + spinner + fin -->
|
||||
<div *ngIf="loadingMore && hasMore && !error" class="flex justify-center py-6" aria-hidden="true">
|
||||
<div class="flex items-center gap-2 text-sm text-zinc-400">
|
||||
<span class="h-5 w-5 animate-spin rounded-full border-2 border-zinc-700 border-t-red-500"></span>
|
||||
Chargement…
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="!loading && !loadingMore && !hasMore && !error && videos.length > 0" class="flex justify-center py-4 text-xs text-zinc-500">
|
||||
Fin des résultats
|
||||
</div>
|
||||
<app-infinite-anchor *ngIf="hasMore && !loading && !error" [busy]="loadingMore" (loadMore)="loadMore.emit()"></app-infinite-anchor>
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import type { VideoItem } from '../../../app/shared/models/video-item.model';
|
||||
import { VideoCardComponent } from '../../../app/shared/components/video-card/video-card.component';
|
||||
import { VideoCardSkeletonComponent } from '../../../app/shared/components/video-card-skeleton/video-card-skeleton.component';
|
||||
import { InfiniteAnchorComponent } from '../../shared/infinite-anchor/infinite-anchor.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-video-grid',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, VideoCardComponent, VideoCardSkeletonComponent],
|
||||
imports: [CommonModule, RouterLink, VideoCardComponent, VideoCardSkeletonComponent, InfiniteAnchorComponent],
|
||||
templateUrl: './channel-video-grid.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ChannelVideoGridComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||
export class ChannelVideoGridComponent {
|
||||
@Input() videos: VideoItem[] = [];
|
||||
@Input() loading = false;
|
||||
@Input() loadingMore = false;
|
||||
@@ -36,34 +31,7 @@ export class ChannelVideoGridComponent implements AfterViewInit, OnChanges, OnDe
|
||||
@Output() loadMore = new EventEmitter<void>();
|
||||
@Output() retry = new EventEmitter<void>();
|
||||
|
||||
@ViewChild('sentinel', { static: false }) sentinel?: ElementRef<HTMLDivElement>;
|
||||
private observer: IntersectionObserver | null = null;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.observe();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['hasMore'] || changes['videos']) {
|
||||
// Ré-observe après chaque rendu (sentinelle recréée par *ngIf)
|
||||
queueMicrotask(() => this.observe());
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.observer?.disconnect();
|
||||
}
|
||||
|
||||
private observe(): void {
|
||||
this.observer?.disconnect();
|
||||
const el = this.sentinel?.nativeElement;
|
||||
if (!el) return;
|
||||
this.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) this.loadMore.emit();
|
||||
},
|
||||
{ rootMargin: '600px' },
|
||||
);
|
||||
this.observer.observe(el);
|
||||
trackById(_index: number, v: VideoItem): string {
|
||||
return String(v?.id ?? _index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,8 @@ export class HomeComponent {
|
||||
return;
|
||||
}
|
||||
this.busyMore.set(true);
|
||||
this.apiService.getTrendingPage(this.nextCursor()).subscribe(res => {
|
||||
this.apiService.getTrendingPage(this.nextCursor()).subscribe({
|
||||
next: res => {
|
||||
const merged = [...this.trendingVideos(), ...res.items];
|
||||
this.trendingVideos.set(merged);
|
||||
this.nextCursor.set(res.nextCursor || null);
|
||||
@@ -100,6 +101,17 @@ export class HomeComponent {
|
||||
this.notice.set(`Les vidéos ne sont pas disponibles pour le provider "${label}" pour le moment. Réessayez plus tard ou choisissez un autre provider.`);
|
||||
}
|
||||
}
|
||||
},
|
||||
// Never leave the infinite scroll stuck: release flags so scrolling retries.
|
||||
error: () => {
|
||||
this.busyMore.set(false);
|
||||
this.loading.set(false);
|
||||
if (!this.notice()) {
|
||||
this.notice.set(this.trendingVideos().length > 0
|
||||
? 'Le chargement de la suite a échoué. Faites défiler pour réessayer.'
|
||||
: 'Le chargement a échoué. Réessayez plus tard ou changez de fournisseur.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
<input #qInput type="search"
|
||||
class="flex-1 bg-transparent outline-none text-slate-100 placeholder-slate-400 px-2"
|
||||
[placeholder]="placeholder"
|
||||
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" name="q"
|
||||
[ngModel]="query()" (ngModelChange)="queryUpdate($event)" (keydown)="handleKeydown($event)" (keydown.enter)="onSubmit($event)" (focus)="onSuggestInputFocus()" name="q"
|
||||
autocomplete="off" aria-label="Rechercher des vidéos"
|
||||
role="combobox" aria-expanded="false" aria-autocomplete="list"
|
||||
[attr.aria-expanded]="atOpen()"
|
||||
[attr.aria-controls]="atOpen() ? 'atOptionsList' : null"
|
||||
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0 ? 'atOption-' + atIndex() : null"/>
|
||||
[attr.aria-expanded]="atOpen() || suggestionsOpen()"
|
||||
[attr.aria-controls]="atOpen() ? 'atOptionsList' : (suggestionsOpen() ? 'sgOptionsList' : null)"
|
||||
[attr.aria-activedescendant]="atOpen() && atOptions().length > 0 ? 'atOption-' + atIndex() : (suggestionsOpen() && suggestIndex() >= 0 ? 'sgOption-' + suggestIndex() : null)"/>
|
||||
|
||||
<!-- Actions -->
|
||||
<button type="button" (click)="openPicker()" class="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600" aria-label="Choisir des fournisseurs (@)">@</button>
|
||||
@@ -75,6 +75,31 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Step 15: query typeahead panel (hidden while the @ popover is open) -->
|
||||
<div *ngIf="suggestionsOpen() && !atOpen() && suggestFlat().length > 0"
|
||||
class="absolute z-40 mt-1 left-3 right-3 rounded-lg shadow-xl overflow-hidden bg-slate-900 border border-slate-700 text-slate-200"
|
||||
style="top: 100%">
|
||||
<ul id="sgOptionsList" role="listbox" aria-label="Suggestions de recherche"
|
||||
class="max-h-72 overflow-y-auto py-1">
|
||||
<li *ngFor="let item of suggestFlat(); let i = index" role="option"
|
||||
[attr.aria-selected]="i === suggestIndex()"
|
||||
[id]="'sgOption-' + i">
|
||||
<button type="button" (click)="applySuggestion(item.text, true)"
|
||||
(mouseenter)="suggestIndex.set(i)"
|
||||
class="w-full flex items-center gap-2 text-left px-3 py-2 text-sm"
|
||||
[ngClass]="{
|
||||
'bg-slate-800': i === suggestIndex(),
|
||||
'hover:bg-slate-800': i !== suggestIndex()
|
||||
}">
|
||||
<span *ngIf="!item.provider" class="text-slate-500" title="Recherche récente">🕘</span>
|
||||
<span *ngIf="item.provider" class="text-[10px] font-bold px-1.5 py-0.5 rounded bg-slate-700 text-slate-300">{{ providerBadge(item.provider) }}</span>
|
||||
<span class="flex-1 truncate" [title]="item.text"><span>{{ suggestHighlight(item.text).pre }}</span><mark *ngIf="suggestHighlight(item.text).match" class="bg-transparent text-inherit font-semibold underline decoration-red-500">{{ suggestHighlight(item.text).match }}</mark><span>{{ suggestHighlight(item.text).post }}</span></span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div *ngIf="suggestLoading()" class="px-3 py-1 text-xs text-slate-500 border-t border-slate-800">Chargement…</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Menu (Ctrl/Cmd+K) -->
|
||||
<div *ngIf="quickOpen()" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background-color: rgba(0, 0, 0, 0.6);" role="presentation" (click)="closeQuickMenu()">
|
||||
<div #quickDialog style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -0%); width: 90%; max-width: 56rem; max-height: 85vh; border-radius: 0.75rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); overflow: hidden; background-color: #0f172a; border: 1px solid #334155; display: flex; flex-direction: column;" role="dialog" aria-modal="true" aria-labelledby="quickMenuTitle" (click)="$event.stopPropagation()" (keydown)="onQuickMenuKeydown($event)">
|
||||
|
||||
@@ -42,15 +42,88 @@
|
||||
}
|
||||
|
||||
@if (hasQuery()) {
|
||||
<div class="my-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-slate-300" for="sortSelect">Trier par</label>
|
||||
<select id="sortSelect" class="bg-slate-900 text-slate-100 border border-slate-700 rounded px-2 py-1 text-sm"
|
||||
[value]="sortParam()" (change)="onSortChange($event)">
|
||||
<option value="relevance">Pertinence</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="views">Vues</option>
|
||||
</select>
|
||||
<!-- Pills de type façon directory Twitch : Directs / Vidéos / Clips / Chaînes -->
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-1" role="tablist" aria-label="Filtrer par type de contenu">
|
||||
<button class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'all' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'all'" (click)="setTypePill('all')">
|
||||
Tout ({{ typeCounts().all }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().live > 0 || typePill() === 'live'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'live' ? 'bg-purple-600 text-white border-purple-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'live'" (click)="setTypePill('live')">
|
||||
Directs ({{ typeCounts().live }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().video > 0 || typePill() === 'video'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'video' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'video'" (click)="setTypePill('video')">
|
||||
Vidéos ({{ typeCounts().video }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().clip > 0 || typePill() === 'short'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'short' ? 'bg-red-600 text-white border-red-500' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'short'" (click)="setTypePill('short')">
|
||||
Clips ({{ typeCounts().clip }})
|
||||
</button>
|
||||
<button *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" class="px-3 py-1 rounded-full text-sm whitespace-nowrap border transition-colors"
|
||||
[ngClass]="typePill() === 'channel' ? 'bg-slate-200 text-slate-900 border-slate-200' : 'bg-slate-800 text-slate-200 border-slate-600 hover:bg-slate-700'"
|
||||
[attr.aria-pressed]="typePill() === 'channel'" (click)="setTypePill('channel')">
|
||||
Chaînes ({{ typeCounts().channel }})
|
||||
</button>
|
||||
</div>
|
||||
<!-- Barre de filtrage (même UX que la page thème / fournisseur) -->
|
||||
<div class="bg-slate-800/50 p-4 rounded-xl border border-slate-700 my-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div>
|
||||
<label for="sortSelect" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.sort' | t }}</label>
|
||||
<select id="sortSelect" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
[value]="sortParam()" (change)="onSortChange($event)">
|
||||
<option value="relevance">{{ 'filter.sort.relevance' | t }}</option>
|
||||
<option value="date">{{ 'filter.sort.date' | t }}</option>
|
||||
<option value="views">{{ 'filter.sort.views' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="duration-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.duration' | t }}</label>
|
||||
<select id="duration-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
[value]="durationFilter()" (change)="onDurationChange($event)">
|
||||
<option value="any">{{ 'filter.duration.any' | t }}</option>
|
||||
<option value="short">{{ 'filter.duration.short' | t }}</option>
|
||||
<option value="medium">{{ 'filter.duration.medium' | t }}</option>
|
||||
<option value="long">{{ 'filter.duration.long' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.type' | t }}</label>
|
||||
<select id="type-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
[value]="typeFilter()" (change)="onTypeChange($event)">
|
||||
<option value="any">{{ 'filter.type.any' | t }}</option>
|
||||
<option value="video">{{ 'filter.type.video' | t }}</option>
|
||||
<option value="live">{{ 'filter.type.live' | t }}</option>
|
||||
<option value="recorded">{{ 'filter.type.recorded' | t }}</option>
|
||||
<option value="short">{{ 'filter.type.short' | t }}</option>
|
||||
<option value="channel">{{ 'filter.type.channel' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t }}</label>
|
||||
<select id="language-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
[value]="languageFilter()" (change)="onLanguageChange($event)">
|
||||
<option value="any">{{ 'filter.language.any' | t }}</option>
|
||||
<option value="en">{{ 'filter.language.en' | t }}</option>
|
||||
<option value="fr">{{ 'filter.language.fr' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="date-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.date' | t }}</label>
|
||||
<select id="date-filter" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
[value]="dateFilter()" (change)="onDateChange($event)">
|
||||
<option value="any">{{ 'filter.date.any' | t }}</option>
|
||||
<option value="day">{{ 'filter.date.day' | t }}</option>
|
||||
<option value="week">{{ 'filter.date.week' | t }}</option>
|
||||
<option value="month">{{ 'filter.date.month' | t }}</option>
|
||||
<option value="year">{{ 'filter.date.year' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,17 +134,19 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-search-result-grid [videos]="filteredResults()" [loading]="loading()"></app-search-result-grid>
|
||||
|
||||
<!-- Infinite scroll anchor & indicators -->
|
||||
<app-search-result-grid [videos]="filteredResults()" [loading]="loading()" [loadingMore]="loadingMore()"></app-search-result-grid>
|
||||
|
||||
<!-- Standard infinite scroll: shared sentinel + bottom loader + end state -->
|
||||
<div class="flex flex-col items-center justify-center py-6">
|
||||
@if (loading()) {
|
||||
@if (loading() || loadingMore()) {
|
||||
<div class="text-slate-400 text-sm">Chargement…</div>
|
||||
}
|
||||
@if (endReached()) {
|
||||
@if (!loading() && !loadingMore() && endReached() && filteredResults().length > 0) {
|
||||
<div class="text-slate-500 text-xs">Fin des résultats</div>
|
||||
}
|
||||
</div>
|
||||
<div #infiniteScrollAnchor class="h-1 w-full"></div>
|
||||
<app-infinite-anchor [disabled]="endReached() || !hasQuery()"
|
||||
[busy]="loading() || loadingMore()"
|
||||
(loadMore)="loadNextPage()"></app-infinite-anchor>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewChild, ElementRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, ParamMap } from '@angular/router';
|
||||
@@ -9,7 +9,9 @@ import { HistoryService } from '../../services/history.service';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
import { SearchResultGridComponent } from 'src/app/shared/components/search-result-grid/search-result-grid.component';
|
||||
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
|
||||
import { VideoItem } from 'src/app/shared/models/video-item.model';
|
||||
import { isShortVideo } from 'src/app/shared/utils/video-kind';
|
||||
import { SearchService, type SearchResponse } from '../../app/search/search.service';
|
||||
import { UserService, type UserPreferences } from '../../services/user.service';
|
||||
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
|
||||
@@ -20,9 +22,9 @@ import { Observable, Subscription } from 'rxjs';
|
||||
standalone: true,
|
||||
templateUrl: './search.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, TranslatePipe, SearchResultGridComponent]
|
||||
imports: [CommonModule, TranslatePipe, SearchResultGridComponent, InfiniteAnchorComponent]
|
||||
})
|
||||
export class SearchComponent implements AfterViewInit {
|
||||
export class SearchComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private api = inject(YoutubeApiService);
|
||||
@@ -34,6 +36,8 @@ export class SearchComponent implements AfterViewInit {
|
||||
|
||||
q = signal<string>('');
|
||||
loading = signal<boolean>(false);
|
||||
// Continuation (page > 1) load: grid stays mounted, only a bottom loader shows.
|
||||
loadingMore = signal<boolean>(false);
|
||||
notice = signal<string | null>(null);
|
||||
providerParam = signal<Provider | null>(null);
|
||||
themeParam = signal<string | null>(null);
|
||||
@@ -53,7 +57,6 @@ export class SearchComponent implements AfterViewInit {
|
||||
private lastRecordedKey: string | null = null;
|
||||
// Signature to detect new searches (q + providers + sort); excludes page
|
||||
private lastSearchSignature: string | null = null;
|
||||
private io?: IntersectionObserver;
|
||||
|
||||
hasQuery = computed(() => this.q().length > 0);
|
||||
providerLabel = computed(() => {
|
||||
@@ -65,14 +68,31 @@ export class SearchComponent implements AfterViewInit {
|
||||
return this.instances.selectedProviderLabel();
|
||||
});
|
||||
|
||||
// Active providers derived from `providers` query param or fallback to all available
|
||||
// Active providers derived from `providers` query param or fallback to all available.
|
||||
// Always normalized to short ids ('yt', …) so ordering/badges stay consistent.
|
||||
activeProviders = computed<ProviderId[]>(() => {
|
||||
const norm = (v: unknown): ProviderId | null => {
|
||||
const s = String(v || '').trim();
|
||||
if (!s) return null;
|
||||
if ((PROVIDERS as { id: string }[]).some(pr => pr.id === s)) return s as ProviderId;
|
||||
const hit = Object.entries(this.providerIdMap).find(([, full]) => full === s);
|
||||
return hit ? (hit[0] as ProviderId) : null;
|
||||
};
|
||||
const p = this.providersListParam();
|
||||
if (p === 'all' || p == null) {
|
||||
try { return (this.instances.providers() || []).map(x => x.id as unknown as ProviderId); }
|
||||
try {
|
||||
const ids = (this.instances.providers() || [])
|
||||
.map(x => norm(x.id))
|
||||
.filter((x): x is ProviderId => !!x);
|
||||
if (ids.length > 0) return ids;
|
||||
return ['yt','dm','tw','pt','od','ru'];
|
||||
}
|
||||
catch { return ['yt','dm','tw','pt','od','ru']; }
|
||||
}
|
||||
if (Array.isArray(p) && p.length > 0) return p as ProviderId[];
|
||||
if (Array.isArray(p) && p.length > 0) {
|
||||
const ids = p.map(norm).filter((x): x is ProviderId => !!x);
|
||||
if (ids.length > 0) return ids;
|
||||
}
|
||||
return ['yt','dm','tw','pt','od','ru'];
|
||||
});
|
||||
|
||||
@@ -134,24 +154,9 @@ export class SearchComponent implements AfterViewInit {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Set up IntersectionObserver for infinite scroll
|
||||
ngAfterViewInit(): void {
|
||||
const anchor = this.infiniteScrollAnchor?.nativeElement;
|
||||
if (!anchor) return;
|
||||
this.io = new IntersectionObserver((entries) => {
|
||||
const e = entries[0];
|
||||
if (e && e.isIntersecting) {
|
||||
this.loadNextPage();
|
||||
}
|
||||
}, { root: null, rootMargin: '200px 0px', threshold: 0 });
|
||||
this.io.observe(anchor);
|
||||
}
|
||||
|
||||
// Optional: clean up observer
|
||||
ngOnDestroy(): void {
|
||||
try { this.io?.disconnect(); } catch {}
|
||||
}
|
||||
|
||||
// Infinite scroll is driven by the shared <app-infinite-anchor> sentinel
|
||||
// (same standard as home / provider-theme / channel grids): it emits
|
||||
// `loadMore` when visible, honoring `disabled` (end) and `busy` (loading).
|
||||
// Page title with search query
|
||||
pageHeading = computed(() => {
|
||||
const q = this.q();
|
||||
@@ -167,6 +172,13 @@ export class SearchComponent implements AfterViewInit {
|
||||
// For Twitch provider: 'twitch_all' | 'twitch_live' | 'twitch_vod'.
|
||||
filterTag = signal<string>('all');
|
||||
|
||||
// Filter bar (same UX as the provider/theme page): client-side selects.
|
||||
// Server-side sort stays on `sortParam` (URL); these four filter locally.
|
||||
durationFilter = signal<'any' | 'short' | 'medium' | 'long'>('any');
|
||||
typeFilter = signal<'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'>('any');
|
||||
languageFilter = signal<'any' | 'en' | 'fr'>('any');
|
||||
dateFilter = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
|
||||
|
||||
// Unified total results count (sum of groups lengths)
|
||||
unifiedTotal = computed(() => {
|
||||
const g = this.groups();
|
||||
@@ -184,23 +196,24 @@ export class SearchComponent implements AfterViewInit {
|
||||
return Object.values(g).flat();
|
||||
});
|
||||
|
||||
// Helper computed: filtered list based on active tag
|
||||
// Helper computed: filtered list based on active tag + filter bar
|
||||
filteredResults = computed(() => {
|
||||
const list = this.allVideos();
|
||||
if (!Array.isArray(list) || list.length === 0) return [];
|
||||
|
||||
const filtered = this.applyTagFilter(list);
|
||||
const filtered = this.applyFilterBar(this.applyTagFilter(list));
|
||||
return this.sortVideos(filtered);
|
||||
});
|
||||
|
||||
// Filter each provider group separately for sectioned rendering
|
||||
filteredGroups = computed(() => {
|
||||
const predicate = this.buildTagPredicate();
|
||||
const tagPredicate = this.buildTagPredicate();
|
||||
const barPredicate = this.buildFilterBarPredicate();
|
||||
const g = this.groups();
|
||||
const result: Record<string, any[]> = {};
|
||||
|
||||
Object.entries(g || {}).forEach(([pid, list]) => {
|
||||
result[pid] = Array.isArray(list) ? list.filter(predicate) : [];
|
||||
result[pid] = Array.isArray(list) ? list.filter((v) => tagPredicate(v) && barPredicate(v)) : [];
|
||||
});
|
||||
return result;
|
||||
});
|
||||
@@ -304,11 +317,16 @@ export class SearchComponent implements AfterViewInit {
|
||||
this.endReached.set(total === 0);
|
||||
}
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.error.set(null);
|
||||
// Surface per-provider backend failures (e.g. YouTube quota) so
|
||||
// users understand why only some providers return results.
|
||||
this.reflectProviderErrors(resp?.errors);
|
||||
},
|
||||
error: () => {
|
||||
this.groups.set({} as any);
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.error.set('Le service de recherche est temporairement indisponible. Réessayer.');
|
||||
}
|
||||
});
|
||||
@@ -344,12 +362,21 @@ export class SearchComponent implements AfterViewInit {
|
||||
|
||||
if (q) {
|
||||
this.notice.set(null);
|
||||
// Reset active tag on new query
|
||||
// Reset active tag + filter bar on new query
|
||||
const provider = prov || this.instances.selectedProvider();
|
||||
this.filterTag.set(provider === 'twitch' ? 'twitch_all' : 'all');
|
||||
this.durationFilter.set('any');
|
||||
this.typeFilter.set('any');
|
||||
this.languageFilter.set('any');
|
||||
this.dateFilter.set('any');
|
||||
|
||||
// Use unified search in all cases (URL providers > user default preference > single provider)
|
||||
const providersToUse = providersList || this.defaultProvidersPref() || [provider as ProviderId];
|
||||
// Use unified search in all cases (URL providers > user default preference > single provider).
|
||||
// Normalized to short ids so every adapter resolves (legacy full names like
|
||||
// 'youtube' would otherwise match no adapter and return nothing).
|
||||
const rawProviders = providersList || this.defaultProvidersPref() || [provider as ProviderId];
|
||||
const providersToUse: ProviderId[] | 'all' = rawProviders === 'all'
|
||||
? 'all'
|
||||
: (rawProviders as unknown[]).map(p => this.toProviderId(p)).filter((p): p is ProviderId => !!p);
|
||||
this.unified.setQuery(q);
|
||||
this.unified.setProviders(providersToUse);
|
||||
|
||||
@@ -374,10 +401,16 @@ export class SearchComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
this.showUnified.set(true);
|
||||
this.loading.set(true);
|
||||
if (isNewSearch) {
|
||||
this.groups.set({} as any); // Clear only for a new search
|
||||
this.endReached.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.loading.set(true);
|
||||
// New search (or sort/filter reset): results restart, scroll to top.
|
||||
// Continuation pages must NOT touch scroll (handled without router nav).
|
||||
try { window.scrollTo(0, 0); } catch {}
|
||||
} else if (!this.groups() || Object.keys(this.groups()).length === 0) {
|
||||
this.loading.set(true);
|
||||
}
|
||||
this.error.set(null);
|
||||
} else {
|
||||
@@ -482,13 +515,147 @@ export class SearchComponent implements AfterViewInit {
|
||||
return Array.isArray(list) ? list.filter(predicate) : [];
|
||||
}
|
||||
|
||||
// ---- Filter bar (same UX as the provider/theme page) ----
|
||||
|
||||
private isLiveItem(item: any): boolean {
|
||||
if (!item) return false;
|
||||
if (item.isLive === true) return true;
|
||||
// Strict: only an explicit 'live' type counts (unlike the shared util,
|
||||
// offline Twitch channels typed 'channel' must NOT match).
|
||||
return String(item.type || '').toLowerCase() === 'live';
|
||||
}
|
||||
|
||||
private isShortItem(item: any): boolean {
|
||||
if (!item) return false;
|
||||
return isShortVideo({
|
||||
duration: this.durationSeconds(item),
|
||||
type: typeof item.type === 'string' ? item.type : undefined,
|
||||
provider: typeof item.provider === 'string' ? item.provider : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private isChannelItem(item: any): boolean {
|
||||
if (!item) return false;
|
||||
// Chaîne Twitch hors-ligne : type explicite 'channel' et jamais en direct.
|
||||
if (String(item.type || '').toLowerCase() === 'channel' && item.isLive !== true) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private isClipItem(item: any): boolean {
|
||||
if (!item) return false;
|
||||
return String((item as any)?.kind || '').toLowerCase() === 'clip';
|
||||
}
|
||||
|
||||
/** Compteurs par type pour les pills façon directory Twitch. */
|
||||
typeCounts = computed(() => {
|
||||
const list = this.allVideos();
|
||||
let live = 0, video = 0, clip = 0, channel = 0;
|
||||
for (const v of list || []) {
|
||||
if (this.isChannelItem(v)) { channel++; continue; }
|
||||
if (this.isLiveItem(v)) { live++; continue; }
|
||||
if (this.isClipItem(v) || this.isShortItem(v)) { clip++; continue; }
|
||||
video++;
|
||||
}
|
||||
return { live, video, clip, channel, all: (list || []).length };
|
||||
});
|
||||
|
||||
/** Pills Tous / Directs / Vidéos / Clips / Chaînes synchronisées avec typeFilter. */
|
||||
typePill = computed<'all' | 'live' | 'video' | 'short' | 'channel'>(() => {
|
||||
const t = this.typeFilter();
|
||||
if (t === 'live') return 'live';
|
||||
if (t === 'video' || t === 'recorded') return 'video';
|
||||
if (t === 'short') return 'short';
|
||||
if (t === 'channel') return 'channel';
|
||||
return 'all';
|
||||
});
|
||||
|
||||
setTypePill(v: 'all' | 'live' | 'video' | 'short' | 'channel'): void {
|
||||
if (v === 'all') this.typeFilter.set('any');
|
||||
else if (v === 'video') this.typeFilter.set('video');
|
||||
else this.typeFilter.set(v);
|
||||
}
|
||||
|
||||
private buildFilterBarPredicate(): (item: any) => boolean {
|
||||
const duration = this.durationFilter();
|
||||
const type = this.typeFilter();
|
||||
const lang = this.languageFilter();
|
||||
const date = this.dateFilter();
|
||||
|
||||
if (duration === 'any' && type === 'any' && lang === 'any' && date === 'any') {
|
||||
return () => true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const cutoff = date === 'any' ? 0 : {
|
||||
day: now - 24 * 3600 * 1000,
|
||||
week: now - 7 * 24 * 3600 * 1000,
|
||||
month: now - 30 * 24 * 3600 * 1000,
|
||||
year: now - 365 * 24 * 3600 * 1000,
|
||||
}[date] || 0;
|
||||
|
||||
const isFrText = (s: string) => /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s);
|
||||
const isEnText = (s: string) => /\b(the|a|an|and|for)\b/i.test(s);
|
||||
|
||||
return (item: any) => {
|
||||
if (duration !== 'any') {
|
||||
const d = this.durationSeconds(item);
|
||||
if (duration === 'short' && !(d > 0 && d < 240)) return false;
|
||||
if (duration === 'medium' && !(d >= 240 && d < 1200)) return false;
|
||||
if (duration === 'long' && !(d >= 1200)) return false;
|
||||
}
|
||||
if (type !== 'any') {
|
||||
const live = this.isLiveItem(item);
|
||||
const short = this.isShortItem(item);
|
||||
const channel = this.isChannelItem(item);
|
||||
const clip = this.isClipItem(item);
|
||||
if (type === 'live' && !live) return false;
|
||||
if (type === 'channel' && !channel) return false;
|
||||
if (type === 'short' && !(short || clip) && !live && !channel) {
|
||||
// Les clips Twitch (kind='clip', parfois sans durée) comptent comme Shorts.
|
||||
if (!clip) return false;
|
||||
}
|
||||
if (type === 'short' && (live || channel)) return false;
|
||||
// 'video' / 'recorded' = VODs (ni live, ni short/clip, ni chaîne).
|
||||
if ((type === 'video' || type === 'recorded') && (live || short || clip || channel)) return false;
|
||||
}
|
||||
if (lang !== 'any') {
|
||||
const text = `${item?.title || ''} ${item?.channelName || ''}`.toLowerCase();
|
||||
if (lang === 'fr' && !isFrText(text)) return false;
|
||||
if (lang === 'en' && !(isEnText(text) && !isFrText(text))) return false;
|
||||
}
|
||||
if (cutoff > 0 && this.publishedTimestamp(item) < cutoff) return false;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
private applyFilterBar(list: any[]): any[] {
|
||||
const predicate = this.buildFilterBarPredicate();
|
||||
return Array.isArray(list) ? list.filter(predicate) : [];
|
||||
}
|
||||
|
||||
onDurationChange(event: Event) {
|
||||
this.durationFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
|
||||
}
|
||||
onTypeChange(event: Event) {
|
||||
this.typeFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
|
||||
}
|
||||
onLanguageChange(event: Event) {
|
||||
this.languageFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
|
||||
}
|
||||
onDateChange(event: Event) {
|
||||
this.dateFilter.set(((event.target as HTMLSelectElement)?.value as any) || 'any');
|
||||
}
|
||||
|
||||
private sortVideos(list: any[]): any[] {
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
}
|
||||
const mode = this.sortParam();
|
||||
if (mode === 'relevance') {
|
||||
return [...list];
|
||||
// Single merged list: round-robin interleave across providers (in the
|
||||
// user's provider order) instead of provider blocks. Prefix-stable
|
||||
// under infinite-scroll appends (per-provider order never changes).
|
||||
return this.interleaveByProvider(list);
|
||||
}
|
||||
|
||||
const arr = [...list];
|
||||
@@ -500,6 +667,79 @@ export class SearchComponent implements AfterViewInit {
|
||||
return arr;
|
||||
}
|
||||
|
||||
/** Normalize a provider token (short 'yt' or full 'youtube') to ProviderId. */
|
||||
private toProviderId(v: unknown): ProviderId | null {
|
||||
const s = String(v || '').trim();
|
||||
if (!s) return null;
|
||||
if ((PROVIDERS as { id: string }[]).some(p => p.id === s)) return s as ProviderId;
|
||||
const hit = Object.entries(this.providerIdMap).find(([, full]) => full === s);
|
||||
return hit ? (hit[0] as ProviderId) : null;
|
||||
}
|
||||
|
||||
/** Round-robin interleave preserving per-provider order and provider order. */
|
||||
private interleaveByProvider(list: any[]): any[] {
|
||||
const order = (this.activeProviders() || [])
|
||||
.map(p => this.toProviderId(p))
|
||||
.filter((p): p is ProviderId => !!p);
|
||||
const buckets = new Map<string, any[]>();
|
||||
const extras: any[] = [];
|
||||
for (const v of list) {
|
||||
const pid = this.toProviderId((v as any)?.provider);
|
||||
if (pid && order.includes(pid)) {
|
||||
const b = buckets.get(pid) || [];
|
||||
b.push(v);
|
||||
buckets.set(pid, b);
|
||||
} else {
|
||||
extras.push(v);
|
||||
}
|
||||
}
|
||||
const out: any[] = [];
|
||||
let idx = 0;
|
||||
let added = true;
|
||||
while (added) {
|
||||
added = false;
|
||||
for (const pid of order) {
|
||||
const b = buckets.get(pid);
|
||||
if (b && idx < b.length) {
|
||||
out.push(b[idx]);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
return [...out, ...extras];
|
||||
}
|
||||
|
||||
private providerLabelOf(pid: string): string {
|
||||
try {
|
||||
return PROVIDERS.find(p => p.id === pid)?.displayName || pid;
|
||||
} catch {
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
|
||||
/** Show backend per-provider failures as an amber notice (partial results). */
|
||||
private reflectProviderErrors(errors: unknown): void {
|
||||
if (!errors || typeof errors !== 'object') {
|
||||
this.notice.set(null);
|
||||
return;
|
||||
}
|
||||
const entries = Object.entries(errors as Record<string, unknown>)
|
||||
.filter(([, m]) => typeof m === 'string' && String(m).trim().length > 0);
|
||||
if (entries.length === 0) {
|
||||
this.notice.set(null);
|
||||
return;
|
||||
}
|
||||
const parts = entries.map(([pid, m]) => {
|
||||
const msg = String(m).replace(/\s+/g, ' ').trim();
|
||||
const short = msg.length > 140 ? msg.slice(0, 140) + '…' : msg;
|
||||
return `${this.providerLabelOf(pid)} : ${short}`;
|
||||
});
|
||||
this.notice.set(
|
||||
`Résultats partiels — certains fournisseurs n'ont pas répondu (${parts.join(' · ')}).`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
formatViews(views: number): string {
|
||||
if (views >= 1_000_000_000) return (views / 1_000_000_000).toFixed(1) + 'B';
|
||||
@@ -563,7 +803,6 @@ export class SearchComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
@ViewChild('searchInput', { static: false }) searchInput?: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('infiniteScrollAnchor', { static: false }) infiniteScrollAnchor?: ElementRef<HTMLDivElement>;
|
||||
|
||||
// Legacy input handlers removed; SearchBox handles keyboard and submit
|
||||
|
||||
@@ -585,21 +824,18 @@ export class SearchComponent implements AfterViewInit {
|
||||
this.filterTag.set(key);
|
||||
}
|
||||
|
||||
// Pagination controls
|
||||
private loadNextPage() {
|
||||
const current = this.pageParam() || 1;
|
||||
const next = current + 1;
|
||||
if (this.loading() || this.endReached()) return;
|
||||
const cleanParams = this.cleanUrlParams({
|
||||
...this.route.snapshot.queryParams,
|
||||
page: next
|
||||
});
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: cleanParams,
|
||||
queryParamsHandling: '',
|
||||
replaceUrl: true
|
||||
});
|
||||
// Infinite-scroll pagination: append the next page WITHOUT touching the
|
||||
// router URL. Navigating (even with replaceUrl) rewrites the location hash
|
||||
// (HashLocationStrategy) and remounts list state, which throws the user
|
||||
// back to the top. The URL keeps page=1 as the canonical shareable state.
|
||||
// Public: bound from the shared <app-infinite-anchor> sentinel.
|
||||
loadNextPage() {
|
||||
if (this.loading() || this.loadingMore() || this.endReached()) return;
|
||||
if (!this.q()) return;
|
||||
const next = (this.pageParam() || 1) + 1;
|
||||
this.pageParam.set(next);
|
||||
this.loadingMore.set(true);
|
||||
this.unified.setPage(next);
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtre rapide Vidéos / Shorts -->
|
||||
<div class="flex items-center gap-2 mb-4" role="tablist" aria-label="Filtrer vidéos ou shorts">
|
||||
<!-- Filtre rapide Vidéos / Shorts (masqué sur Twitch : onglets à sections ci-dessous) -->
|
||||
<div *ngIf="provider() !== 'twitch'" class="flex items-center gap-2 mb-4" role="tablist" aria-label="Filtrer vidéos ou shorts">
|
||||
<button (click)="setContentFilter('all')" [attr.aria-pressed]="contentFilter() === 'all'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors"
|
||||
[class]="contentFilter() === 'all' ? 'bg-red-600 border-red-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
@@ -43,6 +43,43 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Onglets Twitch façon directory : Directs / Vidéos / Clips / Chaînes -->
|
||||
<div *ngIf="isTwitchSections()" class="mb-4">
|
||||
<div *ngIf="twitchGame()" class="mb-3 flex items-center gap-3 rounded-xl border border-purple-500/30 bg-purple-950/30 px-4 py-2.5">
|
||||
<span class="text-2xl">🎮</span>
|
||||
<div>
|
||||
<div class="text-sm text-purple-300">Catégorie Twitch</div>
|
||||
<div class="font-semibold text-slate-100">{{ twitchGame()?.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-1" role="tablist" aria-label="Sections Twitch">
|
||||
<button (click)="setTwitchTab('all')" [attr.aria-pressed]="twitchTab() === 'all'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'all' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Tout
|
||||
</button>
|
||||
<button (click)="setTwitchTab('live')" [attr.aria-pressed]="twitchTab() === 'live'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'live' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
En direct<span *ngIf="twitchLives().length"> ({{ twitchLives().length }})</span>
|
||||
</button>
|
||||
<button (click)="setTwitchTab('video')" [attr.aria-pressed]="twitchTab() === 'video'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'video' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Vidéos<span *ngIf="twitchVods().length"> ({{ twitchVods().length }})</span>
|
||||
</button>
|
||||
<button (click)="setTwitchTab('clip')" [attr.aria-pressed]="twitchTab() === 'clip'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'clip' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Clips<span *ngIf="twitchClips().length"> ({{ twitchClips().length }})</span>
|
||||
</button>
|
||||
<button (click)="setTwitchTab('channel')" [attr.aria-pressed]="twitchTab() === 'channel'"
|
||||
class="px-3 py-1.5 rounded-full text-sm border transition-colors whitespace-nowrap"
|
||||
[class]="twitchTab() === 'channel' ? 'bg-purple-600 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'">
|
||||
Chaînes<span *ngIf="twitchChannels().length"> ({{ twitchChannels().length }})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filters -->
|
||||
<div class="bg-slate-800/50 p-4 rounded-xl border border-slate-700 mb-8">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
|
||||
@@ -66,29 +103,30 @@
|
||||
<div>
|
||||
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.type' | t }}</label>
|
||||
<select id="type-filter" [ngModel]="type()" (ngModelChange)="onTypeChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="any">Any</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="live">Live</option>
|
||||
<option value="recorded">Recorded</option>
|
||||
<option value="short">Shorts</option>
|
||||
<option value="any">{{ 'filter.type.any' | t }}</option>
|
||||
<option value="video">{{ 'filter.type.video' | t }}</option>
|
||||
<option value="live">{{ 'filter.type.live' | t }}</option>
|
||||
<option value="recorded">{{ 'filter.type.recorded' | t }}</option>
|
||||
<option value="short">{{ 'filter.type.short' | t }}</option>
|
||||
<option value="channel">{{ 'filter.type.channel' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t }}</label>
|
||||
<select id="language-filter" [ngModel]="language()" (ngModelChange)="onLanguageChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="any">Any</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="any">{{ 'filter.language.any' | t }}</option>
|
||||
<option value="en">{{ 'filter.language.en' | t }}</option>
|
||||
<option value="fr">{{ 'filter.language.fr' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="date-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.date' | t }}</label>
|
||||
<select id="date-filter" [ngModel]="date()" (ngModelChange)="onDateChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="any">Any</option>
|
||||
<option value="day">Last 24h</option>
|
||||
<option value="week">Last week</option>
|
||||
<option value="month">Last month</option>
|
||||
<option value="year">Last year</option>
|
||||
<option value="any">{{ 'filter.date.any' | t }}</option>
|
||||
<option value="day">{{ 'filter.date.day' | t }}</option>
|
||||
<option value="week">{{ 'filter.date.week' | t }}</option>
|
||||
<option value="month">{{ 'filter.date.month' | t }}</option>
|
||||
<option value="year">{{ 'filter.date.year' | t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,20 +143,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!loading() && videos().length === 0" class="text-center py-16">
|
||||
<div *ngIf="!loading() && !error() && videos().length === 0" class="text-center py-16">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-lg font-medium text-slate-300">{{ 'empty.noItems' | t }}</h3>
|
||||
<p class="mt-1 text-sm text-slate-400">Try adjusting your filters or changing the provider.</p>
|
||||
<p *ngIf="theme() === 'live'" class="mt-1 text-sm text-slate-400">Aucun direct pour le moment — réessayez plus tard.</p>
|
||||
<p *ngIf="theme() !== 'live'" class="mt-1 text-sm text-slate-400">Try adjusting your filters or changing the provider.</p>
|
||||
</div>
|
||||
|
||||
<div *ngIf="error()" class="mt-4 p-4 bg-red-900/30 border border-red-700 text-red-200 rounded-lg text-center">
|
||||
<p>{{ error() }}</p>
|
||||
<button class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md transition-colors" (click)="retry()">{{ 'action.retry' | t }}</button>
|
||||
<button *ngIf="error() !== 'Ce fournisseur ne propose pas de directs.'" class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md transition-colors" (click)="retry()">{{ 'action.retry' | t }}</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
|
||||
<!-- Grille unique (tous providers sauf vue "Tout" Twitch) -->
|
||||
<div *ngIf="!showTwitchSectionsAll()" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
|
||||
<div *ngFor="let v of videos(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
|
||||
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
|
||||
<div class="relative">
|
||||
@@ -127,13 +167,18 @@
|
||||
<app-provider-badge [provider]="provider()"></app-provider-badge>
|
||||
</div>
|
||||
<div class="absolute top-2 left-2 flex gap-1">
|
||||
<span *ngIf="kindOf(v) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
||||
<span *ngIf="kindOf(v) === 'live'" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||
<span *ngIf="isLiveCard(v)" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||
<span *ngIf="!isLiveCard(v) && isClipCard(v)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span>
|
||||
<span *ngIf="!isLiveCard(v) && kindOf(v) === 'short' && !isClipCard(v)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
||||
<span *ngIf="isChannelCard(v)" class="bg-slate-700/90 text-slate-200 text-[11px] font-bold px-1.5 py-0.5 rounded ring-1 ring-slate-500/50">CHAÎNE</span>
|
||||
</div>
|
||||
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">
|
||||
{{ v.duration | duration }}
|
||||
</div>
|
||||
<div class="absolute top-2 left-2">
|
||||
<div *ngIf="isLiveCard(v) && v.views > 0" class="absolute bottom-2 left-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md">
|
||||
{{ v.views | number }} spectateurs
|
||||
</div>
|
||||
<div *ngIf="!isLiveCard(v)" class="absolute bottom-2 left-2">
|
||||
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,22 +195,120 @@
|
||||
[name]="v.uploaderName || null"
|
||||
></app-channel-identity>
|
||||
<div class="mt-1.5 text-xs text-slate-400">
|
||||
<span>{{ v.views | number }} visionnements</span> • <span>{{ v.uploadedDate | date:'shortDate' }}</span>
|
||||
<span *ngIf="isChannelCard(v)">Chaîne Twitch</span>
|
||||
<span *ngIf="!isChannelCard(v)">{{ v.views | number }} visionnements</span>
|
||||
<span *ngIf="!isChannelCard(v) && v.uploadedDate"> • {{ v.uploadedDate | date:'shortDate' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="loading() && videos().length > 0" class="text-center py-8">
|
||||
<svg class="animate-spin h-8 w-8 text-slate-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<!-- Vue "Tout" Twitch : sections empilées façon twitch.tv/directory -->
|
||||
<div *ngIf="showTwitchSectionsAll()" class="space-y-10">
|
||||
<section *ngIf="twitchLives().length > 0">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||
<h3 class="text-xl font-semibold text-slate-100">En direct</h3>
|
||||
<span class="text-sm text-slate-400">({{ twitchLives().length }})</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
|
||||
<div *ngFor="let v of twitchLives(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
|
||||
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
|
||||
<div class="relative">
|
||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
|
||||
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
|
||||
<div class="absolute top-2 left-2"><span class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span></div>
|
||||
<div *ngIf="v.views > 0" class="absolute bottom-2 left-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md">{{ v.views | number }} spectateurs</div>
|
||||
</div>
|
||||
<div class="p-3 pb-1"><h3 class="font-semibold text-slate-100 group-hover:text-purple-300 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3></div>
|
||||
</a>
|
||||
<div class="px-3 pb-3">
|
||||
<app-channel-identity size="sm" [provider]="v.provider || provider()" [externalId]="v.channelExternalId ?? null" [avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null" [name]="v.uploaderName || null"></app-channel-identity>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section *ngIf="twitchVods().length > 0">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<h3 class="text-xl font-semibold text-slate-100">Vidéos</h3>
|
||||
<span class="text-sm text-slate-400">({{ twitchVods().length }})</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
|
||||
<div *ngFor="let v of twitchVods(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
|
||||
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
|
||||
<div class="relative">
|
||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
|
||||
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
|
||||
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">{{ v.duration | duration }}</div>
|
||||
<div class="absolute bottom-2 left-2"><app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button></div>
|
||||
</div>
|
||||
<div class="p-3 pb-1"><h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3></div>
|
||||
</a>
|
||||
<div class="px-3 pb-3">
|
||||
<app-channel-identity size="sm" [provider]="v.provider || provider()" [externalId]="v.channelExternalId ?? null" [avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null" [name]="v.uploaderName || null"></app-channel-identity>
|
||||
<div class="mt-1.5 text-xs text-slate-400"><span>{{ v.views | number }} visionnements</span><span *ngIf="v.uploadedDate"> • {{ v.uploadedDate | date:'shortDate' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section *ngIf="twitchClips().length > 0">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span>
|
||||
<h3 class="text-xl font-semibold text-slate-100">Clips</h3>
|
||||
<span class="text-sm text-slate-400">({{ twitchClips().length }})</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
|
||||
<div *ngFor="let v of twitchClips(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
|
||||
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
|
||||
<div class="relative">
|
||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
|
||||
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
|
||||
<div class="absolute top-2 left-2"><span class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span></div>
|
||||
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">{{ v.duration | duration }}</div>
|
||||
<div class="absolute bottom-2 left-2"><app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button></div>
|
||||
</div>
|
||||
<div class="p-3 pb-1"><h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3></div>
|
||||
</a>
|
||||
<div class="px-3 pb-3">
|
||||
<app-channel-identity size="sm" [provider]="v.provider || provider()" [externalId]="v.channelExternalId ?? null" [avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null" [name]="v.uploaderName || null"></app-channel-identity>
|
||||
<div class="mt-1.5 text-xs text-slate-400"><span>{{ v.views | number }} visionnements</span><span *ngIf="v.uploadedDate"> • {{ v.uploadedDate | date:'shortDate' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section *ngIf="twitchChannels().length > 0">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<h3 class="text-xl font-semibold text-slate-100">Chaînes</h3>
|
||||
<span class="text-sm text-slate-400">({{ twitchChannels().length }})</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
|
||||
<div *ngFor="let v of twitchChannels(); trackBy: trackByVideo" class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
|
||||
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block">
|
||||
<div class="relative flex items-center justify-center bg-slate-900 py-6">
|
||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="h-24 w-24 rounded-full object-cover ring-2 ring-purple-500/50">
|
||||
<div class="absolute top-2 right-2"><app-provider-badge [provider]="provider()"></app-provider-badge></div>
|
||||
<div class="absolute top-2 left-2"><span class="bg-slate-700/90 text-slate-200 text-[11px] font-bold px-1.5 py-0.5 rounded ring-1 ring-slate-500/50">CHAÎNE</span></div>
|
||||
</div>
|
||||
<div class="p-3 pb-1 text-center"><h3 class="font-semibold text-slate-100 group-hover:text-purple-300 transition-colors duration-200 text-sm line-clamp-2">{{ v.title }}</h3></div>
|
||||
</a>
|
||||
<div class="px-3 pb-3">
|
||||
<app-channel-identity size="sm" [provider]="v.provider || provider()" [externalId]="v.channelExternalId ?? null" [avatarUrl]="v.channelAvatarUrl || v.uploaderAvatar || null" [name]="v.uploaderName || null"></app-channel-identity>
|
||||
<div class="mt-1.5 text-xs text-slate-400 text-center">Chaîne Twitch</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div *ngIf="nextCursor()" class="text-center mt-8">
|
||||
<button (click)="loadMore()" [disabled]="loading()" class="px-6 py-3 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700 text-slate-200 font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all">
|
||||
{{ 'loading.more' | t }}
|
||||
</button>
|
||||
<!-- Standard infinite scroll: shared sentinel + bottom loader + end state -->
|
||||
<div class="flex flex-col items-center justify-center py-6">
|
||||
<div *ngIf="loadingMore()" class="text-center">
|
||||
<svg class="animate-spin h-8 w-8 text-slate-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div *ngIf="!loading() && !loadingMore() && !nextCursor() && !error() && videos().length > 0" class="text-slate-500 text-xs">Fin des résultats</div>
|
||||
</div>
|
||||
<app-infinite-anchor [disabled]="!nextCursor()" [busy]="loading() || loadingMore()" (loadMore)="loadMore()"></app-infinite-anchor>
|
||||
</div>
|
||||
@@ -11,8 +11,9 @@ import { LikeButtonComponent } from '../shared/components/like-button/like-butto
|
||||
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
||||
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
|
||||
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
||||
import { map, of, switchMap, catchError, Observable } from 'rxjs';
|
||||
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
|
||||
import { classifyVideo, isLiveVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
||||
import { map, of, switchMap, catchError, forkJoin, Observable } from 'rxjs';
|
||||
import { PagedResult } from '../../services/youtube-api.service';
|
||||
|
||||
const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
|
||||
@@ -47,7 +48,7 @@ const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
|
||||
standalone: true,
|
||||
templateUrl: './provider-theme-page.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe, ProviderBadgeComponent, ChannelIdentityComponent]
|
||||
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe, ProviderBadgeComponent, ChannelIdentityComponent, InfiniteAnchorComponent]
|
||||
})
|
||||
export class ProviderThemePageComponent implements OnDestroy {
|
||||
private route = inject(ActivatedRoute);
|
||||
@@ -66,20 +67,85 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
// Filters state
|
||||
sort = signal<'recent' | 'viewed' | 'longest'>('recent');
|
||||
duration = signal<'any' | 'short' | 'medium' | 'long'>('any');
|
||||
type = signal<'any' | 'video' | 'live' | 'recorded' | 'short'>('any');
|
||||
type = signal<'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'>('any');
|
||||
language = signal<'any' | 'en' | 'fr'>('any');
|
||||
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
|
||||
|
||||
// Data state
|
||||
allVideos = signal<Video[]>([]);
|
||||
nextCursor = signal<string | null>(null);
|
||||
// Curseurs par section Twitch (les paginations Helix sont indépendantes).
|
||||
private twitchCursors = { live: null as string | null, vod: null as string | null, clip: null as string | null, channel: null as string | null };
|
||||
/** Catégorie Twitch résolue (directory/category façon twitch.tv). */
|
||||
twitchGame = signal<{ id: string; name: string; boxArtUrl?: string } | null>(null);
|
||||
loading = signal(false);
|
||||
// Continuation (cursor > first page): grid stays mounted, bottom loader only.
|
||||
loadingMore = signal(false);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
// Derived state for the UI
|
||||
providersList = computed(() => this.instances.providers());
|
||||
videos = computed(() => this.applyFilters(this.allVideos()));
|
||||
|
||||
/** La page courante est-elle une page Twitch à sections (hors thème live) ? */
|
||||
isTwitchSections = computed(() => this.provider() === 'twitch' && this.theme() !== 'live');
|
||||
|
||||
/** Listes Twitch par section (filtres hors-type appliqués), façon directory. */
|
||||
private twitchFilteredNoType(): Video[] {
|
||||
return this.applyFiltersWithType(this.allVideos(), 'any');
|
||||
}
|
||||
|
||||
twitchLives = computed(() => {
|
||||
const p = this.provider();
|
||||
return this.twitchFilteredNoType().filter(v =>
|
||||
isLiveVideo({ ...v, provider: (v.provider as string) || p }));
|
||||
});
|
||||
twitchVods = computed(() => {
|
||||
const p = this.provider();
|
||||
return this.twitchFilteredNoType().filter(v => {
|
||||
const w = { ...v, provider: (v.provider as string) || p };
|
||||
if (isLiveVideo(w)) return false;
|
||||
if (isShortVideo(w)) return false;
|
||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
twitchClips = computed(() => {
|
||||
const p = this.provider();
|
||||
return this.twitchFilteredNoType().filter(v => {
|
||||
const w = { ...v, provider: (v.provider as string) || p };
|
||||
if (isLiveVideo(w)) return false;
|
||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
||||
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
|
||||
return isShortVideo(w);
|
||||
});
|
||||
});
|
||||
twitchChannels = computed(() => {
|
||||
const p = this.provider();
|
||||
return this.twitchFilteredNoType().filter(v => {
|
||||
if ((v as any).isLive === true) return false;
|
||||
if (isLiveVideo({ ...v, provider: (v.provider as string) || p })) return false;
|
||||
return String(v.type || '').toLowerCase() === 'channel';
|
||||
});
|
||||
});
|
||||
|
||||
/** Onglet Twitch synchronisé avec le filtre "type" existant. */
|
||||
twitchTab = computed<'all' | 'live' | 'video' | 'clip' | 'channel'>(() => {
|
||||
const t = this.type();
|
||||
if (t === 'live') return 'live';
|
||||
if (t === 'video' || t === 'recorded') return 'video';
|
||||
if (t === 'short') return 'clip';
|
||||
if (t === 'channel') return 'channel';
|
||||
return 'all';
|
||||
});
|
||||
|
||||
setTwitchTab(v: 'all' | 'live' | 'video' | 'clip' | 'channel'): void {
|
||||
if (v === 'all') this.type.set('any');
|
||||
else if (v === 'video') this.type.set('video');
|
||||
else if (v === 'clip') this.type.set('short');
|
||||
else this.type.set(v);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.route.paramMap.subscribe(pm => {
|
||||
const provider = (pm.get('provider') as Provider) || 'youtube';
|
||||
@@ -87,6 +153,16 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
this.provider.set(provider);
|
||||
this.theme.set(theme);
|
||||
this.instances.setSelectedProvider(provider);
|
||||
// Thème "En direct" : activer le filtre "Live" par défaut et lever les
|
||||
// filtres qui masqueraient les lives (durée/date = any).
|
||||
if (theme === 'live') {
|
||||
this.type.set('live');
|
||||
this.duration.set('any');
|
||||
this.date.set('any');
|
||||
} else if (this.type() === 'live') {
|
||||
// En quittant le thème live, ne pas rester bloqué sur le filtre Live.
|
||||
this.type.set('any');
|
||||
}
|
||||
this.resetAndLoad();
|
||||
});
|
||||
|
||||
@@ -105,7 +181,10 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
this.version++;
|
||||
this.allVideos.set([]);
|
||||
this.nextCursor.set(null);
|
||||
this.twitchCursors = { live: null, vod: null, clip: null, channel: null };
|
||||
this.twitchGame.set(null);
|
||||
this.error.set(null);
|
||||
this.loadingMore.set(false);
|
||||
this.loadMore();
|
||||
}
|
||||
|
||||
@@ -127,7 +206,7 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
loadMore() {
|
||||
if (this.loading()) return;
|
||||
if (this.loading() || this.loadingMore()) return;
|
||||
|
||||
const readiness = this.instances.getProviderReadiness(this.provider());
|
||||
if (!readiness.ready) {
|
||||
@@ -135,7 +214,10 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
// First page (empty list) -> full loader; continuations -> bottom loader,
|
||||
// grid stays mounted so scroll position is preserved.
|
||||
if (this.allVideos().length > 0) this.loadingMore.set(true);
|
||||
else this.loading.set(true);
|
||||
this.error.set(null);
|
||||
const snapshotVersion = this.version;
|
||||
const provider = this.provider();
|
||||
@@ -144,36 +226,28 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
const themeSlug = this.theme();
|
||||
const isLiveTheme = themeSlug === 'live';
|
||||
|
||||
// Fournisseurs sans aucun direct : aucune vidéo, message explicite.
|
||||
const LIVE_PROVIDERS: Provider[] = ['youtube', 'twitch'];
|
||||
if (isLiveTheme && !LIVE_PROVIDERS.includes(provider)) {
|
||||
this.error.set('Ce fournisseur ne propose pas de directs.');
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.cdr.markForCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
let apiCall: Observable<PagedResult<Video>>;
|
||||
if (provider === 'twitch') {
|
||||
if (isLiveTheme) {
|
||||
// Thème live : top streams (jamais vide quand Twitch est configuré).
|
||||
apiCall = this.api.getTwitchTopStreamsPage(cursor || undefined);
|
||||
} else {
|
||||
// Thème classique : VODs puis clips, puis top streams en filet de sécurité.
|
||||
apiCall = this.api.searchTwitchVodsPage(query, cursor || undefined).pipe(
|
||||
switchMap((res) => {
|
||||
if ((res?.items?.length || 0) >= 8) {
|
||||
return of(res);
|
||||
}
|
||||
return this.api.searchTwitchClipsPage(query, cursor || undefined).pipe(
|
||||
map((clips) => ({ ...clips, items: [...(res?.items || []), ...(clips.items || [])] })),
|
||||
switchMap((merged: any) => {
|
||||
if (((merged?.items?.length) || 0) >= 4) return of(merged);
|
||||
return this.api.getTwitchTopStreamsPage(cursor || undefined).pipe(
|
||||
map((top) => {
|
||||
const seen = new Set((merged?.items || []).map((v: Video) => v.videoId));
|
||||
const extra = (top?.items || []).filter((v: Video) => !seen.has(v.videoId));
|
||||
return { items: [...(merged?.items || []), ...extra], nextCursor: top?.nextCursor || merged?.nextCursor || null };
|
||||
}),
|
||||
// En cas d'échec du fallback, garder au moins VODs+clips.
|
||||
catchError(() => of(merged))
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
if (isLiveTheme && provider === 'youtube') {
|
||||
// Directs YouTube : search.list eventType=live (vrais lives uniquement).
|
||||
apiCall = this.api.searchYouTubeLivePage('', cursor || undefined);
|
||||
} else if (provider === 'twitch' && !isLiveTheme) {
|
||||
// Page Twitch à sections façon directory : lives / vidéos / clips / chaînes
|
||||
// chargés en parallèle avec leurs propres curseurs Helix.
|
||||
this.loadTwitchSections(snapshotVersion, provider, query);
|
||||
return;
|
||||
} else if (provider === 'twitch') {
|
||||
// Thème live : top streams (jamais vide quand Twitch est configuré).
|
||||
apiCall = this.api.getTwitchTopStreamsPage(cursor || undefined);
|
||||
} else {
|
||||
apiCall = query
|
||||
? this.api.searchVideosPage(query, cursor, provider)
|
||||
@@ -184,27 +258,127 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
next: (res: PagedResult<Video>) => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
|
||||
// Thème live : garde-fou, ne jamais mélanger des VODs.
|
||||
let items = res.items || [];
|
||||
if (isLiveTheme) {
|
||||
items = items.filter((v: Video) =>
|
||||
isLiveVideo({ ...v, provider: (v.provider as string) || provider })
|
||||
);
|
||||
}
|
||||
const existingIds = new Set(this.allVideos().map((v: Video) => v.videoId));
|
||||
const newVideos = (res.items || []).filter((v: Video) => !existingIds.has(v.videoId));
|
||||
const newVideos = items.filter((v: Video) => !existingIds.has(v.videoId));
|
||||
|
||||
this.allVideos.update(current => [...current, ...newVideos]);
|
||||
this.nextCursor.set(res.nextCursor || null);
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: (e) => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
this.error.set(e.message || 'Failed to load videos');
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private applyFilters(list: Video[]): Video[] {
|
||||
return this.applyFiltersWithType(list, this.type());
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement Twitch à sections (directory/category) : lives (+ catégorie),
|
||||
* VODs, clips et chaînes en parallèle, chacun avec son curseur Helix.
|
||||
* En continuation on ne recharge que l'onglet actif (ou tout si "Tout").
|
||||
*/
|
||||
private loadTwitchSections(snapshotVersion: number, provider: Provider, query: string): void {
|
||||
const firstPage = this.allVideos().length === 0;
|
||||
const tab = this.twitchTab();
|
||||
const wantLive = firstPage || tab === 'all' || tab === 'live';
|
||||
const wantVod = firstPage || tab === 'all' || tab === 'video';
|
||||
const wantClip = firstPage || tab === 'all' || tab === 'clip';
|
||||
const wantChannel = firstPage || tab === 'all' || tab === 'channel';
|
||||
|
||||
const finish = (items: Video[], next: string | null, err?: string) => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
if (err) this.error.set(err);
|
||||
const existing = new Set(this.allVideos().map(v => `${v.type}:${v.videoId}`));
|
||||
const fresh = items.filter(v => !existing.has(`${v.type}:${v.videoId}`));
|
||||
if (fresh.length) this.allVideos.update(cur => [...cur, ...fresh]);
|
||||
this.nextCursor.set(next);
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.cdr.markForCheck();
|
||||
};
|
||||
|
||||
const fetchAll = (gameId: string | null) => {
|
||||
const calls: Record<string, Observable<PagedResult<Video>>> = {};
|
||||
if (wantLive) {
|
||||
calls['live'] = gameId
|
||||
? this.api.getTwitchGameStreamsPage(gameId, this.twitchCursors.live || undefined, 12).pipe(
|
||||
catchError(() => this.api.getTwitchTopStreamsPage(this.twitchCursors.live || undefined)))
|
||||
: this.api.getTwitchTopStreamsPage(this.twitchCursors.live || undefined);
|
||||
}
|
||||
if (wantVod) calls['vod'] = this.api.searchTwitchVodsPage(query, this.twitchCursors.vod || undefined);
|
||||
if (wantClip) calls['clip'] = this.api.searchTwitchClipsPage(query, this.twitchCursors.clip || undefined);
|
||||
if (wantChannel) calls['channel'] = this.api.searchTwitchAllChannelsPage(query, this.twitchCursors.channel || undefined, 20);
|
||||
if (Object.keys(calls).length === 0) {
|
||||
this.loading.set(false);
|
||||
this.loadingMore.set(false);
|
||||
this.cdr.markForCheck();
|
||||
return;
|
||||
}
|
||||
forkJoin(calls).subscribe({
|
||||
next: (res) => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
const r = res as Record<string, PagedResult<Video>>;
|
||||
if (r['live']) this.twitchCursors.live = r['live'].nextCursor || null;
|
||||
if (r['vod']) this.twitchCursors.vod = r['vod'].nextCursor || null;
|
||||
if (r['clip']) this.twitchCursors.clip = r['clip'].nextCursor || null;
|
||||
if (r['channel']) this.twitchCursors.channel = r['channel'].nextCursor || null;
|
||||
const items = Object.values(r).flatMap(x => x?.items || []);
|
||||
// Filet de sécurité : si VODs+clips vides et aucun live, compléter par top streams.
|
||||
const hasContent = items.length > 0;
|
||||
if (!hasContent && firstPage) {
|
||||
this.api.getTwitchTopStreamsPage(null).subscribe({
|
||||
next: (top) => finish([...items, ...(top.items || [])], top.nextCursor || null),
|
||||
error: () => finish(items, null),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const next = this.twitchCursors.live || this.twitchCursors.vod || this.twitchCursors.clip || this.twitchCursors.channel;
|
||||
finish(items, next);
|
||||
},
|
||||
error: (e) => finish([], null, e?.message || 'Failed to load videos'),
|
||||
});
|
||||
};
|
||||
|
||||
if (firstPage) {
|
||||
// Première page : résoudre la catégorie Twitch pour des lives thématiques.
|
||||
this.api.searchTwitchCategory(query).subscribe({
|
||||
next: (game) => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
if (game) this.twitchGame.set(game);
|
||||
fetchAll(game?.id || null);
|
||||
},
|
||||
error: () => {
|
||||
if (snapshotVersion !== this.version) return;
|
||||
fetchAll(null);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
fetchAll(this.twitchGame()?.id || null);
|
||||
}
|
||||
}
|
||||
|
||||
private applyFiltersWithType(list: Video[], typeOverride: 'any' | 'video' | 'live' | 'recorded' | 'short' | 'channel'): Video[] {
|
||||
let arr = [...list];
|
||||
const isLiveTheme = this.theme() === 'live';
|
||||
const durationFilter = this.duration();
|
||||
if (durationFilter !== 'any') {
|
||||
// Les lives n'ont pas de durée connue (0) : le filtre durée ne s'applique pas sur le thème live.
|
||||
if (durationFilter !== 'any' && !isLiveTheme) {
|
||||
arr = arr.filter(v => {
|
||||
const dur = v.duration || 0;
|
||||
if (durationFilter === 'short') return dur > 0 && dur < 240; // < 4 min
|
||||
@@ -214,18 +388,33 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
const typeFilter = this.type();
|
||||
const typeFilter = typeOverride;
|
||||
if (typeFilter !== 'any') {
|
||||
const p = this.provider();
|
||||
arr = arr.filter(v => {
|
||||
const withProvider = { ...v, provider: (v.provider as string) || p };
|
||||
if (typeFilter === 'video') return !isShortVideo(withProvider) && (v.type === 'video' || v.type === 'recorded');
|
||||
if (typeFilter === 'live') return v.type === 'live' || v.type === 'channel' || v.type === 'stream' || (v as any).isLive === true;
|
||||
if (typeFilter === 'recorded') return v.type === 'recorded' || (v.type === 'video' && !isShortVideo(withProvider));
|
||||
// Shorts : classification partagée (durée 0/absente ⇒ jamais un Short).
|
||||
if (typeFilter === 'short') return isShortVideo(withProvider);
|
||||
if (typeFilter === 'video') return !isShortVideo(withProvider) && !isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel' && (v.type === 'video' || v.type === 'recorded');
|
||||
if (typeFilter === 'live') return isLiveVideo(withProvider);
|
||||
if (typeFilter === 'recorded') return !isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel' && (v.type === 'recorded' || (v.type === 'video' && !isShortVideo(withProvider)));
|
||||
// Shorts / Clips : classification partagée + kind='clip' (durée parfois absente).
|
||||
if (typeFilter === 'short') {
|
||||
if (isLiveVideo(withProvider)) return false;
|
||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
||||
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
|
||||
return isShortVideo(withProvider);
|
||||
}
|
||||
// Chaînes : hors-ligne uniquement (les lives restent dans "Direct").
|
||||
if (typeFilter === 'channel') {
|
||||
if ((v as any).isLive === true) return false;
|
||||
if (isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel') return false;
|
||||
return String(v.type || '').toLowerCase() === 'channel';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
} else if (this.theme() === 'live') {
|
||||
// Thème live même si l'utilisateur remet le filtre sur "Any" : LIVE seulement.
|
||||
const p = this.provider();
|
||||
arr = arr.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || p }));
|
||||
}
|
||||
|
||||
const langFilter = this.language();
|
||||
@@ -289,6 +478,31 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
return classifyVideo({ ...v, provider: (v.provider as string) || p });
|
||||
}
|
||||
|
||||
/** Badges Twitch : LIVE seulement si réellement en direct (pas les chaînes hors-ligne). */
|
||||
isLiveCard(v: Video): boolean {
|
||||
if ((v as any).isLive === true) return true;
|
||||
if (String(v.type || '').toLowerCase() === 'live') return true;
|
||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
||||
const p = this.provider();
|
||||
return isLiveVideo({ ...v, provider: (v.provider as string) || p })
|
||||
&& String(v.type || '').toLowerCase() !== 'channel';
|
||||
}
|
||||
|
||||
isChannelCard(v: Video): boolean {
|
||||
if ((v as any).isLive === true) return false;
|
||||
return String(v.type || '').toLowerCase() === 'channel';
|
||||
}
|
||||
|
||||
isClipCard(v: Video): boolean {
|
||||
if (this.isLiveCard(v) || this.isChannelCard(v)) return false;
|
||||
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
|
||||
const p = this.provider();
|
||||
return isShortVideo({ ...v, provider: (v.provider as string) || p });
|
||||
}
|
||||
|
||||
/** Affichage "Tout" Twitch : sections empilées façon directory. */
|
||||
showTwitchSectionsAll = computed(() => this.isTwitchSections() && this.twitchTab() === 'all');
|
||||
|
||||
shortsCount(): number {
|
||||
const p = this.provider();
|
||||
return this.allVideos().filter(v => isShortVideo({ ...v, provider: (v.provider as string) || p })).length;
|
||||
@@ -309,11 +523,21 @@ export class ProviderThemePageComponent implements OnDestroy {
|
||||
if (slug.startsWith('/')) slug = slug.slice(1);
|
||||
qp.slug = slug;
|
||||
}
|
||||
if (p === 'twitch' && v.type === 'channel') qp.channel = v.videoId;
|
||||
if (p === 'twitch') {
|
||||
// Clips : embed dédié ?clip=<slug> (jamais ?channel=, le slug n'est pas un login).
|
||||
if (String((v as any).kind || '').toLowerCase() === 'clip') {
|
||||
qp.clip = v.videoId;
|
||||
return qp;
|
||||
}
|
||||
if (v.type === 'channel' || v.type === 'live' || (v as any).isLive === true) {
|
||||
// ?channel= exige le login (non numérique) ; un id numérique = VOD.
|
||||
if (v.videoId && !/^\d+$/.test(String(v.videoId))) qp.channel = v.videoId;
|
||||
}
|
||||
}
|
||||
return qp;
|
||||
}
|
||||
|
||||
trackByVideo(_idx: number, v: Video) { return v.videoId || v.url; }
|
||||
trackByVideo(_idx: number, v: Video) { return `${v.type}:${v.videoId}` || v.url; }
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.version++; // Invalidate any pending requests
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
|
||||
<!-- Error / empty -->
|
||||
<div *ngIf="!b.loading && b.error" class="text-amber-300">{{ b.error }}</div>
|
||||
<div *ngIf="!b.loading && !b.error && b.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
||||
<div *ngIf="!b.loading && !b.error && b.items.length === 0 && themeSlug() === 'live' && !isLiveProvider(b.provider)" class="text-slate-400">Ce fournisseur ne propose pas de directs.</div>
|
||||
<div *ngIf="!b.loading && !b.error && b.items.length === 0 && !(themeSlug() === 'live' && !isLiveProvider(b.provider))" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
||||
<div *ngIf="!b.loading && !b.error && b.items.length > 0 && filteredItems(b).length === 0" class="text-slate-400">
|
||||
Aucun {{ contentFilter() === 'shorts' ? 'Short' : 'vidéo' }} pour ce fournisseur — essayez l'autre filtre.
|
||||
</div>
|
||||
@@ -55,13 +56,18 @@
|
||||
<app-provider-badge [provider]="b.provider"></app-provider-badge>
|
||||
</div>
|
||||
<div class="absolute top-2 left-2 flex gap-1">
|
||||
<span *ngIf="kindOf(v, b.provider) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
||||
<span *ngIf="kindOf(v, b.provider) === 'live'" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||
<span *ngIf="isLiveCard(v, b.provider)" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||
<span *ngIf="!isLiveCard(v, b.provider) && isClipCard(v, b.provider)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span>
|
||||
<span *ngIf="!isLiveCard(v, b.provider) && !isClipCard(v, b.provider) && kindOf(v, b.provider) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
||||
<span *ngIf="isChannelCard(v)" class="bg-slate-700/90 text-slate-200 text-[11px] font-bold px-1.5 py-0.5 rounded ring-1 ring-slate-500/50">CHAÎNE</span>
|
||||
</div>
|
||||
<div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded" *ngIf="v.duration && v.duration > 0">
|
||||
{{ v.duration | duration }}
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2">
|
||||
<div *ngIf="isLiveCard(v, b.provider) && v.views > 0" class="absolute bottom-2 left-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded">
|
||||
{{ v.views | number }} spectateurs
|
||||
</div>
|
||||
<div class="absolute bottom-2 left-2" *ngIf="!isLiveCard(v, b.provider)">
|
||||
<app-like-button [videoId]="v.videoId" [provider]="b.provider" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LikeButtonComponent } from '../shared/components/like-button/like-butto
|
||||
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
||||
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
|
||||
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||
import { classifyVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
||||
import { classifyVideo, isLiveVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
||||
|
||||
interface ProviderBlock {
|
||||
provider: Provider;
|
||||
@@ -48,11 +48,27 @@ export class ThemePageComponent {
|
||||
this.contentFilter.set(v);
|
||||
}
|
||||
|
||||
/** Items du bloc après application du filtre Vidéos/Shorts. */
|
||||
/** Fournisseurs capables de fournir de vrais directs. */
|
||||
private static readonly LIVE_PROVIDERS: Provider[] = ['youtube', 'twitch'];
|
||||
|
||||
isLiveTheme(): boolean {
|
||||
return this.themeSlug() === 'live';
|
||||
}
|
||||
|
||||
isLiveProvider(p: Provider): boolean {
|
||||
return (ThemePageComponent.LIVE_PROVIDERS as Provider[]).includes(p);
|
||||
}
|
||||
|
||||
/** Items du bloc après application du filtre Vidéos/Shorts (+ LIVE seulement sur thème live). */
|
||||
filteredItems(b: ProviderBlock): Video[] {
|
||||
let items = b.items;
|
||||
// Thème "En direct" : ne jamais mélanger avec des VODs (garde-fou si une API renvoie du non-live).
|
||||
if (this.themeSlug() === 'live') {
|
||||
items = items.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || b.provider }));
|
||||
}
|
||||
const f = this.contentFilter();
|
||||
if (f === 'all') return b.items;
|
||||
return b.items.filter(v =>
|
||||
if (f === 'all') return items;
|
||||
return items.filter(v =>
|
||||
f === 'shorts' ? isShortVideo({ ...v, provider: (v.provider as string) || b.provider }) : !isShortVideo({ ...v, provider: (v.provider as string) || b.provider })
|
||||
);
|
||||
}
|
||||
@@ -61,6 +77,26 @@ export class ThemePageComponent {
|
||||
return classifyVideo({ ...v, provider: (v.provider as string) || provider });
|
||||
}
|
||||
|
||||
/** LIVE seulement si réellement en direct (les chaînes hors-ligne ne le sont pas). */
|
||||
isLiveCard(v: Video, provider: Provider): boolean {
|
||||
if ((v as any).isLive === true) return true;
|
||||
if (String(v.type || '').toLowerCase() === 'live') return true;
|
||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
||||
return isLiveVideo({ ...v, provider: (v.provider as string) || provider })
|
||||
&& String(v.type || '').toLowerCase() !== 'channel';
|
||||
}
|
||||
|
||||
isChannelCard(v: Video): boolean {
|
||||
if ((v as any).isLive === true) return false;
|
||||
return String(v.type || '').toLowerCase() === 'channel';
|
||||
}
|
||||
|
||||
isClipCard(v: Video, provider: Provider): boolean {
|
||||
if (this.isLiveCard(v, provider) || this.isChannelCard(v)) return false;
|
||||
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
|
||||
return isShortVideo({ ...v, provider: (v.provider as string) || provider });
|
||||
}
|
||||
|
||||
shortsCount(b: ProviderBlock): number {
|
||||
return b.items.filter(v => isShortVideo({ ...v, provider: (v.provider as string) || b.provider })).length;
|
||||
}
|
||||
@@ -100,6 +136,14 @@ export class ThemePageComponent {
|
||||
}
|
||||
|
||||
private fetchForProvider(block: ProviderBlock, slug: string) {
|
||||
// Thème "En direct" : les fournisseurs sans live n'affichent aucune vidéo.
|
||||
if (slug === 'live' && !this.isLiveProvider(block.provider)) {
|
||||
block.loading = false;
|
||||
block.error = null;
|
||||
block.items = [];
|
||||
this.blocks.update(a => [...a]);
|
||||
return;
|
||||
}
|
||||
// Provider readiness (e.g., YouTube key, Twitch creds). If not ready, surface reason and skip network calls.
|
||||
const ready = this.instances.getProviderReadiness(block.provider);
|
||||
if (!ready.ready) {
|
||||
@@ -141,17 +185,32 @@ export class ThemePageComponent {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Twitch / live : les top streams live garantissent un bloc rempli
|
||||
// (la recherche de chaînes "live" est trop restrictive).
|
||||
if (block.provider === 'twitch' && slug === 'live') {
|
||||
this.api.getTwitchTopStreamsPage(null).subscribe({
|
||||
// Thème "En direct" : vrais lives uniquement.
|
||||
// - YouTube : search.list eventType=live (pas une recherche textuelle "live").
|
||||
// - Twitch : top streams Helix (la recherche de chaînes "live" est trop restrictive).
|
||||
if (slug === 'live') {
|
||||
const liveCall = block.provider === 'youtube'
|
||||
? this.api.searchYouTubeLivePage('', null)
|
||||
: this.api.getTwitchTopStreamsPage(null);
|
||||
const mode = block.provider === 'youtube' ? 'youtube_eventType_live' : 'twitch_top_streams';
|
||||
liveCall.subscribe({
|
||||
next: res => {
|
||||
block.items = (res.items || []).slice(0, max);
|
||||
block.items = (res.items || [])
|
||||
.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || block.provider }))
|
||||
.slice(0, max);
|
||||
block.loading = false;
|
||||
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode: 'twitch_top_streams' });
|
||||
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode });
|
||||
this.blocks.update(a => [...a]);
|
||||
},
|
||||
error: () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); }
|
||||
error: (e) => {
|
||||
if (block.provider === 'youtube' && e?.status === 403) {
|
||||
this.api.quotaExceeded$.next(true);
|
||||
block.error = 'YouTube quota exceeded. Try again later or switch provider.';
|
||||
} else {
|
||||
block.error = 'Erreur de chargement';
|
||||
}
|
||||
block.loading = false; this.blocks.update(a => [...a]);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -220,9 +279,15 @@ export class ThemePageComponent {
|
||||
qp.slug = slug;
|
||||
} catch {}
|
||||
}
|
||||
if (provider === 'twitch' && v.type === 'channel') {
|
||||
// Try to use channel name
|
||||
if (v.videoId) qp.channel = v.videoId;
|
||||
if (provider === 'twitch') {
|
||||
if (String((v as any).kind || '').toLowerCase() === 'clip') {
|
||||
qp.clip = v.videoId;
|
||||
return qp;
|
||||
}
|
||||
if (v.type === 'channel' || v.type === 'live' || (v as any).isLive === true) {
|
||||
// ?channel= exige le login ; un id VOD numérique ne doit jamais y passer.
|
||||
if (v.videoId && !/^\d+$/.test(String(v.videoId))) qp.channel = v.videoId;
|
||||
}
|
||||
}
|
||||
return qp;
|
||||
}
|
||||
|
||||
@@ -992,6 +992,32 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
// quelle langue" : on ne réessaie jamais deux fois la même).
|
||||
private transcriptTriedLangs = new Set<string>();
|
||||
|
||||
/** Langues de transcripts autorisées par les préférences (défaut fr,en). */
|
||||
private allowedTranscriptLanguages(): string[] {
|
||||
try {
|
||||
const prefs = this.users.preferences();
|
||||
const raw = (prefs as { downloadLanguages?: unknown } | null)?.downloadLanguages;
|
||||
if (Array.isArray(raw) && raw.length) {
|
||||
const cleaned = raw
|
||||
.map(v => String(v || '').trim().toLowerCase().split('-')[0])
|
||||
.filter(v => /^[a-z]{2,3}$/.test(v));
|
||||
const deduped = Array.from(new Set(cleaned));
|
||||
if (deduped.length) return deduped;
|
||||
}
|
||||
if (Array.isArray(raw) && raw.length === 0) return [];
|
||||
} catch {}
|
||||
return ['fr', 'en'];
|
||||
}
|
||||
|
||||
/** Ne jamais afficher une langue non activée dans les préférences. */
|
||||
private filterTranscriptLanguages(languages: unknown): string[] {
|
||||
const allowed = new Set(this.allowedTranscriptLanguages().map(l => l.toLowerCase()));
|
||||
if (!Array.isArray(languages)) return [];
|
||||
return (languages as unknown[])
|
||||
.map(l => String(l || '').trim().slice(0, 12))
|
||||
.filter(l => !!l && allowed.has(l.toLowerCase().split('-')[0]));
|
||||
}
|
||||
|
||||
// --- Transcript logic (Phase 2) ---
|
||||
private resetTranscriptUi() {
|
||||
this.transcriptOpen.set(false);
|
||||
@@ -1013,8 +1039,13 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
this.stopTranscriptClock();
|
||||
this.transcriptTriedLangs.clear();
|
||||
try {
|
||||
const allowed = this.allowedTranscriptLanguages();
|
||||
const prefLang = String(this.users.preferences()?.language || '').trim().toLowerCase().slice(0, 12);
|
||||
this.selectedTranscriptLang.set(prefLang || 'fr');
|
||||
const base = prefLang.split('-')[0];
|
||||
const initial = allowed.includes(prefLang) ? prefLang
|
||||
: allowed.includes(base) ? base
|
||||
: (allowed[0] || 'fr');
|
||||
this.selectedTranscriptLang.set(initial);
|
||||
} catch {
|
||||
this.selectedTranscriptLang.set('fr');
|
||||
}
|
||||
@@ -1039,7 +1070,11 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
}
|
||||
|
||||
onTranscriptLangChange(value: string) {
|
||||
const lang = String(value || '').trim().slice(0, 12) || 'fr';
|
||||
const allowed = this.allowedTranscriptLanguages().map(l => l.toLowerCase());
|
||||
let lang = String(value || '').trim().slice(0, 12) || 'fr';
|
||||
if (!allowed.includes(lang.toLowerCase().split('-')[0])) {
|
||||
lang = this.allowedTranscriptLanguages()[0] || 'fr';
|
||||
}
|
||||
this.selectedTranscriptLang.set(lang);
|
||||
this.loadTranscript();
|
||||
}
|
||||
@@ -1321,15 +1356,18 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-fallback "n'importe quelle langue" : si la langue demandée échoue
|
||||
* mais que le backend signale d'autres pistes, on relance automatiquement
|
||||
* avec la première non encore tentée. Retourne true si un retry est parti.
|
||||
* Auto-fallback restreint aux langues activées : si la langue demandée échoue
|
||||
* mais que le backend signale d'autres pistes autorisées, on relance
|
||||
* automatiquement avec la première non encore tentée. Retourne true si un retry est parti.
|
||||
*/
|
||||
private autoFallbackTranscriptLang(languages: unknown): boolean {
|
||||
if (!Array.isArray(languages)) return false;
|
||||
const allowed = new Set(this.allowedTranscriptLanguages().map(l => l.toLowerCase()));
|
||||
const next = (languages as unknown[])
|
||||
.map((l) => String(l || '').trim().slice(0, 12))
|
||||
.find((l) => !!l && !this.transcriptTriedLangs.has(l.toLowerCase()));
|
||||
.find((l) => !!l
|
||||
&& allowed.has(l.toLowerCase().split('-')[0])
|
||||
&& !this.transcriptTriedLangs.has(l.toLowerCase()));
|
||||
if (!next) return false;
|
||||
this.selectedTranscriptLang.set(next);
|
||||
// loadTranscript() ré-enregistre dans triedLangs et relance la requête.
|
||||
@@ -1348,14 +1386,27 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
}
|
||||
// Never break the Watch page: every failure degrades to the "unavailable" notice.
|
||||
try {
|
||||
const allowed = this.allowedTranscriptLanguages();
|
||||
if (!allowed.length) {
|
||||
this.transcriptLoading.set(false);
|
||||
this.transcriptAvailable.set(false);
|
||||
this.transcriptLanguages.set([]);
|
||||
this.transcriptLines.set([]);
|
||||
this.transcriptReason.set('no_subtitles');
|
||||
return;
|
||||
}
|
||||
let requestedLang = this.selectedTranscriptLang();
|
||||
if (!allowed.map(l => l.toLowerCase()).includes(String(requestedLang || '').toLowerCase().split('-')[0])) {
|
||||
requestedLang = allowed[0] || 'fr';
|
||||
this.selectedTranscriptLang.set(requestedLang);
|
||||
}
|
||||
this.transcriptLoading.set(true);
|
||||
this.transcriptError.set(null);
|
||||
this.transcriptReason.set(null);
|
||||
this.transcriptRetryable.set(false);
|
||||
const requestedLang = this.selectedTranscriptLang();
|
||||
this.transcriptTriedLangs.add(String(requestedLang || '').toLowerCase());
|
||||
const opts = this.buildProviderOpts() as Record<string, string>;
|
||||
const params = new URLSearchParams({ lang: requestedLang });
|
||||
const params = new URLSearchParams({ lang: requestedLang, langs: allowed.join(',') });
|
||||
if (opts['instance']) params.set('instance', opts['instance']);
|
||||
if (opts['slug']) params.set('slug', opts['slug']);
|
||||
const url = `${this.apiBase()}/transcript/${encodeURIComponent(p)}/${encodeURIComponent(id)}?${params.toString()}`;
|
||||
@@ -1363,7 +1414,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
next: (data) => {
|
||||
this.transcriptLoading.set(false);
|
||||
this.transcriptAvailable.set(!!data?.available);
|
||||
this.transcriptLanguages.set(Array.isArray(data?.languages) ? data.languages : []);
|
||||
this.transcriptLanguages.set(this.filterTranscriptLanguages(data?.languages));
|
||||
this.transcriptLines.set(Array.isArray(data?.lines) ? data.lines : []);
|
||||
if (data?.available && typeof data?.lang === 'string' && data.lang) {
|
||||
this.selectedTranscriptLang.set(data.lang);
|
||||
@@ -1390,7 +1441,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
this.transcriptLines.set([]);
|
||||
const body = err?.error;
|
||||
if (body && typeof body === 'object' && Array.isArray(body.languages)) {
|
||||
this.transcriptLanguages.set(body.languages);
|
||||
this.transcriptLanguages.set(this.filterTranscriptLanguages(body.languages));
|
||||
// Erreur réseau/502 mais d'autres pistes connues : on essaie la
|
||||
// suivante automatiquement (une fois chacune, garde anti-boucle).
|
||||
if (this.autoFallbackTranscriptLang(body.languages)) return;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface Video {
|
||||
provider?: 'youtube'|'dailymotion'|'twitch'|'rumble'|'odysee'|'peertube';
|
||||
isVertical?: boolean; // true si 9:16
|
||||
isShort?: boolean; // indicateur natif si disponible
|
||||
isLive?: boolean; // true si diffusion en direct (YouTube eventType=live, Twitch streams)
|
||||
kind?: 'clip'|'vod'|'short'|'channel'; // ex. Twitch=clip
|
||||
raw?: any; // métadonnées brutes si besoin
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ const EN: Record<string, string> = {
|
||||
'label.defaultProvider': 'Default Provider',
|
||||
'label.theme': 'Theme',
|
||||
'label.quality': 'Default Quality',
|
||||
'preferences.download_languages': 'Languages to download',
|
||||
'preferences.download_languages_hint': 'Only enabled languages are displayed and downloaded (subtitles/transcripts). French and English by default.',
|
||||
'preferences.download_languages_empty': 'Select at least one language.',
|
||||
'btn.save': 'Save',
|
||||
'btn.add': 'Add',
|
||||
'btn.remove': 'Remove',
|
||||
@@ -182,6 +185,9 @@ const FR: Record<string, string> = {
|
||||
'label.defaultProvider': 'Fournisseur par défaut',
|
||||
'label.theme': 'Thème',
|
||||
'label.quality': 'Qualité par défaut',
|
||||
'preferences.download_languages': 'Langues à télécharger',
|
||||
'preferences.download_languages_hint': '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': 'Sélectionnez au moins une langue.',
|
||||
'btn.save': 'Enregistrer',
|
||||
'btn.add': 'Ajouter',
|
||||
'btn.remove': 'Supprimer',
|
||||
|
||||
@@ -7,7 +7,9 @@ export type TelemetryEvent =
|
||||
| 'provider_picker_open'
|
||||
| 'provider_apply'
|
||||
| 'at_autocomplete_use'
|
||||
| 'quick_menu_open';
|
||||
| 'quick_menu_open'
|
||||
| 'suggest_shown'
|
||||
| 'suggest_used';
|
||||
|
||||
export interface TelemetryMeta {
|
||||
providers?: string[];
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface UserPreferences {
|
||||
language?: string | null;
|
||||
defaultProvider?: string | null;
|
||||
defaultProviders?: string[] | null;
|
||||
downloadLanguages?: string[] | null;
|
||||
theme?: 'light' | 'dark' | 'black' | 'system' | string | null;
|
||||
videoQuality?: string | null;
|
||||
region?: string | null;
|
||||
|
||||
@@ -121,6 +121,74 @@ export class YoutubeApiService {
|
||||
// ... (rest of the code remains the same)
|
||||
|
||||
// ===================== Twitch Search Helpers =====================
|
||||
/** Chaînes Twitch (lives + hors-ligne) pour une requête, façon directory. */
|
||||
public searchTwitchAllChannelsPage(query: string, cursor?: string | null, limit = 20): Observable<PagedResult<Video>> {
|
||||
return this.getTwitchAuthToken().pipe(
|
||||
switchMap(token => {
|
||||
if (!token) return of({ items: [], nextCursor: null });
|
||||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||||
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
|
||||
const primary = `/api/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=${limit}${after}`;
|
||||
const fallback = `/proxy/twitch-api/helix/search/channels?query=${encodeURIComponent(query)}&first=${limit}${after}`;
|
||||
return this.http.get<any>(primary, { headers }).pipe(
|
||||
catchError(() => this.http.get<any>(fallback, { headers })),
|
||||
map(res => ({
|
||||
items: (Array.isArray(res?.data) ? res.data : []).map((i: any) => this.mapTwitchChannelToVideo(i)),
|
||||
nextCursor: res?.pagination?.cursor || null,
|
||||
} as PagedResult<Video>)),
|
||||
catchError((e) => { console.error('Twitch search channels error', e); return of({ items: [], nextCursor: null }); })
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Catégorie Twitch la plus pertinente (ex. "sports" -> Sports + box-art). */
|
||||
public searchTwitchCategory(query: string): Observable<{ id: string; name: string; boxArtUrl?: string } | null> {
|
||||
return this.getTwitchAuthToken().pipe(
|
||||
switchMap(token => {
|
||||
if (!token) return of(null);
|
||||
const q = String(query || '').trim();
|
||||
if (!q) return of(null);
|
||||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||||
const primary = `/api/twitch-api/helix/search/categories?query=${encodeURIComponent(q)}&first=5`;
|
||||
const fallback = `/proxy/twitch-api/helix/search/categories?query=${encodeURIComponent(q)}&first=5`;
|
||||
return this.http.get<any>(primary, { headers }).pipe(
|
||||
catchError(() => this.http.get<any>(fallback, { headers })),
|
||||
map(res => {
|
||||
const list: any[] = Array.isArray(res?.data) ? res.data : [];
|
||||
if (!list.length) return null;
|
||||
const needle = q.toLowerCase();
|
||||
const exact = list.find(c => String(c?.name || '').toLowerCase() === needle);
|
||||
const best = exact || list[0];
|
||||
return best?.id ? { id: String(best.id), name: String(best.name || ''), boxArtUrl: best.box_art_url || undefined } : null;
|
||||
}),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Top streams d'une catégorie Twitch (directory/category façon twitch.tv). */
|
||||
public getTwitchGameStreamsPage(gameId: string, cursor?: string | null, limit = 24): Observable<PagedResult<Video>> {
|
||||
return this.getTwitchAuthToken().pipe(
|
||||
switchMap(token => {
|
||||
if (!token) return of({ items: [], nextCursor: null });
|
||||
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
|
||||
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
|
||||
const primary = `/api/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}`;
|
||||
const fallback = `/proxy/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}`;
|
||||
return this.http.get<any>(primary, { headers }).pipe(
|
||||
catchError(() => this.http.get<any>(fallback, { headers })),
|
||||
map(res => ({
|
||||
items: (res.data || []).map((i: any) => this.mapTwitchStreamToVideo(i)),
|
||||
nextCursor: res.pagination?.cursor || null,
|
||||
} as PagedResult<Video>)),
|
||||
catchError((e) => { console.error('Twitch game streams error', e); return of({ items: [], nextCursor: null }); })
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Search live channels matching query (around 20), with pagination cursor. */
|
||||
public searchTwitchChannelsPage(query: string, cursor?: string | null): Observable<PagedResult<Video>> {
|
||||
return this.getTwitchAuthToken().pipe(
|
||||
@@ -655,16 +723,19 @@ export class YoutubeApiService {
|
||||
} as Video;
|
||||
};
|
||||
|
||||
private mapYouTubeSearchItemToVideo = (i: any): Video => {
|
||||
private mapYouTubeSearchItemToVideo = (i: any, live = false): Video => {
|
||||
const id = i.id?.videoId || i.id;
|
||||
const sn = i.snippet || {};
|
||||
const thumb = sn.thumbnails?.high?.url || sn.thumbnails?.medium?.url || sn.thumbnails?.default?.url || '';
|
||||
const publishedAt = sn.publishedAt || '';
|
||||
const channelId = sn.channelId || '';
|
||||
// liveBroadcastContent === 'live' => réellement en direct ; 'upcoming' => à venir
|
||||
const broadcast = String(sn?.liveBroadcastContent || '').toLowerCase();
|
||||
const isLive = live || broadcast === 'live';
|
||||
return {
|
||||
url: `/watch/${id}`,
|
||||
videoId: id,
|
||||
type: 'video',
|
||||
type: isLive ? 'live' : 'video',
|
||||
provider: 'youtube',
|
||||
title: sn.title || '',
|
||||
thumbnail: thumb,
|
||||
@@ -678,9 +749,80 @@ export class YoutubeApiService {
|
||||
duration: 0,
|
||||
views: 0,
|
||||
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
|
||||
isLive,
|
||||
} as Video;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recherche de directs YouTube (search.list eventType=live + type=video).
|
||||
* Contrairement à une recherche textuelle "live", ne retourne que des
|
||||
* diffusions réellement en direct. `query` optionnel : sans requête on
|
||||
* cherche "live" avec le filtre live pour garantir des résultats.
|
||||
*/
|
||||
public searchYouTubeLivePage(query?: string, cursor?: string | null): Observable<PagedResult<Video>> {
|
||||
const region = this.instances.region();
|
||||
const key = this.getActiveYouTubeKey();
|
||||
if (!key) return of({ items: [], nextCursor: null });
|
||||
const q = String(query || '').trim() || 'live';
|
||||
const params = new URLSearchParams({
|
||||
type: 'video',
|
||||
part: 'snippet',
|
||||
maxResults: '25',
|
||||
q,
|
||||
regionCode: String(region),
|
||||
safeSearch: 'moderate',
|
||||
videoEmbeddable: 'true',
|
||||
eventType: 'live',
|
||||
order: 'viewCount',
|
||||
key: String(key),
|
||||
});
|
||||
if (cursor) params.set('pageToken', cursor);
|
||||
const cacheKey = `yt:live|${region}|${q}|${cursor || ''}`;
|
||||
const cached = this.searchCache.get(cacheKey);
|
||||
const now = Date.now();
|
||||
if (cached && (now - cached.ts) < this.CACHE_TTL_MS) {
|
||||
return of(cached.data);
|
||||
}
|
||||
const path = `/youtube/v3/search`;
|
||||
return this.fetchYouTube(path, params, key).pipe(
|
||||
map(res => ({
|
||||
items: (res.items || []).map((i: any) => this.mapYouTubeSearchItemToVideo(i, true)),
|
||||
nextCursor: res.nextPageToken || null,
|
||||
} as PagedResult<Video>)),
|
||||
switchMap((out) => {
|
||||
const ids = out.items.map(v => v.videoId).filter(Boolean);
|
||||
if (!ids.length) return of(out);
|
||||
return this.getYouTubeContentAndStats(ids).pipe(
|
||||
map((stats) => {
|
||||
const enriched = out.items.map(v => {
|
||||
const s = stats[v.videoId];
|
||||
if (!s) return v;
|
||||
const duration = typeof s.duration === 'number' && s.duration >= 0 ? s.duration : 0;
|
||||
return { ...v, duration, views: s.views, type: 'live', isLive: true };
|
||||
});
|
||||
const next = { ...out, items: enriched } as PagedResult<Video>;
|
||||
this.searchCache.set(cacheKey, { ts: now, data: next });
|
||||
return next;
|
||||
})
|
||||
);
|
||||
}),
|
||||
catchError((e) => {
|
||||
try {
|
||||
const reason = e?.error?.error?.errors?.[0]?.reason || '';
|
||||
const msg = String(e?.error?.error?.message || e?.message || '');
|
||||
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
|
||||
this.quotaExceeded$.next(true);
|
||||
this.banYouTubeKey(key);
|
||||
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
|
||||
this.banYouTubeKey(key);
|
||||
}
|
||||
} catch {}
|
||||
console.error('YouTube live search error', e);
|
||||
return of({ items: [], nextCursor: null });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Dailymotion mapper
|
||||
private mapDailymotionItemToVideo = (i: any): Video => ({
|
||||
url: `/watch/${i.id}`,
|
||||
@@ -831,56 +973,89 @@ export class YoutubeApiService {
|
||||
videoId: i.id || '',
|
||||
type: 'video',
|
||||
provider: 'twitch',
|
||||
kind: 'vod',
|
||||
title: i.title || '',
|
||||
thumbnail: i.thumbnail_url?.replace('%{width}', '640').replace('%{height}', '360') || '',
|
||||
uploaderName: i.user_name || '',
|
||||
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
|
||||
uploaderName: i.user_name || i.broadcaster_name || '',
|
||||
uploaderUrl: i.user_login ? `https://www.twitch.tv/${i.user_login}` : '',
|
||||
uploaderAvatar: '', // Requires another API call
|
||||
channelId: i.user_id || undefined,
|
||||
channelId: i.user_id || i.broadcaster_id || undefined,
|
||||
channelHandle: i.user_login || undefined,
|
||||
channelExternalId: i.user_login || undefined,
|
||||
channelAvatarUrl: undefined,
|
||||
uploadedDate: i.created_at || '',
|
||||
duration: this.parseTwitchDurationToSeconds(i.duration || '0s'),
|
||||
views: i.view_count || 0,
|
||||
views: Number(i.view_count || i.viewer_count || 0),
|
||||
uploaded: i.created_at ? Date.parse(i.created_at) : 0,
|
||||
isLive: false,
|
||||
});
|
||||
|
||||
// Twitch Streams mapper (for trending popular live streams)
|
||||
private mapTwitchStreamToVideo = (i: any): Video => ({
|
||||
url: `https://www.twitch.tv/${i.user_login}`,
|
||||
videoId: i.user_login || i.id || '',
|
||||
type: 'channel',
|
||||
provider: 'twitch',
|
||||
title: i.title || '',
|
||||
thumbnail: (i.thumbnail_url || '').replace('{width}', '640').replace('{height}', '360'),
|
||||
uploaderName: i.user_name || '',
|
||||
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
|
||||
uploaderAvatar: (i.thumbnail_url || '').replace('{width}', '70').replace('{height}', '70'),
|
||||
channelId: i.user_id || undefined,
|
||||
channelHandle: i.user_login || undefined,
|
||||
channelExternalId: i.user_login || undefined,
|
||||
channelAvatarUrl: (i.thumbnail_url || '').replace('{width}', '70').replace('{height}', '70') || undefined,
|
||||
uploadedDate: i.started_at || '',
|
||||
duration: 0,
|
||||
views: Number(i.viewer_count || 0),
|
||||
uploaded: i.started_at ? Date.parse(i.started_at) : 0,
|
||||
});
|
||||
// Les streams Helix sont TOUJOURS en direct : type normalisé 'live' + isLive.
|
||||
// Le thumbnail Helix utilise le motif "{width}x{height}" (ex. ...-{width}x{height}.jpg).
|
||||
private mapTwitchStreamToVideo = (i: any): Video => {
|
||||
const rawThumb: string = String(i.thumbnail_url || '');
|
||||
const resolvedThumb = rawThumb
|
||||
.replace('{width}x{height}', '640x360')
|
||||
.replace('{width}', '640')
|
||||
.replace('{height}', '360')
|
||||
|| 'https://static-cdn.jtvnw.net/ttv-static/404_preview-640x360.jpg';
|
||||
const avatar = rawThumb
|
||||
.replace('{width}x{height}', '70x70')
|
||||
.replace('{width}', '70')
|
||||
.replace('{height}', '70');
|
||||
return {
|
||||
url: `https://www.twitch.tv/${i.user_login}`,
|
||||
videoId: i.user_login || i.id || '',
|
||||
type: 'live',
|
||||
provider: 'twitch',
|
||||
title: i.title || '',
|
||||
thumbnail: resolvedThumb,
|
||||
uploaderName: i.user_name || '',
|
||||
uploaderUrl: `https://www.twitch.tv/${i.user_login}`,
|
||||
uploaderAvatar: avatar,
|
||||
channelId: i.user_id || undefined,
|
||||
channelHandle: i.user_login || undefined,
|
||||
channelExternalId: i.user_login || undefined,
|
||||
channelAvatarUrl: avatar || undefined,
|
||||
uploadedDate: i.started_at || '',
|
||||
duration: 0,
|
||||
views: Number(i.viewer_count || 0),
|
||||
uploaded: i.started_at ? Date.parse(i.started_at) : 0,
|
||||
isLive: true,
|
||||
} as Video;
|
||||
};
|
||||
|
||||
private mapTwitchChannelToVideo = (i: any): Video => ({
|
||||
url: `https://www.twitch.tv/${i.broadcaster_login || i.user_login || ''}`,
|
||||
videoId: i.broadcaster_login || i.user_login || '',
|
||||
type: 'channel',
|
||||
provider: 'twitch',
|
||||
title: i.title || i.display_name || i.broadcaster_login || i.user_name || '',
|
||||
thumbnail: (i.thumbnail_url || '').replace('{width}', '640').replace('{height}', '360'),
|
||||
uploaderName: i.display_name || i.broadcaster_name || i.user_name || '',
|
||||
uploaderUrl: `https://www.twitch.tv/${i.broadcaster_login || i.user_login || ''}`,
|
||||
uploaderAvatar: (i.thumbnail_url || '').replace('{width}', '70').replace('{height}', '70'),
|
||||
uploadedDate: '',
|
||||
duration: 0,
|
||||
views: 0,
|
||||
uploaded: 0,
|
||||
});
|
||||
private mapTwitchChannelToVideo = (i: any): Video => {
|
||||
// /search/channels : thumbnail_url = image de profil (carrée), PAS une miniature 16:9.
|
||||
// On la garde comme avatar + miniature (object-cover côté template) et on
|
||||
// distingue bien les chaînes hors-ligne (type 'channel') des directs.
|
||||
const rawThumb: string = String(i.thumbnail_url || '');
|
||||
const login: string = String(i.broadcaster_login || i.user_login || '');
|
||||
const display: string = String(i.display_name || i.broadcaster_name || i.user_name || login || '');
|
||||
const live = i?.is_live === true;
|
||||
const thumb = rawThumb || 'https://static-cdn.jtvnw.net/ttv-static/404_preview-640x360.jpg';
|
||||
return {
|
||||
url: `https://www.twitch.tv/${login}`,
|
||||
videoId: login || String(i.id || ''),
|
||||
type: live ? 'live' : 'channel',
|
||||
provider: 'twitch',
|
||||
title: (live && i.title) ? String(i.title) : display,
|
||||
thumbnail: thumb,
|
||||
uploaderName: display,
|
||||
uploaderUrl: login ? `https://www.twitch.tv/${login}` : '',
|
||||
uploaderAvatar: rawThumb || '',
|
||||
channelId: i.id || undefined,
|
||||
channelHandle: login || undefined,
|
||||
channelExternalId: login || undefined,
|
||||
channelAvatarUrl: rawThumb || undefined,
|
||||
uploadedDate: live ? (i.started_at || '') : '',
|
||||
duration: 0,
|
||||
views: 0,
|
||||
uploaded: live && i.started_at ? Date.parse(i.started_at) : 0,
|
||||
isLive: live,
|
||||
} as Video;
|
||||
};
|
||||
|
||||
// Twitch Clips mapper (Shorts-equivalent)
|
||||
private mapTwitchClipToVideo = (i: any): Video => ({
|
||||
@@ -895,10 +1070,15 @@ export class YoutubeApiService {
|
||||
uploaderName: i.broadcaster_name || i.creator_name || '',
|
||||
uploaderUrl: i.broadcaster_name ? `https://www.twitch.tv/${i.broadcaster_name}` : '',
|
||||
uploaderAvatar: '',
|
||||
channelId: i.broadcaster_id || undefined,
|
||||
channelHandle: undefined,
|
||||
channelExternalId: undefined,
|
||||
channelAvatarUrl: undefined,
|
||||
uploadedDate: i.created_at || '',
|
||||
duration: Number(i.duration || 0),
|
||||
views: Number(i.view_count || 0),
|
||||
uploaded: i.created_at ? Date.parse(i.created_at) : 0,
|
||||
isLive: false,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -326,3 +326,7 @@ SmartTube (`yuliskov/SmartTube`, 34k stars) ne touche ni la Data API ni les Goog
|
||||
- Limites connues : pas de `getTrending` en youtubei.js v18 → trending reste sur scrape ; continuations parfois redondantes (dédup + garde-fou 12) ; même combat anti-ban IP qu'avant (cookies/PO-Token/proxy réutilisés côté yt-dlp, session InnerTube sans auth).
|
||||
- Complément 2026-09-26 : Watch → sidebar « connexes » branchée sur le vrai watch-next (`loadRelatedSuggestions()` utilise `GET /api/details/youtube/:id` → `related[]`, fallback recherche-par-titre pour les autres providers et en cas d'échec) ; `docker-compose/.env.example` + `README.md` (endpoints + roadmap) à jour ; idée #2 (pagination/infinite scroll) clôturée.
|
||||
- Complément transcript InnerTube : découverte des pistes via `getInfo().captions` (`YT_TRANSCRIPT_SOURCE`, défaut `innertube-first`) adaptée au format yt-dlp → `pickTrack`/`orderedTracks`/`parseTrackText` inchangés ; 0 piste InnerTube = `no_subtitles` direct (même backend que le lecteur) ; fallback yt-dlp conservé. Vérifié live (`lang=en` → 217 lignes) ; `test:transcript` 17/17.
|
||||
- Complément 2026-09-26 (TiFkNMYFvCM) : l'IP d'egress (datacenter) est throttle sur timedtext (200-vide/Sorry/429) quel que soit le client (node, curl, impersonation chrome) — youtube.com en résidentiel n'est pas affecté. Ajout dernier-recours `translatedFallbacks()` (`&tlang=`, YouTube-only, borné à 2) quand la langue demandée reste murée mais une autre passe ; `test:transcript` 18/18. Bouton validé : découverte bien via InnerTube (2 pistes auto `en-US`+`fr`), `fr` → 511 lignes, sélecteur de langue + Réessayer déjà gérés côté Watch.
|
||||
- Rappel fond : IP d'egress throttle sur le **contenu** timedtext (pas la découverte) ; mitigations : `ytdlpNetOpts()` (cookies + proxy) sur les appels yt-dlp, `&tlang=`, cache 24 h, UI retry + sélecteur.
|
||||
- Bug 2026-09-26 (vrai root cause du bouton Transcript) : la route était `app.get('/api/transcript/...')` alors que le front prod appelle `/proxy/api/transcript/...` (apiBase, port ≠ 4000) → aucun match → fallback SPA → **index.html en 200** → panneau "Aucun sous-titre disponible" (en dev, le proxy rewrite masquait le bug). Fix : route montée sur le router `r` (`/api` + `/proxy/api`), idem `rumbleRouter`. Vérifié live sur les deux préfixes (511 lignes).
|
||||
- Complément 2026-09-26 (transcript "n'importe quelle langue") : serveur essaie désormais **toutes** les langues (`firstPerLanguage`, max 10 + 2 traductions `&tlang=`, YouTube) au lieu d'un slice arbitraire à 5 ; UI `watch.component` avec auto-fallback (essaie automatiquement la première langue non tentée listée par le backend, garde anti-boucle, `Réessayer` réinitialise). Logs live : `lang=de` inconnue → parcourt `en` → `en+tlang=de` → yt-dlp avant 502. `test:transcript` 19/19.
|
||||
|
||||
Reference in New Issue
Block a user