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
+2 -1
View File
@@ -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 };
}
}
+2 -1
View File
@@ -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 };
}
}
+2 -1
View File
@@ -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 };
}
}
+2 -1
View File
@@ -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 };
}
}
+18 -9
View File
@@ -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 };
}
}
+2 -1
View File
@@ -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 };
}
}
+2
View File
@@ -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>>;
}
+3 -1
View File
@@ -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> {
+9 -1
View File
@@ -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)