feat(shorts): préchauffage en arrière-plan du flux « Pour toi »
CI / build-and-test (push) Successful in 15m4s

Cliquer sur « Pour toi » lançait un fan-out d'environ 22 appels (4 sujets ×
5 fournisseurs + Twitch) et attendait le plus lent : 4 à 8 s mesuré, 13 s à
froid.

- Dès la première vidéo affichée, le lot est préparé EN ARRIÈRE-PLAN
  (`warmForYou`) : mêmes tuyaux (`topics$()` puis `forYouFeed$()`), aucune
  écriture de signal — l'écran ne bouge pas. Réchauffage périodique toutes
  les 5 min (onglet visible), lot réputé frais pendant 5 min.
- `toggleForYou` consomme le lot : prêt → application immédiate ; en cours →
  le flux partagé (shareReplay) est rejoint au lieu de déclencher un second
  fan-out ; périmé ou absent → chemin réseau comme avant.
- Refactor : `loadForYou` = `topics$()` + `fetchForYou`, le fan-out devient
  `forYouFeed$()` (pur, retourne le lot) et un seul `applyForYouFeed()` écrit
  les signaux, `seenIds` compris.
- Journal `[forYou]` en console : préchauffage lancé, lot préparé, branche
  prise au clic — pour diagnostiquer un clic lent sans rejouer la scénario.

