feat: update Angular cache with latest TypeScript definitions

This commit is contained in:
2025-09-22 22:17:38 -04:00
parent d78afda4cd
commit d372b7d509
27 changed files with 512 additions and 646 deletions
+15 -13
View File
@@ -1,25 +1,27 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export class DmAdapter implements ProviderAdapter {
// TODO: Make SearchResult generic
export class DmAdapter implements ProviderAdapter<VideoItem> {
key = 'dm' as const;
label = 'Dailymotion';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'dm' } }));
const items = (res?.groups?.dm || []).map((it: any) => ({
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'dm');
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const items: VideoItem[] = (res?.groups?.dm || []).map((it: any) => ({
id: it.id,
provider: this.key,
provider: 'dailymotion',
title: it.title,
channel: it.uploaderName,
channelName: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: it.type === 'live',
isShort: it.isShort === true
thumbnailUrl: it.thumbnail,
viewCount: undefined, // Dailymotion API does not provide view count in search results
publishedAt: undefined, // Dailymotion API does not provide published date in search results
}));
return { items, total: Array.isArray(res?.groups?.dm) ? res.groups.dm.length : 0 };
}
+28 -17
View File
@@ -1,26 +1,37 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export class OdAdapter implements ProviderAdapter {
export class OdAdapter implements ProviderAdapter<VideoItem> {
key = 'od' as const;
label = 'Odysee';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'od' } }));
const items = (res?.groups?.od || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: false,
isShort: it.isShort === true
}));
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'od');
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const items: VideoItem[] = (res?.groups?.od || []).map((it: any) => {
let slug: string | undefined;
try {
const url: string | undefined = it?.url;
if (url && /^https?:\/\/odysee\.com\//i.test(url)) {
slug = url.replace(/^https?:\/\/odysee\.com\//i, '').replace(/^\//, '');
}
} catch {}
return {
id: it.id,
provider: 'odysee',
title: it.title,
channelName: it.uploaderName,
durationSec: it.duration,
thumbnailUrl: it.thumbnail,
viewCount: undefined,
publishedAt: undefined,
slug,
} as VideoItem;
});
return { items, total: Array.isArray(res?.groups?.od) ? res.groups.od.length : 0 };
}
}
+13 -12
View File
@@ -1,25 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export class PtAdapter implements ProviderAdapter {
export class PtAdapter implements ProviderAdapter<VideoItem> {
key = 'pt' as const;
label = 'PeerTube';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'pt' } }));
const items = (res?.groups?.pt || []).map((it: any) => ({
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'pt');
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const items: VideoItem[] = (res?.groups?.pt || []).map((it: any) => ({
id: it.id,
provider: this.key,
provider: 'peertube',
title: it.title,
channel: it.uploaderName,
channelName: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
thumbnailUrl: it.thumbnail,
viewCount: undefined,
publishedAt: undefined,
isLive: false,
isShort: false
}));
return { items, total: Array.isArray(res?.groups?.pt) ? res.groups.pt.length : 0 };
}
+14 -13
View File
@@ -1,25 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export class RuAdapter implements ProviderAdapter {
export class RuAdapter implements ProviderAdapter<VideoItem> {
key = 'ru' as const;
label = 'Rumble';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'ru' } }));
const items = (res?.groups?.ru || []).map((it: any) => ({
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'ru');
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const items: VideoItem[] = (res?.groups?.ru || []).map((it: any) => ({
id: it.id,
provider: this.key,
provider: 'rumble',
title: it.title,
channel: it.uploaderName,
channelName: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: false,
isShort: false
thumbnailUrl: it.thumbnail,
viewCount: typeof it.views === 'number' ? it.views : undefined,
publishedAt: it.publishedAt,
}));
return { items, total: Array.isArray(res?.groups?.ru) ? res.groups.ru.length : 0 };
}
+22 -17
View File
@@ -1,26 +1,31 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export class TwAdapter implements ProviderAdapter {
export class TwAdapter implements ProviderAdapter<VideoItem> {
key = 'tw' as const;
label = 'Twitch';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'tw' } }));
const items = (res?.groups?.tw || []).map((it: any) => ({
id: it.id,
provider: this.key,
title: it.title,
channel: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: it.type === 'live',
isShort: false
}));
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'tw');
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const items: VideoItem[] = (res?.groups?.tw || []).map((it: any) => {
const isChannel = String(it?.type || '').toLowerCase() === 'channel';
return {
id: it.id,
provider: 'twitch',
title: it.title,
channelName: it.uploaderName,
durationSec: it.duration,
thumbnailUrl: it.thumbnail,
viewCount: typeof it.views === 'number' ? it.views : undefined,
publishedAt: it.publishedAt,
channel: isChannel ? it.id : undefined,
} as VideoItem;
});
return { items, total: Array.isArray(res?.groups?.tw) ? res.groups.tw.length : 0 };
}
}
+14 -13
View File
@@ -1,25 +1,26 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import type { ProviderAdapter, ProviderSearchParams, SearchResult } from '../models';
import { VideoItem } from 'src/app/shared/models/video-item.model';
export class YtAdapter implements ProviderAdapter {
export class YtAdapter implements ProviderAdapter<VideoItem> {
key = 'yt' as const;
label = 'YouTube';
constructor(private http: HttpClient) {}
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult> {
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: { q: params.q, providers: 'yt' } }));
const items = (res?.groups?.yt || []).map((it: any) => ({
async search(params: ProviderSearchParams, _signal: AbortSignal): Promise<SearchResult<VideoItem>> {
let httpParams = new HttpParams().set('q', params.q).set('providers', 'yt');
if (params.pageToken) httpParams = httpParams.set('page', params.pageToken);
if (params.sort) httpParams = httpParams.set('sort', params.sort);
const res = await firstValueFrom(this.http.get<any>(`/api/search`, { params: httpParams }));
const items: VideoItem[] = (res?.groups?.yt || []).map((it: any) => ({
id: it.id,
provider: this.key,
provider: 'youtube',
title: it.title,
channel: it.uploaderName,
channelName: it.uploaderName,
durationSec: it.duration,
thumbUrl: it.thumbnail,
watchUrl: it.url || '',
views: undefined,
publishedAt: undefined,
isLive: it.type === 'live',
isShort: it.isShort === true
thumbnailUrl: it.thumbnail,
viewCount: typeof it.views === 'number' ? it.views : (typeof it.viewCount === 'number' ? it.viewCount : undefined),
publishedAt: it.publishedAt,
}));
return { items, total: Array.isArray(res?.groups?.yt) ? res.groups.yt.length : 0 };
}
+4 -4
View File
@@ -14,8 +14,8 @@ export interface SearchItem {
watchUrl: string; // internal or external
}
export interface SearchResult {
items: SearchItem[];
export interface SearchResult<T = SearchItem> {
items: T[];
total?: number;
nextPageToken?: string; // or page: string|number
}
@@ -29,8 +29,8 @@ export interface ProviderSearchParams {
type?: 'video'|'live'|'shorts'|'all';
}
export interface ProviderAdapter {
export interface ProviderAdapter<T = SearchItem> {
key: ProviderId;
label: string;
search(params: ProviderSearchParams, signal: AbortSignal): Promise<SearchResult>;
search(params: ProviderSearchParams, signal: AbortSignal): Promise<SearchResult<T>>;
}
+14 -22
View File
@@ -10,12 +10,13 @@ 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: number; data: SearchResult; }
interface CacheEntry<T> { t: number; data: SearchResult<T>; }
@Injectable({ providedIn: 'root' })
export class SearchService {
@@ -29,11 +30,11 @@ export class SearchService {
readonly sort$ = new BehaviorSubject<'relevance' | 'date' | 'views' | 'duration'>('relevance');
// In-memory cache 60s per (provider, q, params)
private cache = new Map<CacheKey, CacheEntry>();
private cache = new Map<CacheKey, CacheEntry<VideoItem>>();
private cacheTtlMs = 60_000;
// Adapters registry
private adapters: Record<ProviderId, ProviderAdapter> = {
private adapters: Record<ProviderId, ProviderAdapter<VideoItem>> = {
yt: new YtAdapter(this.http),
dm: new DmAdapter(this.http),
tw: new TwAdapter(this.http),
@@ -57,22 +58,13 @@ export class SearchService {
// global filter knobs could be added here: time/length/type in the future
}, prov)))
,
// Map normalized items to legacy "groups" response expected by UI
// Map provider results to the format expected by the UI
map((byProvider) => {
const groups = Object.entries(byProvider).reduce((acc, [pid, result]) => {
const arr: SuggestionItemV1[] = (result?.items || []).map(it => ({
id: it.id,
title: it.title,
duration: it.durationSec,
isShort: it.isShort,
thumbnail: it.thumbUrl,
uploaderName: it.channel,
url: it.watchUrl,
type: it.isLive ? 'live' : 'video'
}));
(acc as any)[pid as ProviderId] = arr;
acc[pid as ProviderId] = result.items;
return acc;
}, {} as Record<ProviderId, SuggestionItemV1[]>);
}, {} as Record<ProviderId, VideoItem[]>);
const providers = Object.keys(byProvider) as ProviderId[];
const resp: SearchResponse = { q: this.q$.value, providers, groups };
return resp;
@@ -81,13 +73,13 @@ export class SearchService {
);
// Orchestrate all active providers in parallel with timeout(8s), retry(1) and abort on param change.
private async runAdapters(params: ProviderSearchParams, prov: ProviderId[] | 'all') {
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 any[] } as SearchResult] as const;
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);
@@ -99,9 +91,9 @@ export class SearchService {
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> => withTimeout(adapter.search(params, signal));
const attempt = async (): Promise<SearchResult<VideoItem>> => withTimeout(adapter.search(params, signal));
try {
const res = await attempt().catch(async (e) => {
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; }
@@ -110,11 +102,11 @@ export class SearchService {
return [pid, res] as const;
} catch {
// Swallow errors per provider, return empty
return [pid, { items: [] }] as const;
return [pid, { items: [] } as SearchResult<VideoItem>] as const;
}
});
const results = await Promise.all(tasks);
return Object.fromEntries(results) as Record<ProviderId, SearchResult>;
return Object.fromEntries(results) as Record<ProviderId, SearchResult<VideoItem>>;
}
// Convenience setter helpers
@@ -0,0 +1,14 @@
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<ng-container *ngIf="loading">
<app-video-card-skeleton *ngFor="let _ of skeletonItems"></app-video-card-skeleton>
</ng-container>
<ng-container *ngIf="!loading">
<app-video-card *ngFor="let video of videos" [video]="video"></app-video-card>
</ng-container>
</div>
<div *ngIf="!loading && videos.length === 0" class="flex flex-col items-center justify-center gap-4 rounded-lg border border-zinc-800 bg-zinc-900/50 p-8 text-center">
<h3 class="text-lg font-semibold text-white">Aucun résultat</h3>
<p class="text-zinc-400">Essayez une autre recherche ou sélectionnez un autre fournisseur.</p>
</div>
@@ -0,0 +1,22 @@
import { Component, Input } from '@angular/core';
import { VideoItem } from '../../models/video-item.model';
import { CommonModule } from '@angular/common';
import { VideoCardComponent } from '../video-card/video-card.component';
import { VideoCardSkeletonComponent } from '../video-card-skeleton/video-card-skeleton.component';
@Component({
selector: 'app-search-result-grid',
templateUrl: './search-result-grid.component.html',
styleUrls: ['./search-result-grid.component.scss'],
standalone: true,
imports: [CommonModule, VideoCardComponent, VideoCardSkeletonComponent],
})
export class SearchResultGridComponent {
@Input() videos: VideoItem[] = [];
@Input() loading = false;
@Input() skeletons = 12;
get skeletonItems() {
return new Array(this.skeletons);
}
}
@@ -0,0 +1,7 @@
<div class="flex flex-col gap-2 animate-pulse">
<div class="aspect-video w-full rounded-lg bg-zinc-800"></div>
<div class="flex flex-col gap-2">
<div class="h-5 w-3/4 rounded bg-zinc-800"></div>
<div class="h-4 w-1/2 rounded bg-zinc-800"></div>
</div>
</div>
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-video-card-skeleton',
templateUrl: './video-card-skeleton.component.html',
styleUrls: ['./video-card-skeleton.component.scss'],
standalone: true,
})
export class VideoCardSkeletonComponent {}
@@ -0,0 +1,21 @@
<a [routerLink]="['/watch', video.id]" [queryParams]="buildQueryParams(video)" [state]="{ video }" class="group flex flex-col gap-2">
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-zinc-800">
<img [src]="video.thumbnailUrl" [alt]="video.title" class="h-full w-full object-cover transition-transform group-hover:scale-105" />
<span *ngIf="video.durationSec" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
{{ video.durationSec | date:'mm:ss' }}
</span>
</div>
<div class="flex flex-col">
<h3 class="text-base font-semibold text-zinc-100 line-clamp-2" [title]="video.title">
{{ video.title }}
</h3>
<div class="text-sm text-zinc-400">
<p *ngIf="video.channelName" class="font-medium">{{ video.channelName }}</p>
<div class="flex gap-2">
<span *ngIf="video.viewCount">{{ video.viewCount | number }} vues</span>
<span *ngIf="video.publishedAt">{{ video.publishedAt | date:'shortDate' }}</span>
</div>
</div>
</div>
</a>
@@ -0,0 +1,7 @@
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@@ -0,0 +1,22 @@
import { Component, Input } from '@angular/core';
import { VideoItem } from '../../models/video-item.model';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-video-card',
templateUrl: './video-card.component.html',
styleUrls: ['./video-card.component.scss'],
standalone: true,
imports: [CommonModule, RouterLink],
})
export class VideoCardComponent {
@Input() video!: VideoItem;
buildQueryParams(v: VideoItem): Record<string, any> {
const qp: any = { p: v.provider };
if (v.provider === 'odysee' && v.slug) qp.slug = v.slug;
if (v.provider === 'twitch' && v.channel) qp.channel = v.channel;
return qp;
}
}
+13
View File
@@ -0,0 +1,13 @@
export interface VideoItem {
id: string; // id vidéo canonicalisé
provider: 'youtube' | 'dailymotion' | 'twitch' | 'peertube' | 'odysee' | 'rumble';
title: string;
thumbnailUrl: string;
durationSec?: number;
channelName?: string;
viewCount?: number;
publishedAt?: string; // ISO
// Provider-specific routing hints
slug?: string; // Odysee slug when available
channel?: string; // Twitch channel when item represents a channel/live
}