feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
CI / build-and-test (push) Successful in 14m43s
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:
@@ -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') {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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>(() => {
|
||||
|
||||
Reference in New Issue
Block a user