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:
@@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user