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)
@@ -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
}