feat(shorts): préchauffage en arrière-plan du flux « Pour toi »
CI / build-and-test (push) Successful in 15m4s
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:
@@ -3,8 +3,8 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
import { firstValueFrom, forkJoin, from, map, of } from 'rxjs';
|
import { firstValueFrom, forkJoin, from, map, of, Observable } from 'rxjs';
|
||||||
import { catchError, switchMap } from 'rxjs/operators';
|
import { catchError, shareReplay, switchMap } from 'rxjs/operators';
|
||||||
import { YoutubeApiService } from '../../services/youtube-api.service';
|
import { YoutubeApiService } from '../../services/youtube-api.service';
|
||||||
import { InstanceService, Provider } from '../../services/instance.service';
|
import { InstanceService, Provider } from '../../services/instance.service';
|
||||||
import { HistoryService } from '../../services/history.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). */
|
/** Clé de persistance de la lecture auto (activée par défaut). */
|
||||||
const LS_SHORTS_AUTONEXT_KEY = 'newtube.shorts.autoNext';
|
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 };
|
type ShortsGlyph = { kind: 'play' } | { kind: 'chat' } | { kind: 'letter'; letter: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,6 +131,12 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
forYou = signal(false);
|
forYou = signal(false);
|
||||||
topics = signal<string[]>([]);
|
topics = signal<string[]>([]);
|
||||||
topicsLoading = signal(false);
|
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 ----
|
// ---- Expérience façon YouTube Shorts ----
|
||||||
/** Cadre 9:16 (plein écran + focus). */
|
/** Cadre 9:16 (plein écran + focus). */
|
||||||
@@ -724,6 +737,23 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
this.fetchNextPage(false);
|
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.
|
// Hydrate abonnements (état du bouton S'abonner) une fois.
|
||||||
try {
|
try {
|
||||||
if (this.subs.items().length === 0) this.subs.list().subscribe();
|
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 {}
|
try { if (this.passthroughTimer) clearTimeout(this.passthroughTimer); } catch {}
|
||||||
this.passthroughTimer = null;
|
this.passthroughTimer = null;
|
||||||
if (this.shareCopiedTimer) { try { clearTimeout(this.shareCopiedTimer); } catch {} }
|
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;
|
return;
|
||||||
}
|
}
|
||||||
this.forYou.set(true);
|
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();
|
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 {
|
private loadForYou(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
@@ -1415,6 +1500,26 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
this.nextCursor.set(null);
|
this.nextCursor.set(null);
|
||||||
this.seenIds = new Set<string>();
|
this.seenIds = new Set<string>();
|
||||||
this.topicsLoading.set(true);
|
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.
|
// 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 { if (this.subs.items().length === 0) this.subs.list().subscribe(); } catch {}
|
||||||
try { this.subs.listGroups().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 watch$ = loggedIn ? this.history.getWatchHistory(30).pipe(catchError(() => of([]))) : of([]);
|
||||||
const likes$ = loggedIn ? this.likes.list(100).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: [] });
|
const dl$ = loggedIn ? this.downloads.listJobs({ limit: 50 }).pipe(catchError(() => of({ items: [] }))) : of({ items: [] });
|
||||||
forkJoin([search$, watch$, likes$, dl$]).subscribe({
|
const fallback = ['music', 'gaming', 'funny', 'sports'];
|
||||||
next: ([searches, watches, liked, jobs]: any[]) => {
|
return forkJoin([search$, watch$, likes$, dl$]).pipe(
|
||||||
|
map(([searches, watches, liked, jobs]: any[]) => {
|
||||||
const topics = this.extractTopics(searches || [], watches || [], liked || [], (jobs as any)?.items || []);
|
const topics = this.extractTopics(searches || [], watches || [], liked || [], (jobs as any)?.items || []);
|
||||||
this.topics.set(topics);
|
return topics.length ? topics : fallback;
|
||||||
this.topicsLoading.set(false);
|
}),
|
||||||
if (!topics.length) {
|
catchError(() => of(fallback)),
|
||||||
// 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());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extrait des sujets pondérés (abos ×3, recherches ×2, reste ×1). */
|
/** 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);
|
* Cherche chaque sujet en Shorts sur tous les fournisseurs et entrelace.
|
||||||
if (!picks.length) {
|
* RETOURNE le lot sans écrire le moindre signal : le même tuyau sert le
|
||||||
this.afterNoResults();
|
* chargement « Pour toi » ET son préchauffage en arrière-plan.
|
||||||
this.loading.set(false);
|
*/
|
||||||
return;
|
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 defs: Array<{ provider: Provider; call: any }> = [];
|
||||||
const ready = (p: Provider) => {
|
const ready = (p: Provider) => {
|
||||||
try { return this.instances.getProviderReadiness(p).ready; } catch { return true; }
|
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 }))) });
|
defs.push({ provider: 'twitch', call: this.api.searchTwitchClipsPage(t).pipe(catchError(() => of({ items: [], nextCursor: null }))) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!defs.length) {
|
if (!defs.length) return of([] as Video[]);
|
||||||
this.afterNoResults();
|
return forkJoin(defs.map(d => d.call.pipe(catchError(() => of({ items: [], nextCursor: null }))))).pipe(
|
||||||
this.loading.set(false);
|
map((pages: any[]) => {
|
||||||
return;
|
|
||||||
}
|
|
||||||
forkJoin(defs.map(d => d.call.pipe(catchError(() => of({ items: [], nextCursor: null }))))).subscribe({
|
|
||||||
next: (pages: any[]) => {
|
|
||||||
// Regroupe par provider puis entrelace (tourniquet) pour mixer les sources.
|
// Regroupe par provider puis entrelace (tourniquet) pour mixer les sources.
|
||||||
const byProvider: Video[][] = defs.map((d, i) => {
|
const byProvider: Video[][] = defs.map((d, i) => {
|
||||||
const raw = ((pages[i]?.items || []) as Video[]).filter(v => !!v?.videoId);
|
const raw = ((pages[i]?.items || []) as Video[]).filter(v => !!v?.videoId);
|
||||||
@@ -1575,24 +1668,35 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
if (!v) continue;
|
if (!v) continue;
|
||||||
const key = `${String((v as any).provider || '')}:${String(v.videoId)}`;
|
const key = `${String((v as any).provider || '')}:${String(v.videoId)}`;
|
||||||
if (this.seenIds.has(key)) continue;
|
if (this.seenIds.has(key)) continue;
|
||||||
this.seenIds.add(key);
|
|
||||||
merged.push(v);
|
merged.push(v);
|
||||||
added = true;
|
added = true;
|
||||||
if (merged.length >= 80) break;
|
if (merged.length >= 80) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.items.set(merged);
|
return merged;
|
||||||
this.index.set(0);
|
}),
|
||||||
this.nextCursor.set(null);
|
catchError(() => of([] as Video[])),
|
||||||
// Pré-validation Rumble (retire les "Video not found").
|
);
|
||||||
this.queueRumbleResolves(merged);
|
}
|
||||||
if (!merged.length) this.afterNoResults(); else this.error.set(null);
|
|
||||||
this.loading.set(false);
|
/** Le seul endroit qui écrit les signaux d'un lot « Pour toi ». */
|
||||||
},
|
private applyForYouFeed(merged: Video[]): void {
|
||||||
error: () => {
|
this.seenIds = new Set<string>();
|
||||||
this.afterNoResults();
|
for (const v of merged) this.seenIds.add(`${String((v as any).provider || '')}:${String(v.videoId)}`);
|
||||||
this.loading.set(false);
|
this.items.set(merged);
|
||||||
}
|
this.index.set(0);
|
||||||
|
this.nextCursor.set(null);
|
||||||
|
// Pré-validation Rumble (retire les "Video not found").
|
||||||
|
this.queueRumbleResolves(merged);
|
||||||
|
if (!merged.length) this.afterNoResults(); else this.error.set(null);
|
||||||
|
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); },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user