Files
NewTube/src/components/themes/theme-page.component.ts
T
bruno 983c6cb5ab
CI / build-and-test (push) Successful in 14m9s
refactor(video): uniformiser classification et routage par section
Source unique de classification (video-kind.ts) + politique de section centralisee (section-policy.ts). Les composants ne portent plus de seuils ni de regles de type maison.

- section-policy.ts: sectionAccepts / routeForVideo / shortsFeedAccepts /
  isPlayable + classifyOnce (enrichissement _kind a l'ingestion)
- watch-short: separe classification (shortsFeedAccepts) et jouabilite
  (isPlayable: allowEmbed DM, live/channel Twitch, Rumble 404); supprime le
  seuil Rumble 90 s et l'import YOUTUBE_SHORT_MAX_SECONDS du composant
- home: filtre sectionAccepts(...,'home') (plus de shorts dans le trending),
  routeForVideo, pagination bouclee (min 20, max 5 iterations)
- themes: _kind pre-calcule une fois, sectionAccepts, capacites live lues
  depuis le registre providers (fin de LIVE_PROVIDERS codes en dur)
- video-kind: helpers isNativeVerticalProvider / hasKnownDuration
- routes: alias /shorts/:id et /live/:id pour routeForVideo
- tests: section-policy.spec.ts + video-kind.spec.ts etendus; npm run test:section
2026-09-29 13:34:58 -04:00

310 lines
13 KiB
TypeScript

import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ThemesService } from '../../services/themes.service';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { Provider, InstanceService } from '../../services/instance.service';
import { Video } from '../../models/video.model';
import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
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 { sectionAccepts, classifyOnce, type WithKind } from '../../app/shared/utils/section-policy';
import { providerSupportsLive } from '../../app/core/providers/provider-registry';
interface ProviderBlock {
provider: Provider;
label: string;
loading: boolean;
error: string | null;
items: WithKind<Video>[];
}
@Component({
selector: 'app-theme-page',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './theme-page.component.html',
imports: [CommonModule, RouterLink, TranslatePipe, LikeButtonComponent, ProviderBadgeComponent, DurationPipe, ChannelIdentityComponent]
})
export class ThemePageComponent {
private route = inject(ActivatedRoute);
themes = inject(ThemesService);
private api = inject(YoutubeApiService);
private instances = inject(InstanceService);
// Show all supported providers on Tendances
private providersToShow: Provider[] = ['youtube', 'twitch', 'dailymotion', 'peertube', 'rumble', 'odysee'];
readonly themeSlug = signal<string>('trending');
readonly themeLabel = computed(() => this.themes.bySlug(this.themeSlug())?.label || this.themeSlug());
blocks = signal<ProviderBlock[]>([]);
// Filtre d'affichage Vidéos / Shorts (les Shorts ne sont plus mélangés sans contrôle).
readonly contentFilter = signal<'all' | 'videos' | 'shorts'>('all');
setContentFilter(v: 'all' | 'videos' | 'shorts'): void {
this.contentFilter.set(v);
}
/** Fournisseurs capables de fournir de vrais directs (source unique = registre). */
readonly providerSupportsLive = providerSupportsLive;
isLiveTheme(): boolean {
return this.themeSlug() === 'live';
}
/** Enrichit une page : `_kind` calculé UNE fois à l'ingestion. */
private enrich(items: Video[], provider: Provider): WithKind<Video>[] {
return (items || []).map(v =>
classifyOnce({ ...(v as object), provider: (v.provider as string) || provider } as Video)
);
}
/** Items du bloc après filtre Vidéos/Shorts (+ LIVE seulement sur thème live). */
filteredItems(b: ProviderBlock): Video[] {
let items: Video[] = 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 => sectionAccepts(v, 'theme-live'));
}
const f = this.contentFilter();
if (f === 'all') return items;
return items.filter(v => f === 'shorts'
? sectionAccepts(v, 'theme-shorts')
: sectionAccepts(v, 'theme-videos'));
}
/** LIVE seulement si réellement en direct (les chaînes hors-ligne ne le sont pas). */
isLiveCard(v: Video): boolean {
return v._kind === 'live' && !this.isChannelCard(v);
}
isChannelCard(v: Video): boolean {
if ((v as any).isLive === true) return false;
return String(v.type || '').toLowerCase() === 'channel';
}
isClipCard(v: Video): boolean {
if (this.isLiveCard(v) || this.isChannelCard(v)) return false;
return v._kind === 'short';
}
shortsCount(b: ProviderBlock): number {
return b.items.filter(v => v._kind === 'short').length;
}
constructor() {
this.route.paramMap.subscribe(pm => {
const slug = pm.get('theme') || pm.get('slug') || 'trending';
this.themeSlug.set(slug);
this.loadAllBlocks();
});
}
private loadAllBlocks() {
const slug = this.themeSlug();
const blocks: ProviderBlock[] = this.providersToShow.map(p => ({ provider: p, label: this.labelFor(p), loading: true, error: null, items: [] }));
this.blocks.set(blocks);
for (const b of blocks) this.fetchForProvider(b, slug);
}
private tokensForTheme(slug: string): string[] {
const tokens = this.themes.tokensFor(slug);
// Special cases
if (slug === 'trending') return [];
if (slug === 'live') return ['live'];
return tokens;
}
private labelFor(p: Provider): string {
switch (p) {
case 'youtube': return 'YouTube';
case 'twitch': return 'Twitch';
case 'peertube': return 'PeerTube';
case 'rumble': return 'Rumble';
case 'odysee': return 'Odysee';
case 'dailymotion': return 'Dailymotion';
}
}
private fetchForProvider(block: ProviderBlock, slug: string) {
// Thème "En direct" : les fournisseurs sans live n'affichent aucune vidéo.
if (slug === 'live' && !providerSupportsLive(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) {
block.loading = false;
block.error = ready.reason || 'Provider not ready';
console.warn('[ThemePage] Provider not ready', { provider: block.provider, theme: slug, reason: block.error });
this.blocks.update(a => [...a]);
return;
}
// If YouTube quota already exceeded, surface and skip
if (block.provider === 'youtube' && this.api.quotaExceeded$.getValue()) {
block.loading = false;
block.error = 'YouTube quota exceeded. Try again later or switch provider.';
console.warn('[ThemePage] Skip fetch: YouTube quota exceeded', { theme: slug });
this.blocks.update(a => [...a]);
return;
}
const tokens = this.tokensForTheme(slug);
const max = 10;
block.loading = true;
block.error = null;
if (slug === 'trending') {
this.api.getTrendingPage(null, block.provider).subscribe({
next: res => {
block.items = this.enrich(res.items || [], block.provider).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug });
this.blocks.update(a => [...a]);
},
error: (e) => {
if (block.provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
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;
}
// 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 = this.enrich(res.items || [], block.provider)
.filter(v => v._kind === 'live')
.slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode });
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;
}
// Twitch hors live/trending : VODs + clips + fallback top streams.
// Helix ne matche pas les longues phrases : un seul mot-clé court.
if (block.provider === 'twitch') {
const raw = tokens.join(' ').trim();
const q = (raw.split(/[^A-Za-z0-9à-ÿ]+/).filter(w => w.length >= 3)[0] || raw).trim();
const show = (items: Video[]) => {
block.items = this.enrich(items || [], block.provider).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
this.blocks.update(a => [...a]);
};
const fail = () => { block.error = 'Erreur de chargement'; block.loading = false; this.blocks.update(a => [...a]); };
const call = q ? this.api.searchTwitchThemePage(q, null) : this.api.getTwitchTopStreamsPage(null);
call.subscribe({
next: res => {
// Thème vide malgré les replis : dernière chance top streams live.
if (!(res.items || []).length && q) {
this.api.getTwitchTopStreamsPage(null).subscribe({
next: top => show(top.items || []),
error: () => fail(),
});
return;
}
show(res.items || []);
},
error: () => {
// Erreur (timeout 30 s…) : une tentative top streams avant l'erreur.
this.api.getTwitchTopStreamsPage(null).subscribe({
next: top => { if ((top.items || []).length) show(top.items || []); else fail(); },
error: () => fail(),
});
}
});
return;
}
const q = tokens.join(' ');
if (!q) {
// fallback to trending
this.api.getTrendingPage(null, block.provider).subscribe({
next: res => {
block.items = this.enrich(res.items || [], block.provider).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode: 'fallback_trending' });
this.blocks.update(a => [...a]);
},
error: (e) => {
if (block.provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
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]);
}
});
} else {
this.api.searchVideosPage(q, null, block.provider).subscribe({
next: res => {
block.items = this.enrich(res.items || [], block.provider).slice(0, max);
block.loading = false;
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
this.blocks.update(a => [...a]);
},
error: (e) => {
if (block.provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
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]);
}
});
}
}
watchQueryParams(provider: Provider, v: Video): Record<string, any> {
const qp: any = { p: provider };
if (provider === 'odysee' && v.url?.startsWith('https://odysee.com/')) {
try {
let slug = v.url.substring('https://odysee.com/'.length);
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
} catch {}
}
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;
}
}