CI / build-and-test (push) Successful in 14m56s
#1 Live - PeerTube: API native ?isLive=true (recherche sepiasearch + videos de chaine), mapPeertubeItem exporte la regle live (type/kind/isLive, viewers, jamais de duree), caps pt live:true, themes « En direct » branches pt/ru - Rumble: mode 'live' de scrapeRumbleList (rumble.com/browse/live, SSR), items type:live + extraction channelId (/c/<slug>), route GET /api/rumble/live, onglet chaine live filtre par channelId (plafond page 1 = 50 directs) - Odysee: N/A prouve (proxy na-backend sous jeton, probe 2026-10), caps inchangees - Tests: npm run test:live (25 assertions), assertion adapter_contract mise a jour (seuls tw/pt/ru declarent live) #2 Playlists natives - Twitch: NON FAISABLE — probe avec token app valide: GET/POST /helix/playlists et /helix/playlists/{id}/videos -> 404 (controle /helix/users -> 400). Matrice README: Twitch/Odysee/Rumble en N/A avec preuves #3 Quick wins - Page Administration /#/admin (entree sidebar Informations + i18n nav.admin FR/EN): healthz + providers/metrics rendus (statut, yt-dlp, clés actives/ bannies, anti-ban, cache par provider, compteurs 1h, quota du jour) ; healthz sert aussi sous /proxy/api/healthz - TTL par provider: server/env-ttl.mjs (<BASE>_<P> -> <BASE> -> defaut, jamais nul), branche sur les caches details et transcripts ; suggest reste global (cle agregee multi-providers) ; tests npm run test:cache - Import/Export playlists JSON: GET /playlists/export (newtube-playlists-v1, avant /playlists/:id) + POST /playlists/import (dedoublonnage par titre), boutons [Exporter] [Importer] sur la page listes de lecture README: matrice fournisseurs (Live pt/ru OK, od N/A, playlists N/A prouves), roadmap deplace en ✅ (Live, import/export, Admin, TTL) + 🟡 mis a jour.
404 lines
17 KiB
TypeScript
404 lines
17 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 {
|
|
DEFAULT_FILTERS, formatCount, matchesFilters, matchesLanguage, sortItems,
|
|
type SearchFilters,
|
|
} from '../../app/search/filters';
|
|
import { toShortProviderId } from '../../app/shared/providers/provider-ids';
|
|
import { providerSupportsLive } from '../../app/core/providers/provider-registry';
|
|
import { FilterToolbarComponent } from '../../app/shared/components/filter-toolbar/filter-toolbar.component';
|
|
|
|
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, FilterToolbarComponent]
|
|
})
|
|
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[]>([]);
|
|
|
|
// Filtres + tri : identiques à la page de recherche (barre partagée
|
|
// app-filter-toolbar), appliqués ici sur les blocs déjà chargés.
|
|
readonly filters = signal<SearchFilters>({ ...DEFAULT_FILTERS });
|
|
readonly language = signal<'any' | 'en' | 'fr'>('any');
|
|
/** Fournisseurs masqués (bouton « Aucune » puis re-sélection à la main). */
|
|
readonly hiddenProviders = signal<Provider[]>([]);
|
|
|
|
onToolbarFilter(ev: { key: keyof SearchFilters; value: string }): void {
|
|
this.filters.update(f => ({ ...f, [ev.key]: ev.value } as SearchFilters));
|
|
}
|
|
|
|
onSourceChange(ev: { action: 'all' | 'none' | 'toggle' | 'queryAll'; id?: string }): void {
|
|
if (ev.action === 'all') { this.hiddenProviders.set([]); return; }
|
|
if (ev.action === 'none') { this.hiddenProviders.set(this.blocks().map(b => b.provider)); return; }
|
|
if (ev.action !== 'toggle' || !ev.id) return;
|
|
const p = toShortProviderId(ev.id);
|
|
const long = this.blocks().map(b => b.provider).find(x => toShortProviderId(x) === p);
|
|
if (!long) return;
|
|
this.hiddenProviders.update(h => (h.includes(long) ? h.filter(x => x !== long) : [...h, long]));
|
|
}
|
|
|
|
/** Blocs visibles (la ligne Sources masque des blocs entiers). */
|
|
readonly visibleBlocks = computed(() =>
|
|
this.blocks().filter(b => !this.hiddenProviders().includes(b.provider)));
|
|
|
|
/** Ligne Sources de la barre partagée. */
|
|
readonly sourceChips = computed(() => this.blocks().map(b => ({
|
|
id: toShortProviderId(b.provider) || b.provider,
|
|
label: this.labelFor(b.provider),
|
|
visible: !this.hiddenProviders().includes(b.provider),
|
|
countLabel: formatCount(b.items.length),
|
|
})));
|
|
|
|
/** Compteurs par type (pastilles), sur les blocs visibles. */
|
|
readonly typeCounts = computed(() => {
|
|
let live = 0, video = 0, clip = 0, channel = 0, all = 0;
|
|
for (const b of this.visibleBlocks()) {
|
|
for (const v of b.items) {
|
|
all++;
|
|
if (this.isChannelCard(v)) { channel++; continue; }
|
|
if (this.isLiveCard(v)) { live++; continue; }
|
|
if (this.isClipCard(v)) { clip++; continue; }
|
|
video++;
|
|
}
|
|
}
|
|
return { all, live, video, clip, channel };
|
|
});
|
|
|
|
/** « X / Y » du panneau Filtres (résultats visibles / chargés). */
|
|
readonly resultCounts = computed(() => {
|
|
let shown = 0, total = 0;
|
|
for (const b of this.visibleBlocks()) {
|
|
total += b.items.length;
|
|
shown += this.filteredItems(b).length;
|
|
}
|
|
return { shown, total };
|
|
});
|
|
|
|
/** « Tout effacer » du panneau Filtres. */
|
|
clearFilters(): void {
|
|
this.filters.set({ ...DEFAULT_FILTERS });
|
|
this.language.set('any');
|
|
}
|
|
|
|
/** 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 les filtres de la barre partagée, puis le tri courant. */
|
|
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.filters();
|
|
const lang = this.language();
|
|
// Le type suit la classification de la page (`_kind`, celle des badges des
|
|
// cartes) ; période / durée passent par le modèle partagé avec `type`
|
|
// neutralisé pour ne pas le filtrer deux fois avec deux sémantiques.
|
|
const rest = { ...f, type: 'all' as const };
|
|
items = items.filter(v =>
|
|
this.matchesType(v) && matchesFilters(v, rest) && matchesLanguage(v, lang));
|
|
return sortItems(items, f.sort);
|
|
}
|
|
|
|
/** Type de contenu selon la classification de la page. */
|
|
private matchesType(v: Video): boolean {
|
|
switch (this.filters().type) {
|
|
case 'channel': return this.isChannelCard(v);
|
|
case 'live': return sectionAccepts(v, 'theme-live');
|
|
case 'video': return sectionAccepts(v, 'theme-videos');
|
|
case 'shorts': return sectionAccepts(v, 'theme-shorts');
|
|
default: return true;
|
|
}
|
|
}
|
|
|
|
/** 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).
|
|
// - PeerTube : API native `?isLive=true` (instance active).
|
|
// - Rumble : page SSR rumble.com/browse/live (route /api/rumble/live).
|
|
if (slug === 'live') {
|
|
const mode = block.provider === 'youtube' ? 'youtube_eventType_live'
|
|
: block.provider === 'twitch' ? 'twitch_top_streams'
|
|
: block.provider === 'peertube' ? 'peertube_islive'
|
|
: 'rumble_browse_live';
|
|
// La porte `providerSupportsLive` n'autorise que yt/tw/pt/ru : tout
|
|
// autre provider ne doit PAS retomber sur la source Rumble par défaut.
|
|
if (!['youtube', 'twitch', 'peertube', 'rumble'].includes(block.provider)) {
|
|
block.loading = false; block.error = null; block.items = [];
|
|
this.blocks.update(a => [...a]);
|
|
return;
|
|
}
|
|
const liveCall = block.provider === 'youtube'
|
|
? this.api.searchYouTubeLivePage('', null)
|
|
: block.provider === 'twitch'
|
|
? this.api.getTwitchTopStreamsPage(null)
|
|
: block.provider === 'peertube'
|
|
? this.api.searchPeerTubeLivePage(null)
|
|
: this.api.getRumbleLivePage(null);
|
|
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;
|
|
}
|
|
}
|