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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user