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
+79 -14
View File
@@ -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;
}