Mesures (Playwright, localhost:4200) : clic avec lot prêt 34-74 ms
(4,3 à 7,6 s avant), clic pendant la préparation 1,3-1,8 s avec UN seul
fan-out, UI intacte pendant la préparation. Harnais :
$LOCALAPPDATA/Temp/pw/foryou-warm-check.mjs (7 checks).
This commit is contained in:
2026-10-03 11:42:12 -04:00
parent 80ac48da20
commit bb2cd2f780
+141 -37
View File
@@ -3,8 +3,8 @@ import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { firstValueFrom, forkJoin, from, map, of } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { firstValueFrom, forkJoin, from, map, of, Observable } from 'rxjs';
import { catchError, shareReplay, switchMap } from 'rxjs/operators';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { InstanceService, Provider } from '../../services/instance.service';
import { HistoryService } from '../../services/history.service';
@@ -43,6 +43,13 @@ const LS_SHORTS_LANG_KEY = 'newtube.shorts.langFilter';
/** Clé de persistance de la lecture auto (activée par défaut). */
const LS_SHORTS_AUTONEXT_KEY = 'newtube.shorts.autoNext';
/** Préchauffage « Pour toi » : fraîcheur d'un lot préparé en arrière-plan. */
const FORYOU_PREFETCH_TTL_MS = 5 * 60_000;
/** ponytail : cadence ≤ TTL ⇒ un tour peut sauter (cycle ~5-10 min), largement
* suffisant pour servir un lot frais ; passer à un réchauffage serveur (cron +
* search_cache) si le coût client devient gênant. */
const FORYOU_PREFETCH_EVERY_MS = 5 * 60_000;
type ShortsGlyph = { kind: 'play' } | { kind: 'chat' } | { kind: 'letter'; letter: string };
/**
@@ -124,6 +131,12 @@ export class WatchShortComponent implements OnDestroy {
forYou = signal(false);
topics = signal<string[]>([]);
topicsLoading = signal(false);
/** Lot "Pour toi" préparé en arrière-plan (aucune écriture d'UI). */
private forYouWarm: { at: number; items: Video[] } | null = null;
/** Lot en cours de préparation : un clic l'attend au lieu d'en lancer un 2e. */
private forYouWarm$: Observable<Video[]> | null = null;
private forYouWarmStarted = false;
private forYouWarmTimer: ReturnType<typeof setInterval> | null = null;
// ---- Expérience façon YouTube Shorts ----
/** Cadre 9:16 (plein écran + focus). */
@@ -724,6 +737,23 @@ export class WatchShortComponent implements OnDestroy {
this.fetchNextPage(false);
}
});
// Préchauffage du flux "Pour toi" : dès que la PREMIÈRE vidéo est affichée
// (sans attendre la pagination, qui retarderait le lot au-delà du clic),
// on prépare ce lot EN ARRIÈRE-PLAN (sans toucher à l'UI) puis on le refait
// périodiquement — le clic sur "Pour toi" le consomme au lieu d'attendre le
// fan-out multi-fournisseurs (~4-8 s, 22 appels).
effect(() => {
const ready = !this.loading() && this.items().length > 0;
if (!ready || this.forYouWarmStarted) return;
this.forYouWarmStarted = true;
console.log('[forYou] préchauffage lancé');
try {
this.warmForYou();
this.forYouWarmTimer = setInterval(() => {
try { if (typeof document === 'undefined' || !document.hidden) this.warmForYou(); } catch {}
}, FORYOU_PREFETCH_EVERY_MS);
} catch {}
});
// Hydrate abonnements (état du bouton S'abonner) une fois.
try {
if (this.subs.items().length === 0) this.subs.list().subscribe();
@@ -820,6 +850,8 @@ export class WatchShortComponent implements OnDestroy {
try { if (this.passthroughTimer) clearTimeout(this.passthroughTimer); } catch {}
this.passthroughTimer = null;
if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} }
try { if (this.forYouWarmTimer) clearInterval(this.forYouWarmTimer); } catch {}
this.forYouWarmTimer = null;
}
/**
@@ -1404,9 +1436,62 @@ export class WatchShortComponent implements OnDestroy {
return;
}
this.forYou.set(true);
// Lot préparé en arrière-plan : consommé au lieu de relancer le fan-out
// (~4-8 s). Un lot déjà en cours se rejoint (partagé) ; un lot périmé
// retombe sur le chargement réseau normal.
const inflight = this.forYouWarm$;
if (inflight) {
console.log('[forYou] clic : lot en préparation, on attend la fin');
this.consumeForYou(inflight);
return;
}
const warm = this.forYouWarm;
if (warm && Date.now() - warm.at < FORYOU_PREFETCH_TTL_MS) {
console.log(`[forYou] clic : lot prêt (${warm.items.length} items, âge ${Math.round((Date.now() - warm.at) / 1000)} s)`);
this.applyForYouFeed(warm.items);
return;
}
console.log('[forYou] clic : aucun lot frais, chargement réseau');
this.loadForYou();
}
/** S'abonne au lot partagé : valeur rejouée si prêt, sinon fin du fan-out. */
private consumeForYou(shared: Observable<Video[]>): void {
this.loading.set(true);
this.error.set(null);
this.items.set([]);
this.index.set(0);
this.nextCursor.set(null);
shared.subscribe({
next: (items) => this.applyForYouFeed(items || []),
error: () => { this.afterNoResults(); this.loading.set(false); },
});
}
/**
* Prépare le lot "Pour toi" EN ARRIÈRE-PLAN : mêmes tuyaux que le chargement
* (sujets puis fan-out), mais AUCUNE écriture de signal — l'écran ne bouge
* pas. Le flux est partagé : un clic pendant la préparation rejoint ce même
* appel au lieu d'en déclencher un deuxième.
*/
private warmForYou(): void {
if (this.forYouWarm$) return; // déjà en vol
if (this.forYouWarm && Date.now() - this.forYouWarm.at < FORYOU_PREFETCH_TTL_MS) return; // déjà frais
const shared = this.topics$().pipe(
switchMap((topics) => this.forYouFeed$(topics)),
shareReplay({ bufferSize: 1, refCount: false }),
);
this.forYouWarm$ = shared;
shared.subscribe({
next: (items) => {
console.log(`[forYou] lot préparé en arrière-plan : ${items?.length ?? 0} items`);
if (items?.length) this.forYouWarm = { at: Date.now(), items };
},
error: () => console.log('[forYou] préchauffage en échec'),
complete: () => { this.forYouWarm$ = null; },
});
}
private loadForYou(): void {
this.loading.set(true);
this.error.set(null);
@@ -1415,6 +1500,26 @@ export class WatchShortComponent implements OnDestroy {
this.nextCursor.set(null);
this.seenIds = new Set<string>();
this.topicsLoading.set(true);
this.topics$().subscribe({
next: (topics) => {
this.topics.set(topics);
this.topicsLoading.set(false);
this.fetchForYou(topics);
},
error: () => {
this.topicsLoading.set(false);
this.topics.set(['music', 'gaming', 'funny', 'sports']);
this.fetchForYou(this.topics());
},
});
}
/**
* Sujets "Pour toi" : hydratation (abos, groupes, historique, likes,
* téléchargements) puis extraction pondérée ; repli sur les sujets génériques
* si aucun signal. Partagé par le chargement et par le préchauffage.
*/
private topics$(): Observable<string[]> {
// Hydrate les abonnements/groupes (signaux) s'ils ne sont pas chargés.
try { if (this.subs.items().length === 0) this.subs.list().subscribe(); } catch {}
try { this.subs.listGroups().subscribe(); } catch {}
@@ -1423,23 +1528,14 @@ export class WatchShortComponent implements OnDestroy {
const watch$ = loggedIn ? this.history.getWatchHistory(30).pipe(catchError(() => of([]))) : of([]);
const likes$ = loggedIn ? this.likes.list(100).pipe(catchError(() => of([]))) : of([]);
const dl$ = loggedIn ? this.downloads.listJobs({ limit: 50 }).pipe(catchError(() => of({ items: [] }))) : of({ items: [] });
forkJoin([search$, watch$, likes$, dl$]).subscribe({
next: ([searches, watches, liked, jobs]: any[]) => {
const fallback = ['music', 'gaming', 'funny', 'sports'];
return forkJoin([search$, watch$, likes$, dl$]).pipe(
map(([searches, watches, liked, jobs]: any[]) => {
const topics = this.extractTopics(searches || [], watches || [], liked || [], (jobs as any)?.items || []);
this.topics.set(topics);
this.topicsLoading.set(false);
if (!topics.length) {
// Aucun signal : repli sur des sujets génériques populaires.
this.topics.set(['music', 'gaming', 'funny', 'sports']);
}
this.fetchForYou(this.topics());
},
error: () => {
this.topicsLoading.set(false);
this.topics.set(['music', 'gaming', 'funny', 'sports']);
this.fetchForYou(this.topics());
}
});
return topics.length ? topics : fallback;
}),
catchError(() => of(fallback)),
);
}
/** Extrait des sujets pondérés (abos ×3, recherches ×2, reste ×1). */
@@ -1522,13 +1618,14 @@ export class WatchShortComponent implements OnDestroy {
);
}
/** Cherche chaque sujet en Shorts sur tous les fournisseurs et entrelace. */
private fetchForYou(topics: string[]): void { const picks = topics.slice(0, 4);
if (!picks.length) {
this.afterNoResults();
this.loading.set(false);
return;
}
/**
* Cherche chaque sujet en Shorts sur tous les fournisseurs et entrelace.
* RETOURNE le lot sans écrire le moindre signal : le même tuyau sert le
* chargement « Pour toi » ET son préchauffage en arrière-plan.
*/
private forYouFeed$(topics: string[]): Observable<Video[]> {
const picks = topics.slice(0, 4);
if (!picks.length) return of([] as Video[]);
const defs: Array<{ provider: Provider; call: any }> = [];
const ready = (p: Provider) => {
try { return this.instances.getProviderReadiness(p).ready; } catch { return true; }
@@ -1545,13 +1642,9 @@ export class WatchShortComponent implements OnDestroy {
defs.push({ provider: 'twitch', call: this.api.searchTwitchClipsPage(t).pipe(catchError(() => of({ items: [], nextCursor: null }))) });
}
}
if (!defs.length) {
this.afterNoResults();
this.loading.set(false);
return;
}
forkJoin(defs.map(d => d.call.pipe(catchError(() => of({ items: [], nextCursor: null }))))).subscribe({
next: (pages: any[]) => {
if (!defs.length) return of([] as Video[]);
return forkJoin(defs.map(d => d.call.pipe(catchError(() => of({ items: [], nextCursor: null }))))).pipe(
map((pages: any[]) => {
// Regroupe par provider puis entrelace (tourniquet) pour mixer les sources.
const byProvider: Video[][] = defs.map((d, i) => {
const raw = ((pages[i]?.items || []) as Video[]).filter(v => !!v?.videoId);
@@ -1575,12 +1668,21 @@ export class WatchShortComponent implements OnDestroy {
if (!v) continue;
const key = `${String((v as any).provider || '')}:${String(v.videoId)}`;
if (this.seenIds.has(key)) continue;
this.seenIds.add(key);
merged.push(v);
added = true;
if (merged.length >= 80) break;
}
}
return merged;
}),
catchError(() => of([] as Video[])),
);
}
/** Le seul endroit qui écrit les signaux d'un lot « Pour toi ». */
private applyForYouFeed(merged: Video[]): void {
this.seenIds = new Set<string>();
for (const v of merged) this.seenIds.add(`${String((v as any).provider || '')}:${String(v.videoId)}`);
this.items.set(merged);
this.index.set(0);
this.nextCursor.set(null);
@@ -1588,11 +1690,13 @@ export class WatchShortComponent implements OnDestroy {
this.queueRumbleResolves(merged);
if (!merged.length) this.afterNoResults(); else this.error.set(null);
this.loading.set(false);
},
error: () => {
this.afterNoResults();
this.loading.set(false);
}
/** Chargement réseau du flux « Pour toi » (quand aucun lot n'est prêt). */
private fetchForYou(topics: string[]): void {
this.forYouFeed$(topics).subscribe({
next: (merged) => this.applyForYouFeed(merged),
error: () => { this.afterNoResults(); this.loading.set(false); },
});
}