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

This commit is contained in:
2026-09-26 11:49:56 -04:00
parent 1b0a7eb3ce
commit 655061ba2c
46 changed files with 2018 additions and 380 deletions
+220 -40
View File
@@ -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,
});
/**