feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
CI / build-and-test (push) Successful in 14m43s

7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
This commit is contained in:
2026-09-30 07:57:11 -04:00
parent 37681c4f53
commit 665a0f0ebd
87 changed files with 8850 additions and 550 deletions
+3 -2
View File
@@ -12,6 +12,7 @@ import { ThemesService } from '../../services/themes.service';
import { HistoryService, SearchHistoryItem } from '../../services/history.service';
import { SearchBoxComponent } from '../search/search-box.component';
import { filtersToQueryParams, type SearchFilters } from '../../app/search/filters';
import { ALL_PROVIDER_IDS_CSV, type ProviderId } from '../../app/shared/providers/provider-ids';
@Component({
selector: 'app-header',
@@ -142,7 +143,7 @@ export class HeaderComponent implements AfterViewInit {
});
// New SearchBox submit handler (navigate with providers list + filters)
onSearchBoxSubmit(evt: { q: string; providers: ("yt"|"dm"|"tw"|"pt"|"od"|"ru")[] | 'all'; filters?: SearchFilters }) {
onSearchBoxSubmit(evt: { q: string; providers: ProviderId[] | 'all'; filters?: SearchFilters }) {
const q = (evt?.q || '').trim();
if (!q) return;
const provider = this.selectedProvider();
@@ -152,7 +153,7 @@ export class HeaderComponent implements AfterViewInit {
qp.providers = evt.providers.join(',');
} else if (evt.providers === 'all') {
// Explicitly include all providers so SearchComponent uses unified multi-provider mode
qp.providers = 'yt,dm,tw,pt,od,ru';
qp.providers = ALL_PROVIDER_IDS_CSV;
}
// Filtres (type / durée / période / tri) : seulement les valeurs actives,
// pour garder une URL lisible et partageable.
@@ -18,6 +18,7 @@ import {
type SubscriptionItem,
} from '../../../services/subscriptions.service';
import type { ProviderId } from '../../../app/shared/models/channel.model';
import { toShortProviderId } from '../../../app/shared/providers/provider-ids';
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
import { SubscribeButtonComponent } from '../../../app/shared/components/subscribe-button/subscribe-button.component';
@@ -488,19 +489,7 @@ export class SubscriptionsComponent {
if (filter !== 'all') {
// Normalise les codes courts ('yt') et longs ('youtube') : les imports
// mixtes stockent les deux formes.
const alias = (p: string | null | undefined): string => {
const v = String(p || '').toLowerCase();
return (
{
youtube: 'yt', yt: 'yt',
dailymotion: 'dm', dm: 'dm',
twitch: 'tw', tw: 'tw',
peertube: 'pt', pt: 'pt',
odysee: 'od', od: 'od',
rumble: 'ru', ru: 'ru',
} as Record<string, string>
)[v] || v;
};
const alias = (p: string | null | undefined): string => toShortProviderId(p) || String(p || '').toLowerCase();
filtered = filtered.filter((item) => alias(item.channel?.provider) === filter);
}
if (groupId !== 'all') {
+22 -5
View File
@@ -85,25 +85,25 @@
[attr.aria-pressed]="typePill() === 'all'">
Tout <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().all) }}</span>
</button>
<button type="button" *ngIf="typeCounts().live > 0 || typePill() === 'live'" (click)="setTypePill('live')"
<button type="button" *ngIf="canShowPill('live')" (click)="setTypePill('live')"
class="chip inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="typePill() === 'live' ? 'border border-transparent bg-purple-600 text-white' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'live'">
Directs <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().live) }}</span>
</button>
<button type="button" *ngIf="typeCounts().video > 0 || typePill() === 'video'" (click)="setTypePill('video')"
<button type="button" *ngIf="canShowPill('video')" (click)="setTypePill('video')"
class="chip inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="typePill() === 'video' ? 'border border-transparent bg-slate-200 text-slate-900' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'video'">
Vidéos <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().video) }}</span>
</button>
<button type="button" *ngIf="typeCounts().clip > 0 || typePill() === 'shorts'" (click)="setTypePill('shorts')"
<button type="button" *ngIf="canShowPill('clip')" (click)="setTypePill('shorts')"
class="chip inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="typePill() === 'shorts' ? 'border border-transparent bg-purple-600 text-white' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'shorts'">
Clips <span class="ml-1.5 text-[11px] tabular-nums opacity-60">{{ countLabel(typeCounts().clip) }}</span>
</button>
<button type="button" *ngIf="typeCounts().channel > 0 || typePill() === 'channel'" (click)="setTypePill('channel')"
<button type="button" *ngIf="canShowPill('channel')" (click)="setTypePill('channel')"
class="chip inline-flex h-8 shrink-0 items-center whitespace-nowrap rounded-full px-3 text-[13px] font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
[ngClass]="typePill() === 'channel' ? 'border border-transparent bg-slate-200 text-slate-900' : 'border border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'"
[attr.aria-pressed]="typePill() === 'channel'">
@@ -280,7 +280,24 @@
</div>
}
<app-search-result-grid [videos]="filteredResults()" [loading]="loading()" [loadingMore]="loadingMore()"></app-search-result-grid>
<!-- Phase 7.4 : badge discret « source dégradée ». Distinct du bandeau d'erreur
ci-dessous : celui-ci concerne CETTE requete, celui-la l'etat amont reel
(une source non selectionnee peut etre morte). Masque si rien n'est
degrade, et jamais pour un provider volontairement eteint (FF_*). -->
@if (degradedSources().length > 0) {
<div class="mb-2 flex items-start gap-2 text-[11px] text-amber-200/90" role="status" aria-live="polite" [title]="degradedSourcesTitle()">
<svg class="mt-px h-3 w-3 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
</svg>
<span>
{{ degradedSources().length }} source{{ degradedSources().length > 1 ? 's' : '' }} dégradée{{ degradedSources().length > 1 ? 's' : '' }} :
<span class="text-amber-200/70">{{ degradedSources()[0].label }}</span><span *ngIf="degradedSources().length > 1"> +{{ degradedSources().length - 1 }}</span>
<span class="ml-1 text-amber-200/50">(résultats peut-être incomplets)</span>
</span>
</div>
}
<app-search-result-grid [videos]="filteredResults()" [loading]="loading()" [loadingMore]="loadingMore()" [providerErrors]="providerErrorList()" [pendingProviders]="pendingProviderList()" [skeletonAspect]="skeletonAspect()"></app-search-result-grid>
<!-- Standard infinite scroll: shared sentinel + bottom loader + end state -->
<div class="flex flex-col items-center justify-center py-6">
+169 -42
View File
@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewChild, ElementRef } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal, ViewChild, ElementRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, ParamMap } from '@angular/router';
@@ -21,6 +21,9 @@ import {
} from '../../app/search/filters';
import { UserService, type UserPreferences } from '../../services/user.service';
import { PROVIDERS, type ProviderId } from '../../app/core/providers/provider-registry';
import { ALL_PROVIDER_IDS, ALL_PROVIDER_IDS_CSV, SHORT_TO_LONG, toShortProviderId } from '../../app/shared/providers/provider-ids';
import { PROVIDER_CAPABILITIES } from '../../app/shared/providers/provider-capabilities';
import { ProviderHealthService, type ProviderHealth } from '../../app/search/provider-health.service';
import { Observable, Subscription } from 'rxjs';
/** Longueur minimale d'une requete (aligne sur `SearchService.request$` et le backend). */
@@ -57,6 +60,8 @@ export class SearchComponent {
private title = inject(Title);
private unified = inject(SearchService);
private users = inject(UserService);
private providerHealth = inject(ProviderHealthService);
private destroyRef = inject(DestroyRef);
q = signal<string>('');
loading = signal<boolean>(false);
@@ -104,13 +109,7 @@ export class SearchComponent {
// Active providers derived from `providers` query param or fallback to all available.
// Always normalized to short ids ('yt', …) so ordering/badges stay consistent.
activeProviders = computed<ProviderId[]>(() => {
const norm = (v: unknown): ProviderId | null => {
const s = String(v || '').trim();
if (!s) return null;
if ((PROVIDERS as { id: string }[]).some(pr => pr.id === s)) return s as ProviderId;
const hit = Object.entries(this.providerIdMap).find(([, full]) => full === s);
return hit ? (hit[0] as ProviderId) : null;
};
const norm = (v: unknown): ProviderId | null => toShortProviderId(v);
const p = this.providersListParam();
if (p === 'all' || p == null) {
try {
@@ -118,26 +117,21 @@ export class SearchComponent {
.map(x => norm(x.id))
.filter((x): x is ProviderId => !!x);
if (ids.length > 0) return ids;
return ['yt','dm','tw','pt','od','ru'];
return [...ALL_PROVIDER_IDS];
}
catch { return ['yt','dm','tw','pt','od','ru']; }
catch { return [...ALL_PROVIDER_IDS]; }
}
if (Array.isArray(p) && p.length > 0) {
const ids = p.map(norm).filter((x): x is ProviderId => !!x);
if (ids.length > 0) return ids;
}
return ['yt','dm','tw','pt','od','ru'];
return [...ALL_PROVIDER_IDS];
});
// Map between Provider and ProviderId
private readonly providerIdMap: Record<string, Provider> = {
'yt': 'youtube',
'dm': 'dailymotion',
'tw': 'twitch',
'pt': 'peertube',
'od': 'odysee',
'ru': 'rumble'
};
// Map between Provider (long name) and ProviderId (short) — derived from the single source.
private readonly providerIdMap: Record<ProviderId, Provider> = Object.fromEntries(
ALL_PROVIDER_IDS.map(id => [id, SHORT_TO_LONG[id] as unknown as Provider]),
) as Record<ProviderId, Provider>;
// ---- En-tete compact : filtres repliables + fournisseurs en boutons ----
/**
@@ -412,6 +406,12 @@ export class SearchComponent {
.pipe(takeUntilDestroyed())
.subscribe({
next: (resp: any) => {
// Phase 7.6 — agrégation progressive : `partial` = certains providers
// répondent encore. On met à jour la grille MAIS on ne conclut rien
// (pas de fin atteinte, pas de fin du chargement) tant que le snapshot
// final n'est pas arrivé.
const partial = resp?.partial === true;
// Convertir la réponse et concaténer si page > 1 (scroll infini)
const incoming: Record<string, any[]> = {};
if (resp.groups) {
@@ -426,21 +426,38 @@ export class SearchComponent {
const merged = this.mergeGroups(this.groups(), incoming);
this.groups.set(merged);
// If no new items were added, mark end reached
const addedCount = Object.values(incoming).reduce((a: number, arr: any[]) => a + (arr?.length || 0), 0);
if (addedCount === 0) this.endReached.set(true);
if (!partial) {
const addedCount = Object.values(incoming).reduce((a: number, arr: any[]) => a + (arr?.length || 0), 0);
if (addedCount === 0) this.endReached.set(true);
}
} else {
this.groups.set(incoming);
// If first page is empty for all providers, end reached
const total = Object.values(incoming).reduce((a: number, arr: any[]) => a + (arr?.length || 0), 0);
this.endReached.set(total === 0);
// Only the final snapshot decides. Un snapshot partiel a forcément
// `total === 0` pour les sources pas encore revenues : conclure « fini »
// là-dessus fermerait la page avant la fin.
if (!partial) {
const total = Object.values(incoming).reduce((a: number, arr: any[]) => a + (arr?.length || 0), 0);
this.endReached.set(total === 0);
}
}
if (partial) {
// Les sources déjà livrées quittent la liste des squelettes.
const settled = new Set<string>((resp.providers || []).map((p: string) => String(p)));
this.pendingProviderListUpdate(settled);
} else {
this.loading.set(false);
this.loadingMore.set(false);
this.clearLoadingWatchdog();
// Phase 7.6 : plus rien en attente -> plus aucun squelette.
this.pendingProviderListUpdate(null);
}
this.loading.set(false);
this.loadingMore.set(false);
this.clearLoadingWatchdog();
this.error.set(null);
// Surface per-provider backend failures (e.g. YouTube quota) so
// users understand why only some providers return results.
this.reflectProviderErrors(resp?.errors);
// Phase 7.4 : une fois la réponse reçue, on rafraîchit l'état de santé
// des sources (le bandeau ci-dessus ne parle que de CETTE requête).
this.refreshProviderHealth();
},
error: () => {
this.groups.set({} as any);
@@ -619,6 +636,30 @@ export class SearchComponent {
return { live, video, clip, channel, all: (list || []).length };
});
/**
* Phase 7.5 — une pastille n'apparaît que si au moins UN fournisseur actif sait
* réellement servir ce type. Sans cette garde, on proposait « Directs » sur une
* recherche 100 % Dailymotion (qui n'a pas de live) : la pastille menait à un
* vide, ce qui se lisait comme un bug. Source unique : `PROVIDER_CAPABILITIES`.
*/
private hasLiveProvider(): boolean {
return this.activeProviders().some((id) => PROVIDER_CAPABILITIES[id]?.live === true);
}
private hasChannelCapableProvider(): boolean {
return this.activeProviders().length > 0;
}
canShowPill(kind: 'live' | 'video' | 'clip' | 'channel'): boolean {
const counts = this.typeCounts();
if (this.typePill() === kind) return true; // toujours atteignable si déjà sélectionné
if (kind === 'live') return counts.live > 0 || this.hasLiveProvider();
if (kind === 'video') return counts.video > 0;
if (kind === 'clip') return counts.clip > 0;
if (kind === 'channel') return counts.channel > 0 || this.hasChannelCapableProvider();
return true;
}
/** Nombre compact pour les compteurs de l'en-tête (1 284 -> « 1,3 k »). */
countLabel(n: number): string {
return formatCount(n);
@@ -752,11 +793,7 @@ export class SearchComponent {
/** Normalize a provider token (short 'yt' or full 'youtube') to ProviderId. */
private toProviderId(v: unknown): ProviderId | null {
const s = String(v || '').trim();
if (!s) return null;
if ((PROVIDERS as { id: string }[]).some(p => p.id === s)) return s as ProviderId;
const hit = Object.entries(this.providerIdMap).find(([, full]) => full === s);
return hit ? (hit[0] as ProviderId) : null;
return toShortProviderId(v);
}
/** Round-robin interleave preserving per-provider order and provider order. */
@@ -801,28 +838,118 @@ export class SearchComponent {
}
}
/**
* Phase 7.4 — échecs par provider, version structurée.
* L'ancien `notice` en texte libre collapsait tout en une phrase et exposait
* le message technique brut au lecteur. On garde un signal structuré que la
* grille rend (une seule bannière, lisible, avec une cause traduite).
*/
providerErrorList = signal<{ id: string; label: string; message: string }[]>([]);
/** Show backend per-provider failures as an amber notice (partial results). */
private reflectProviderErrors(errors: unknown): void {
if (!errors || typeof errors !== 'object') {
this.notice.set(null);
this.providerErrorList.set([]);
return;
}
const entries = Object.entries(errors as Record<string, unknown>)
.filter(([, m]) => typeof m === 'string' && String(m).trim().length > 0);
if (entries.length === 0) {
this.notice.set(null);
this.providerErrorList.set([]);
return;
}
const parts = entries.map(([pid, m]) => {
const msg = String(m).replace(/\s+/g, ' ').trim();
const short = msg.length > 140 ? msg.slice(0, 140) + '…' : msg;
return `${this.providerLabelOf(pid)} : ${short}`;
});
this.notice.set(
`Résultats partiels — certains fournisseurs n'ont pas répondu (${parts.join(' · ')}).`
);
this.providerErrorList.set(entries.map(([pid, m]) => ({
id: pid,
label: this.providerLabelOf(pid),
message: String(m).replace(/\s+/g, ' ').trim(),
})));
}
/**
* Phase 7.4 — seconde moitié de la tâche : le badge « source dégradée ».
*
* Le bandeau ci-dessus répond à « cette recherche a-t-elle échoué ? ». Il ne
* répond pas à « une source que je n'ai pas sélectionnée est-elle morte ? »,
* ce qui est précisément le cas qui fait croire à l'utilisateur qu'une
* recherche renvoie moins de résultats qu'avant. `/api/providers/health`
* apporte cette information ; on n'affiche un badge que pour les sources
* RÉELLEMENT dégradées — un provider éteint par feature flag n'est pas en
* panne, et l'annoncer comme tel serait un faux signal d'incident.
*/
private readonly providerHealthState = signal<ProviderHealth[]>([]);
/** Sources dégradées, une fois retirées celles volontairement désactivées. */
degradedSources = computed<{ id: string; label: string; detail: string }[]>(() =>
this.providerHealthState()
.filter((h) => h.state === 'degraded')
.map((h) => ({
id: h.id,
label: this.providerLabelOf(h.id),
detail: this.degradedDetail(h),
})),
);
/** Phrase courte pour l'infobulle du badge. */
degradedSourcesTitle = computed(() => {
const list = this.degradedSources();
if (!list.length) return '';
return list.map((d) => `${d.label} : ${d.detail}`).join('\n');
});
private degradedDetail(h: ProviderHealth): string {
if (h.consecutiveFailures > 0) return `${h.consecutiveFailures} échec(s) consécutif(s)`;
if (h.errorRate > 0) return `taux d'erreur ${Math.round(h.errorRate * 100)} %`;
return 'réponse en échec';
}
/**
* Rafraîchit l'état de santé APRÈS une recherche, jamais au chargement de la
* page : cet endpoint sonde les upstreams, on ne veut pas six requêtes
* réseau avant même que l'utilisateur ait tapé quoi que ce soit. Le TTL de 60 s
* du service rend les rafraîchissements suivants gratuits.
*/
private refreshProviderHealth(): void {
this.providerHealth.health()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((entries) => this.providerHealthState.set(entries || []));
}
/**
* Phase 7.6 — sources en attente, pour un squelette PAR SOURCE.
* Les providers réellement interrogés (ceux de l'URL), pas la liste entière :
* afficher « YouTube est en attente » alors qu'il n'a pas été demandé serait
* un faux signal.
*/
private readonly pendingSettled = new Set<string>();
pendingProviderList = computed<{ id: string; label: string }[]>(() =>
this.visibleProviders()
.filter((id) => !this.pendingSettled.has(id))
.map((id) => ({ id, label: this.providerLabelOf(id) })),
);
/**
* Fait sortir une source de la liste des squelettes quand son groupe arrive,
* ou vide tout (snapshot final).
*/
private pendingProviderListUpdate(settled: Set<string> | null): void {
if (!settled) { this.pendingSettled.clear(); return; }
for (const id of settled) this.pendingSettled.add(id);
}
/**
* Ratio des squelettes. Si l'utilisateur filtre sur les Shorts, les cartes
* qui arrivent seront verticales : un squelette 16:9 ferait sauter la page
* à l'arrivée des résultats (le défaut qu'on veut justement supprimer).
*/
skeletonAspect = computed<number | null>(() => {
const type = (this.filters() as { type?: string })?.type;
return type === 'short' ? 9 / 16 : null;
});
formatRelative(dateIso?: string): string {
if (!dateIso) return '';
@@ -869,7 +996,7 @@ export class SearchComponent {
if (Array.isArray(evt.providers)) {
queryParams.providers = evt.providers.join(',');
} else if (evt.providers === 'all') {
queryParams.providers = 'yt,dm,tw,pt,od,ru';
queryParams.providers = ALL_PROVIDER_IDS_CSV;
}
// Nettoyer les paramètres avant la navigation
@@ -14,11 +14,12 @@ import { SubscriptionsService, GROUP_ICON_KEYWORDS } from '../../services/subscr
import { AuthService } from '../../services/auth.service';
import { Video } from '../../models/video.model';
import { shortsFeedAccepts, isPlayable as isPlayablePolicy, type PlayableContext } from '../../app/shared/utils/section-policy';
import { SHORT_TO_LONG, type ProviderId } from '../../app/shared/providers/provider-ids';
/** Provider long (youtube…) -> id court d'abonnement ('yt'…). */
const SUB_PROVIDER_OF: Record<string, 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru'> = {
youtube: 'yt', dailymotion: 'dm', twitch: 'tw', peertube: 'pt', odysee: 'od', rumble: 'ru',
};
const SUB_PROVIDER_OF: Record<string, ProviderId> = Object.fromEntries(
Object.entries(SHORT_TO_LONG).map(([shortId, longId]) => [longId, shortId]),
) as Record<string, ProviderId>;
/** Mots vides FR/EN + termes vidéo génériques (extraction de sujets "Pour toi"). */
const TOPIC_STOPWORDS = new Set([
+2 -7
View File
@@ -16,6 +16,7 @@ import { AuthService } from '../../services/auth.service';
import { ChannelsService } from '../../services/channels.service';
import { SubscriptionsService } from '../../services/subscriptions.service';
import type { ChannelMeta, ProviderId } from '../../app/shared/models/channel.model';
import { toShortProviderId } from '../../app/shared/providers/provider-ids';
import { SubscribeButtonComponent } from '../../app/shared/components/subscribe-button/subscribe-button.component';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { IframeProgressService } from '../../services/iframe-progress.service';
@@ -60,13 +61,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
subscribeBusy = signal(false);
/** Mappe le provider d'affichage ('youtube') vers l'id canal ('yt'). */
subscribeProvider = computed<ProviderId | null>(() => {
const map: Record<string, ProviderId> = {
youtube: 'yt', dailymotion: 'dm', twitch: 'tw',
peertube: 'pt', odysee: 'od', rumble: 'ru',
};
return map[this.provider()] || null;
});
subscribeProvider = computed<ProviderId | null>(() => toShortProviderId(this.provider()));
/** ExternalId de la chaîne : meta résolue > détails vidéo > néant. */
subscribeExternalId = computed<string | null>(() => {