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
+72
View File
@@ -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();
}
}
+103
View File
@@ -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);
});
+72
View File
@@ -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) };
}
+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();
}
}