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:
@@ -0,0 +1,72 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { dedupeSort } from './suggest.util';
|
||||
|
||||
export interface SuggestResponse {
|
||||
q: string;
|
||||
groups: Record<string, string[]>;
|
||||
}
|
||||
|
||||
interface SuggestCacheEntry {
|
||||
t: number;
|
||||
data: SuggestResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 15 — Query typeahead service.
|
||||
*
|
||||
* Contract: no network while `q.trim().length < 2`. Callers debounce
|
||||
* (250-300ms) + `switchMap` (in-flight cancellation). Responses are cached
|
||||
* 5 minutes, deduped and sorted per provider group.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SuggestService {
|
||||
private http = inject(HttpClient);
|
||||
private cache = new Map<string, SuggestCacheEntry>();
|
||||
private cacheTtlMs = 5 * 60 * 1000;
|
||||
|
||||
private apiBase(): string {
|
||||
try {
|
||||
const port = window?.location?.port || '';
|
||||
return port && port !== '4000' ? '/proxy/api' : '/api';
|
||||
} catch {
|
||||
return '/api';
|
||||
}
|
||||
}
|
||||
|
||||
/** Single suggest round-trip (no debounce here — the caller owns it). */
|
||||
suggestOnce(q: string, providers?: string[] | 'all', limit = 10): Observable<SuggestResponse> {
|
||||
const query = String(q ?? '').trim();
|
||||
if (query.length < 2) return of({ q: query, groups: {} });
|
||||
const prov = Array.isArray(providers) && providers.length > 0 ? [...providers].sort().join(',') : 'all';
|
||||
const key = `${prov}|${query.toLowerCase()}|${limit}`;
|
||||
const now = Date.now();
|
||||
const hit = this.cache.get(key);
|
||||
if (hit && now - hit.t < this.cacheTtlMs) return of(hit.data);
|
||||
|
||||
const params = new URLSearchParams({ q: query, limit: String(limit) });
|
||||
if (Array.isArray(providers) && providers.length > 0) params.set('providers', providers.join(','));
|
||||
return this.http.get<SuggestResponse>(`${this.apiBase()}/search/suggest?${params.toString()}`).pipe(
|
||||
tap((res) => {
|
||||
try {
|
||||
const groups: Record<string, string[]> = {};
|
||||
for (const [pid, list] of Object.entries(res?.groups || {})) {
|
||||
groups[pid] = dedupeSort(Array.isArray(list) ? list : [], query).slice(0, limit);
|
||||
}
|
||||
const clean: SuggestResponse = { q: res?.q ?? query, groups };
|
||||
if (this.cache.size > 200) this.cache.clear();
|
||||
this.cache.set(key, { t: Date.now(), data: clean });
|
||||
// Mutate in place so subscribers see the cleaned payload
|
||||
res.q = clean.q;
|
||||
res.groups = clean.groups;
|
||||
} catch {}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'zone.js/node';
|
||||
import '@angular/compiler'; // JIT needed: decorators are evaluated at import time under ts-node
|
||||
import { firstValueFrom, of } from 'rxjs';
|
||||
import { Injector, runInInjectionContext } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { SuggestService } from './suggest.service';
|
||||
import { dedupeSort, mergeGroups, highlightParts } from './suggest.util';
|
||||
|
||||
/**
|
||||
* Step 15 — Unit tests for query typeahead (parsing / dedup / min-length guard).
|
||||
*
|
||||
* Same harness as Step 11: ts-node isolated transpilation, manual injection
|
||||
* with a minimal HttpClient fake returning real Observables.
|
||||
* Run with: npm run test:suggest
|
||||
*/
|
||||
|
||||
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||
const a = JSON.stringify(actual);
|
||||
const b = JSON.stringify(expected);
|
||||
if (a !== b) {
|
||||
throw new Error(`${message} (expected ${b}, got ${a})`);
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function logOk(msg: string): void {
|
||||
console.log(`✓ ${msg}`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// --- Pure helpers: dedupe + sort ---
|
||||
assertEqual(
|
||||
dedupeSort(['Tutoriel Angular', 'tutoriel angular ', ' ', 'Tutoriel Android'], 'tutoriel an'),
|
||||
['Tutoriel Android', 'Tutoriel Angular'],
|
||||
'dedupeSort dedupes case-insensitively and sorts prefix-first then alpha',
|
||||
);
|
||||
logOk('dedupeSort dedup + tri');
|
||||
|
||||
assertEqual(dedupeSort(['b', 'a', 'c']), ['a', 'b', 'c'], 'dedupeSort sorts alphabetically without query');
|
||||
logOk('dedupeSort alpha fallback');
|
||||
|
||||
// --- Pure helpers: merge groups ---
|
||||
const merged = mergeGroups({ dm: ['Tutoriel android'], yt: ['Tutoriel Angular', 'tutoriel android'], xx: ['zz'] });
|
||||
assertEqual(
|
||||
merged,
|
||||
[
|
||||
{ text: 'Tutoriel Angular', provider: 'yt' },
|
||||
{ text: 'tutoriel android', provider: 'yt' },
|
||||
{ text: 'zz', provider: 'xx' },
|
||||
],
|
||||
'mergeGroups respects registry order, dedupes across providers, unknowns last',
|
||||
);
|
||||
logOk('mergeGroups order + dedup');
|
||||
|
||||
// --- Pure helpers: highlight ---
|
||||
assertEqual(
|
||||
highlightParts('Tutoriel Angular', 'toriel an'),
|
||||
{ pre: 'Tu', match: 'toriel An', post: 'gular' },
|
||||
'highlightParts splits on first case-insensitive occurrence',
|
||||
);
|
||||
assertEqual(
|
||||
highlightParts('Bonjour', 'zzz'),
|
||||
{ pre: 'Bonjour', match: '', post: '' },
|
||||
'highlightParts without occurrence returns plain text',
|
||||
);
|
||||
logOk('highlightParts');
|
||||
|
||||
// --- Service: min length guard (no network below 2 chars) ---
|
||||
let httpCalls = 0;
|
||||
const httpFake = {
|
||||
get: (url: string) => {
|
||||
httpCalls += 1;
|
||||
assert(url.includes('/search/suggest'), `suggest hits /api/search/suggest (got ${url})`);
|
||||
return of({ q: 'angular', groups: { yt: ['angular tutorial', 'Angular'], dm: ['angular tutorial'] } });
|
||||
},
|
||||
};
|
||||
const injector = Injector.create([{ provide: HttpClient, useValue: httpFake }]);
|
||||
const svc: SuggestService = runInInjectionContext(injector, () => new SuggestService());
|
||||
|
||||
const short = await firstValueFrom(svc.suggestOnce('a'));
|
||||
assertEqual(short, { q: 'a', groups: {} }, 'q < 2 chars returns empty without network');
|
||||
assertEqual(httpCalls, 0, 'no HTTP call for short queries');
|
||||
logOk('suggestOnce min-length guard (no network)');
|
||||
|
||||
// --- Service: dedup + cache ---
|
||||
const first = await firstValueFrom(svc.suggestOnce('angular', ['yt', 'dm']));
|
||||
assertEqual(httpCalls, 1, 'one HTTP call for a fresh query');
|
||||
assertEqual(first.groups['yt'], ['Angular', 'angular tutorial'], 'per-provider groups deduped + sorted');
|
||||
const second = await firstValueFrom(svc.suggestOnce('angular', ['dm', 'yt']));
|
||||
assertEqual(httpCalls, 1, 'second identical query served from cache (provider order-insensitive)');
|
||||
assert(second === first || JSON.stringify(second) === JSON.stringify(first), 'cached payload matches');
|
||||
logOk('suggestOnce dedup + cache 5 min');
|
||||
|
||||
console.log('\nAll suggest tests passed.');
|
||||
})().catch((e) => {
|
||||
console.error(e instanceof Error ? e.stack : String(e));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Step 15 — Pure helpers for query typeahead (testable without Angular).
|
||||
*/
|
||||
|
||||
/** Normalize a suggestion string for dedup comparisons. */
|
||||
export function normSuggest(s: string): string {
|
||||
return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate (case-insensitive, first occurrence wins) then sort:
|
||||
* items starting with `q` come first, alphabetical within each group.
|
||||
*/
|
||||
export function dedupeSort(items: string[], q = ''): string[] {
|
||||
const seen = new Set<string>();
|
||||
const clean: string[] = [];
|
||||
for (const raw of items || []) {
|
||||
const t = String(raw ?? '').trim();
|
||||
if (!t) continue;
|
||||
const k = normSuggest(t);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
clean.push(t);
|
||||
}
|
||||
const nq = normSuggest(q);
|
||||
clean.sort((a, b) => {
|
||||
const na = normSuggest(a);
|
||||
const nb = normSuggest(b);
|
||||
const pa = nq ? (na.startsWith(nq) ? 0 : 1) : 0;
|
||||
const pb = nq ? (nb.startsWith(nq) ? 0 : 1) : 0;
|
||||
if (pa !== pb) return pa - pb;
|
||||
return na < nb ? -1 : na > nb ? 1 : 0;
|
||||
});
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten provider groups into a single ordered list (registry order),
|
||||
* deduped case-insensitively. Unknown provider keys go last (insertion order).
|
||||
*/
|
||||
export function mergeGroups(
|
||||
groups: Record<string, string[]> | null | undefined,
|
||||
order: string[] = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'],
|
||||
): Array<{ text: string; provider: string }> {
|
||||
const out: Array<{ text: string; provider: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (pid: string, list: string[] | undefined) => {
|
||||
for (const raw of list || []) {
|
||||
const t = String(raw ?? '').trim();
|
||||
if (!t) continue;
|
||||
const k = normSuggest(t);
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push({ text: t, provider: pid });
|
||||
}
|
||||
};
|
||||
for (const pid of order) push(pid, groups?.[pid]);
|
||||
for (const pid of Object.keys(groups || {})) {
|
||||
if (!order.includes(pid)) push(pid, (groups as Record<string, string[]>)[pid]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Split `text` on the first case-insensitive occurrence of `q` for <mark> highlighting. */
|
||||
export function highlightParts(text: string, q: string): { pre: string; match: string; post: string } {
|
||||
const t = String(text ?? '');
|
||||
const needle = String(q ?? '').trim();
|
||||
if (!needle) return { pre: t, match: '', post: '' };
|
||||
const idx = t.toLowerCase().indexOf(needle.toLowerCase());
|
||||
if (idx === -1) return { pre: t, match: '', post: '' };
|
||||
return { pre: t.slice(0, idx), match: t.slice(idx, idx + needle.length), post: t.slice(idx + needle.length) };
|
||||
}
|
||||
@@ -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