NewTube/src/app/search/search.service.ts

119 lines
5.5 KiB
TypeScript

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, combineLatest, distinctUntilChanged, debounceTime, map, switchMap, filter, shareReplay, from, of } from 'rxjs';
import type { ProviderId } from '../core/providers/provider-registry';
import type { SuggestionItemV1, SearchResponseV1 } from './api.v1';
import { YtAdapter } from './adapters/yt';
import { DmAdapter } from './adapters/dm';
import { TwAdapter } from './adapters/tw';
import { PtAdapter } from './adapters/pt';
import { OdAdapter } from './adapters/od';
import { RuAdapter } from './adapters/ru';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from './models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export type SuggestionItem = SuggestionItemV1;
export type SearchResponse = SearchResponseV1;
type CacheKey = string;
interface CacheEntry<T> { t: number; data: SearchResult<T>; }
@Injectable({ providedIn: 'root' })
export class SearchService {
private http = inject(HttpClient);
// Subjects for params from UI/URL
readonly q$ = new BehaviorSubject<string>('');
readonly providers$ = new BehaviorSubject<ProviderId[] | 'all'>('all');
readonly page$ = new BehaviorSubject<number>(1);
readonly pageSize$ = new BehaviorSubject<number>(24);
readonly sort$ = new BehaviorSubject<'relevance' | 'date' | 'views' | 'duration'>('relevance');
// In-memory cache 60s per (provider, q, params)
private cache = new Map<CacheKey, CacheEntry<VideoItem>>();
private cacheTtlMs = 60_000;
// Adapters registry
private adapters: Record<ProviderId, ProviderAdapter<VideoItem>> = {
yt: new YtAdapter(this.http),
dm: new DmAdapter(this.http),
tw: new TwAdapter(this.http),
pt: new PtAdapter(this.http),
od: new OdAdapter(this.http),
ru: new RuAdapter(this.http)
};
readonly params$ = combineLatest([this.q$, this.providers$, this.page$, this.pageSize$, this.sort$]).pipe(
debounceTime(120),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
);
// Public request stream: aggregates groups by provider, runs adapters in parallel with timeout/abort/retry
readonly request$ = this.params$.pipe(
filter(([q]) => typeof q === 'string' && q.trim().length >= 2),
switchMap(([q, prov, page, pageSize, sort]) => from(this.runAdapters({
q: String(q),
pageToken: String(page || 1),
sort: (sort as any) || 'relevance',
// global filter knobs could be added here: time/length/type in the future
}, prov)))
,
// Map provider results to the format expected by the UI
map((byProvider) => {
const groups = Object.entries(byProvider).reduce((acc, [pid, result]) => {
acc[pid as ProviderId] = result.items;
return acc;
}, {} as Record<ProviderId, VideoItem[]>);
const providers = Object.keys(byProvider) as ProviderId[];
const resp: SearchResponse = { q: this.q$.value, providers, groups };
return resp;
}),
shareReplay(1)
);
// Orchestrate all active providers in parallel with timeout(8s), retry(1) and abort on param change.
private async runAdapters(params: ProviderSearchParams, prov: ProviderId[] | 'all'): Promise<Record<ProviderId, SearchResult<VideoItem>>> {
const active: ProviderId[] = prov === 'all' ? ['yt','dm','tw','pt','od','ru'] : (Array.isArray(prov) ? prov : []);
const controller = new AbortController();
const signal = controller.signal;
const tasks = active.map(async (pid) => {
const adapter = this.adapters[pid];
if (!adapter) return [pid, { items: [] } as SearchResult<VideoItem>] as const;
const key: CacheKey = `${pid}|${params.q}|${params.pageToken}|${params.sort}`;
const now = Date.now();
const cached = this.cache.get(key);
if (cached && (now - cached.t) < this.cacheTtlMs) {
return [pid, cached.data] as const;
}
const perProviderTimeout = 8000;
const withTimeout = <T>(p: Promise<T>): Promise<T> => new Promise((resolve, reject) => {
const tid = setTimeout(() => reject(new Error('timeout')), perProviderTimeout);
p.then(v => { clearTimeout(tid); resolve(v); }).catch(e => { clearTimeout(tid); reject(e); });
});
const attempt = async (): Promise<SearchResult<VideoItem>> => withTimeout(adapter.search(params, signal));
try {
const res: SearchResult<VideoItem> = await attempt().catch(async (e) => {
// retry once on network-like errors
if (e && (e.name === 'AbortError' || String(e.message || '').includes('abort'))) throw e;
try { return await attempt(); } catch (err) { throw err; }
});
this.cache.set(key, { t: Date.now(), data: res });
return [pid, res] as const;
} catch {
// Swallow errors per provider, return empty
return [pid, { items: [] } as SearchResult<VideoItem>] as const;
}
});
const results = await Promise.all(tasks);
return Object.fromEntries(results) as Record<ProviderId, SearchResult<VideoItem>>;
}
// Convenience setter helpers
setQuery(q: string) { this.q$.next(q || ''); }
setProviders(list: ProviderId[] | 'all') { this.providers$.next(list && (Array.isArray(list) ? list : 'all')); }
setPage(page: number) { this.page$.next(Math.max(1, Math.floor(page || 1))); }
setPageSize(size: number) { this.pageSize$.next(Math.min(50, Math.max(1, Math.floor(size || 24)))); }
setSort(sort: 'relevance' | 'date' | 'views' | 'duration') { this.sort$.next(sort || 'relevance'); }
}