feat(youtube): InnerTube-first search, transcripts and watch-next related (Steps 15-18)

- InnerTube layer via pinned youtubei.js 18.1.0 (no quota, no key):
  search with merged continuations (unlimited pages), watch-next
  related with LockupView mapping, caption-track discovery
- 3-layer dispatcher (YT_SEARCH_MODE, default innertube-first):
  innertube -> yt-dlp scrape -> official API, graceful errors.yt
- Robust yt-dlp binary resolution (YT_DLP_PATH > PATH > bundled)
  with systematic API fallback (fixes spawn ENOENT in UI)
- Transcript: InnerTube caption discovery (YT_TRANSCRIPT_SOURCE),
  reusing pickTrack/orderedTracks/parseTrackText; yt-dlp fallback kept
- Watch: sidebar uses real watch-next related[] (/api/details),
  title-search fallback for other providers
- Cache: memory LRU + SQLite (youtube_search_cache, youtube_metrics),
  never persist empty pages; /healthz observability; /api/trending
- Includes pending Step 15/16 leftovers in same files (suggest,
  test scripts); unrelated provider adapters left uncommitted
This commit is contained in:
2026-09-25 19:38:46 -04:00
parent 300e5c45b1
commit 4bdcd393fe
25 changed files with 4112 additions and 168 deletions
+276 -14
View File
@@ -23,6 +23,7 @@ import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
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 { UserService } from '../../services/user.service';
@Component({
selector: 'app-watch',
@@ -44,6 +45,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
private auth = inject(AuthService);
private http = inject(HttpClient);
private iframeProgress = inject(IframeProgressService);
private users = inject(UserService);
private channels = inject(ChannelsService);
private subs = inject(SubscriptionsService);
private routeSubscription!: Subscription;
@@ -197,6 +199,8 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
// Build a direct Twitch URL as a fallback open-in-new-tab action
twitchOpenUrl(): string | null {
if (this.provider() !== 'twitch') return null;
const clip = this.twitchClip();
if (clip) return `https://clips.twitch.tv/${encodeURIComponent(clip)}`;
const ch = this.twitchChannel();
const id = this.videoId();
const cur = this.video();
@@ -286,6 +290,20 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
liked = signal<boolean>(false);
likeBusy = signal<boolean>(false);
// --- Transcript state (Step 16, Phase 1: display + language selector, no seek) ---
transcriptOpen = signal(false);
transcriptLoading = signal(false);
transcriptAvailable = signal(false);
transcriptError = signal<string | null>(null);
// Machine-readable unavailability reason from the API: 'no_subtitles' |
// 'provider_unsupported' | 'temporarily_unavailable' | null.
transcriptReason = signal<string | null>(null);
// True when retrying may succeed (transient YouTube rate-limit/fetch failure).
transcriptRetryable = signal(false);
transcriptLanguages = signal<string[]>([]);
transcriptLines = signal<Array<{ t: number; dur: number; text: string }>>([]);
selectedTranscriptLang = signal('fr');
// --- Watch history tracking for native player ---
watchHistoryId = signal<string | null>(null);
startPositionSeconds = signal<number | null>(null);
@@ -296,6 +314,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
private providerSel = signal<string>('');
provider = computed(() => this.providerSel() || this.instances.selectedProvider());
private twitchChannel = signal<string | null>(null);
private twitchClip = signal<string | null>(null);
// Backend-provided embed URL for providers that need it (e.g., Rumble)
private rumbleEmbed = signal<string | null>(null);
@@ -303,9 +322,10 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
const id = this.videoId();
const p = this.provider();
const ch = this.twitchChannel();
const clip = this.twitchClip();
const slug = this.odyseeSlug();
const start = Math.max(0, this.startPositionSeconds() || 0);
if (!id && !(p === 'twitch' && ch) && !(p === 'odysee' && slug)) return null;
if (!id && !(p === 'twitch' && (ch || clip)) && !(p === 'odysee' && slug)) return null;
try {
const host = (location && location.hostname) ? location.hostname : 'localhost';
if (p === 'youtube') {
@@ -324,9 +344,15 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
if (p === 'twitch') {
// Channel or VOD; Twitch requires one or more 'parent' params that match the embedding domain
// Channel, clip ou VOD ; Twitch exige 'parent' = domaine d'embed.
const parentsSet = new Set<string>([host, 'localhost', '127.0.0.1']);
const parentParams = Array.from(parentsSet).map(h => `parent=${encodeURIComponent(h)}`).join('&');
// Clips : player dédié clips.twitch.tv (le player VOD rejette les slugs).
const clipId = clip || (/^\d+$/.test(String(id || '')) ? null : ((this.video() as any)?.kind === 'clip' ? id : null));
if (clipId) {
const u = `https://clips.twitch.tv/embed?clip=${encodeURIComponent(clipId)}&${parentParams}&autoplay=false`;
return this.sanitizer.bypassSecurityTrustResourceUrl(u);
}
const base = 'https://player.twitch.tv/';
// ?video= exige un id de VOD numérique ; un login (non numérique) sans
// ?channel= doit être traité comme une chaîne (résultats de recherche).
@@ -461,11 +487,13 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
const slug = this.route.snapshot.queryParamMap.get('slug');
const p = (this.route.snapshot.queryParamMap.get('p') || providerFromPath) as Provider | null;
const ch = this.route.snapshot.queryParamMap.get('channel');
const clip = this.route.snapshot.queryParamMap.get('clip');
if (id) {
this.videoId.set(id);
this.odyseeSlug.set(slug);
this.providerSel.set(p || '');
this.twitchChannel.set(ch);
this.twitchClip.set(clip);
// Choisir un provider final (paramètre présent, segment d'URL, ou fallback depuis état courant)
let finalProvider = p as Provider | null;
@@ -583,6 +611,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
this.summaryError.set(null);
this.selectedQuality.set(null);
this.resetDownloadUi();
this.resetTranscriptUi();
// Reset like state while loading
this.liked.set(false);
this.likeBusy.set(false);
@@ -861,6 +890,189 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
this.selectedQuality.set(value || null);
}
// --- Transcript logic (Step 16) ---
private resetTranscriptUi() {
this.transcriptOpen.set(false);
this.transcriptLoading.set(false);
this.transcriptAvailable.set(false);
this.transcriptError.set(null);
this.transcriptReason.set(null);
this.transcriptRetryable.set(false);
this.transcriptLanguages.set([]);
this.transcriptLines.set([]);
try {
const prefLang = String(this.users.preferences()?.language || '').trim().toLowerCase().slice(0, 12);
this.selectedTranscriptLang.set(prefLang || 'fr');
} catch {
this.selectedTranscriptLang.set('fr');
}
}
toggleTranscript() {
const next = !this.transcriptOpen();
this.transcriptOpen.set(next);
if (next && this.transcriptLines().length === 0 && !this.transcriptLoading()) {
this.loadTranscript();
}
}
onTranscriptLangChange(value: string) {
const lang = String(value || '').trim().slice(0, 12) || 'fr';
this.selectedTranscriptLang.set(lang);
this.loadTranscript();
}
/** Map a backend transcript state (reason/error/retryable) to the UI:
* user-facing message + whether a "Réessayer" button makes sense. */
private transcriptStatusFor(data: any): { message: string | null; retryable: boolean; reason: string | null } {
const reason = typeof data?.reason === 'string' ? data.reason : null;
const code = typeof data?.error === 'string' ? data.error : '';
const retryable = data?.retryable === true
|| code === 'transcript_temporarily_unavailable'
|| code === 'rate_limited';
if (reason === 'provider_unsupported') {
return {
message: 'Les transcripts ne sont pas pris en charge pour ce fournisseur (aucun sous-titre exposé).',
retryable: false,
reason,
};
}
if (reason === 'temporarily_unavailable' || code === 'transcript_temporarily_unavailable') {
return {
message: 'Sous-titres temporairement indisponibles (limite YouTube). Réessayez dans quelques instants.',
retryable: true,
reason: reason || 'temporarily_unavailable',
};
}
if (reason === 'no_subtitles') {
return { message: null, retryable: false, reason };
}
// Legacy / unknown error code: sanitize, never leak HTML or parse noise.
const clean = this.transcriptDisplayError(code);
if (clean) return { message: `Transcript indisponible. (${clean})`, retryable, reason };
if (retryable) {
return {
message: 'Sous-titres temporairement indisponibles. Réessayez dans quelques instants.',
retryable: true,
reason: reason || 'temporarily_unavailable',
};
}
return { message: null, retryable: false, reason };
}
/** Sanitize a backend error code for display: never leak HTML pages or raw
* JSON-parse noise (e.g. proxy / CDN "<!DOCTYPE..." bodies surfaced as
* "SyntaxError: Unexpected token '<'..."). Returns null when nothing
* user-friendly can be shown (the "no subtitles" notice already covers it). */
private transcriptDisplayError(raw: unknown): string | null {
let msg = '';
try {
if (typeof raw === 'string') msg = raw;
else if (raw instanceof Error) msg = raw.message || '';
else if (raw != null) msg = String(raw);
} catch {
return null;
}
msg = msg.trim().slice(0, 160);
if (!msg) return null;
const lowered = msg.toLowerCase();
// Ignore internal codes handled silently + any HTML / JSON-parse noise.
if (
msg === 'transcript_fetch_failed' ||
msg === 'transcript_temporarily_unavailable' ||
msg === 'rate_limited' ||
lowered.includes('<!doctype') ||
lowered.includes('<html') ||
msg.includes('<') ||
lowered.includes('not valid json') ||
lowered.includes('unexpected token') ||
lowered.startsWith('syntaxerror')
) {
return null;
}
return msg;
}
retryTranscript() {
if (this.transcriptLoading()) return;
this.transcriptLines.set([]);
this.loadTranscript();
}
loadTranscript() {
const p = this.provider();
const id = this.videoId();
if (!p || !id) {
this.transcriptError.set('Vidéo introuvable.');
this.transcriptReason.set(null);
this.transcriptRetryable.set(false);
return;
}
// Never break the Watch page: every failure degrades to the "unavailable" notice.
try {
this.transcriptLoading.set(true);
this.transcriptError.set(null);
this.transcriptReason.set(null);
this.transcriptRetryable.set(false);
const opts = this.buildProviderOpts() as Record<string, string>;
const params = new URLSearchParams({ lang: this.selectedTranscriptLang() });
if (opts['instance']) params.set('instance', opts['instance']);
if (opts['slug']) params.set('slug', opts['slug']);
const url = `${this.apiBase()}/transcript/${encodeURIComponent(p)}/${encodeURIComponent(id)}?${params.toString()}`;
this.http.get<any>(url).subscribe({
next: (data) => {
this.transcriptLoading.set(false);
this.transcriptAvailable.set(!!data?.available);
this.transcriptLanguages.set(Array.isArray(data?.languages) ? data.languages : []);
this.transcriptLines.set(Array.isArray(data?.lines) ? data.lines : []);
if (data?.available && typeof data?.lang === 'string' && data.lang) {
this.selectedTranscriptLang.set(data.lang);
}
if (!data?.available) {
const st = this.transcriptStatusFor(data);
this.transcriptReason.set(st.reason);
this.transcriptRetryable.set(st.retryable);
this.transcriptError.set(st.message);
} else {
this.transcriptReason.set(null);
this.transcriptRetryable.set(false);
this.transcriptError.set(null);
}
},
error: (err) => {
this.transcriptLoading.set(false);
this.transcriptAvailable.set(false);
this.transcriptLines.set([]);
const body = err?.error;
if (body && typeof body === 'object' && Array.isArray(body.languages)) {
this.transcriptLanguages.set(body.languages);
}
const raw =
(body && typeof body === 'object' && (body.error || body.details || body.reason)) ||
(typeof body === 'string' ? body : '') ||
err?.message ||
'';
const status = err?.status;
if (status === 429 || String(raw || '').trim() === 'rate_limited') {
this.transcriptReason.set('temporarily_unavailable');
this.transcriptRetryable.set(true);
this.transcriptError.set('Trop de requêtes. Réessayez dans une minute.');
return;
}
const st = this.transcriptStatusFor(
body && typeof body === 'object' ? body : { error: raw },
);
this.transcriptReason.set(st.reason);
this.transcriptRetryable.set(st.retryable);
this.transcriptError.set(st.message);
},
});
} catch {
this.transcriptLoading.set(false);
this.transcriptAvailable.set(false);
}
}
// --- Download logic ---
openDownloadPanel() {
this.downloadOpen.set(true);
@@ -1053,6 +1265,10 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
if (slug.startsWith('/')) slug = slug.slice(1);
qp.slug = slug;
}
if (p === 'twitch' && String((v as any).kind || '').toLowerCase() === 'clip') {
qp.clip = v.videoId;
return qp;
}
if (p === 'twitch' && (v.type === 'channel' || (v as any).type === 'live')) {
// Prefer explicit videoId as channel login; fallback to parsing URL
if (v.videoId && !/^\d+$/.test(String(v.videoId))) {
@@ -1072,18 +1288,64 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
private loadRelatedSuggestions(): void {
const cur = this.video();
if (!cur) return;
const title = (cur.title || '').trim();
const maxItems = 12;
if (title) {
this.apiService.searchVideosPage(title).subscribe(res => {
const items = (res.items || []).filter(v => v.videoId !== cur.videoId).slice(0, maxItems);
this.video.update(v => v ? { ...v, relatedStreams: items } : v);
});
} else {
this.apiService.getTrendingPage().subscribe(res => {
const items = (res.items || []).filter(v => v.videoId !== cur.videoId).slice(0, maxItems);
this.video.update(v => v ? { ...v, relatedStreams: items } : v);
});
}
const apply = (items: any[]) => {
// Ignore les réponses arrivées après une navigation (race).
try {
if ((this.videoId() || '') !== (cur.videoId || '')) return;
} catch {}
const list = (items || []).filter((v: any) => (v?.videoId || v?.id) !== cur.videoId).slice(0, maxItems);
this.video.update(v => v ? { ...v, relatedStreams: list } : v);
};
const titleFallback = () => {
const title = (cur.title || '').trim();
if (title) {
this.apiService.searchVideosPage(title).subscribe(res => {
apply(res.items || []);
});
} else {
this.apiService.getTrendingPage().subscribe(res => {
apply(res.items || []);
});
}
};
// YouTube : vrai watch-next InnerTube via /api/details (0 quota, 0 recherche
// supplémentaire) — façon SmartTube. Autres providers : fallback existant.
try {
if (this.provider() === 'youtube' && cur.videoId) {
const id = cur.videoId;
this.http.get<any>(`/api/details/youtube/${encodeURIComponent(id)}`).subscribe({
next: (data: any) => {
const rel = Array.isArray(data?.related) ? data.related : [];
if (rel.length > 0) {
apply(rel.map((it: any) => ({
url: it.url || `https://www.youtube.com/watch?v=${it.id}`,
type: 'video',
title: it.title || '',
thumbnail: it.thumbnail || '',
uploaderName: it.uploaderName || '',
uploaderAvatar: '',
channelId: it.channelId || undefined,
channelHandle: it.channelHandle || undefined,
channelExternalId: it.channelExternalId || it.channelId || undefined,
uploadedDate: it.publishedAt || '',
duration: typeof it.duration === 'number' ? it.duration : 0,
views: typeof it.views === 'number' ? it.views : 0,
uploaded: 0,
videoId: String(it.id || ''),
provider: 'youtube',
isShort: it.isShort === true ? true : undefined,
isLive: it.isLive === true ? true : undefined,
} as Video)));
} else {
titleFallback();
}
},
error: () => titleFallback(),
});
return;
}
} catch {}
titleFallback();
}
}