chore: update Angular cache and TypeScript build info

This commit is contained in:
2025-09-23 21:45:57 -04:00
parent d372b7d509
commit 9a3983a253
18 changed files with 664 additions and 169 deletions
@@ -1,8 +1,8 @@
<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 *ngIf="video.durationSec !== undefined" class="absolute bottom-1 right-1 rounded bg-black/80 px-1.5 py-0.5 text-xs text-white">
{{ video.durationSec | duration }}
</span>
</div>
@@ -2,13 +2,14 @@ import { Component, Input } from '@angular/core';
import { VideoItem } from '../../models/video-item.model';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { DurationPipe } from '../../pipes/duration.pipe';
@Component({
selector: 'app-video-card',
templateUrl: './video-card.component.html',
styleUrls: ['./video-card.component.scss'],
standalone: true,
imports: [CommonModule, RouterLink],
imports: [CommonModule, RouterLink, DurationPipe],
})
export class VideoCardComponent {
@Input() video!: VideoItem;
+58
View File
@@ -0,0 +1,58 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'duration',
standalone: true,
})
export class DurationPipe implements PipeTransform {
transform(value: number | string | null | undefined): string {
if (value == null) {
return '0:00';
}
let totalSeconds: number;
if (typeof value === 'number') {
totalSeconds = value;
} else if (typeof value === 'string' && value.trim() !== '') {
const numeric = Number(value);
totalSeconds = Number.isFinite(numeric) ? numeric : this.parseColonDuration(value);
} else {
totalSeconds = 0;
}
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
return '0:00';
}
totalSeconds = Math.round(totalSeconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${this.pad(minutes)}:${this.pad(seconds)}`;
}
return `${minutes}:${this.pad(seconds)}`;
}
private pad(value: number): string {
return value.toString().padStart(2, '0');
}
private parseColonDuration(raw: string): number {
const clean = raw.replace(/[^0-9:]/g, '');
if (!clean.includes(':')) {
return 0;
}
const parts = clean.split(':').filter(Boolean).map(part => Number(part));
if (parts.some(part => !Number.isFinite(part))) {
return 0;
}
while (parts.length < 3) {
parts.unshift(0);
}
const [hours, minutes, seconds] = parts.slice(-3);
return (hours * 3600) + (minutes * 60) + seconds;
}
}
+11 -6
View File
@@ -52,12 +52,6 @@
<option value="views">Vues</option>
</select>
</div>
<div class="flex items-center gap-2 justify-end">
<button class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 disabled:opacity-50"
(click)="prevPage()" [disabled]="!canPrev()">Précédent</button>
<div class="text-sm text-slate-300">Page {{ pageParam() }}</div>
<button class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" (click)="nextPage()">Suivant</button>
</div>
</div>
@if (error()) {
@@ -68,5 +62,16 @@
}
<app-search-result-grid [videos]="filteredResults()" [loading]="loading()"></app-search-result-grid>
<!-- Infinite scroll anchor & indicators -->
<div class="flex flex-col items-center justify-center py-6">
@if (loading()) {
<div class="text-slate-400 text-sm">Chargement…</div>
}
@if (endReached()) {
<div class="text-slate-500 text-xs">Fin des résultats</div>
}
</div>
<div #infiniteScrollAnchor class="h-1 w-full"></div>
}
</div>
+88 -7
View File
@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewChild, ElementRef } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, ParamMap } from '@angular/router';
@@ -21,7 +21,7 @@ import { Observable, Subscription } from 'rxjs';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TranslatePipe, SearchResultGridComponent]
})
export class SearchComponent {
export class SearchComponent implements AfterViewInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private api = inject(YoutubeApiService);
@@ -43,9 +43,13 @@ export class SearchComponent {
groups = signal<Record<string, any[]>>({});
showUnified = signal<boolean>(false);
error = signal<string | null>(null);
endReached = signal<boolean>(false);
// Dedup key to avoid recording the same search multiple times
private lastRecordedKey: string | null = null;
// Signature to detect new searches (q + providers + sort); excludes page
private lastSearchSignature: string | null = null;
private io?: IntersectionObserver;
hasQuery = computed(() => this.q().length > 0);
providerLabel = computed(() => {
@@ -107,6 +111,43 @@ export class SearchComponent {
}
}
// Merge groups with dedup by id/videoId/url
private mergeGroups(prev: Record<string, any[]>, incoming: Record<string, any[]>): Record<string, any[]> {
const result: Record<string, any[]> = { ...(prev || {}) };
const providers = new Set<string>([...Object.keys(prev || {}), ...Object.keys(incoming || {})]);
providers.forEach(pid => {
const a = Array.isArray(prev?.[pid]) ? prev[pid] : [];
const b = Array.isArray(incoming?.[pid]) ? incoming[pid] : [];
if (a.length === 0) { result[pid] = b.slice(); return; }
if (b.length === 0) { result[pid] = a.slice(); return; }
const seen = new Set<string>();
const merged: any[] = [];
const keyOf = (v: any) => String((v && (v.id || v.videoId || v.url)) ?? JSON.stringify(v));
a.forEach(v => { const k = keyOf(v); if (!seen.has(k)) { seen.add(k); merged.push(v); } });
b.forEach(v => { const k = keyOf(v); if (!seen.has(k)) { seen.add(k); merged.push(v); } });
result[pid] = merged;
});
return result;
}
// Set up IntersectionObserver for infinite scroll
ngAfterViewInit(): void {
const anchor = this.infiniteScrollAnchor?.nativeElement;
if (!anchor) return;
this.io = new IntersectionObserver((entries) => {
const e = entries[0];
if (e && e.isIntersecting) {
this.loadNextPage();
}
}, { root: null, rootMargin: '200px 0px', threshold: 0 });
this.io.observe(anchor);
}
// Optional: clean up observer
ngOnDestroy(): void {
try { this.io?.disconnect(); } catch {}
}
// Page title with search query
pageHeading = computed(() => {
const q = this.q();
@@ -280,14 +321,28 @@ export class SearchComponent {
.pipe(takeUntilDestroyed())
.subscribe({
next: (resp: any) => {
// Convertir la réponse en un format compatible avec notre modèle
const groups: Record<string, any[]> = {};
// Convertir la réponse et concaténer si page > 1 (scroll infini)
const incoming: Record<string, any[]> = {};
if (resp.groups) {
Object.entries(resp.groups).forEach(([key, value]) => {
groups[key] = Array.isArray(value) ? value : [];
incoming[key] = Array.isArray(value) ? value : [];
});
}
this.groups.set(groups);
const currentPage = this.pageParam() || 1;
const isContinuation = currentPage > 1 && this.lastSearchSignature != null;
if (isContinuation) {
const merged = this.mergeGroups(this.groups(), incoming);
this.groups.set(merged);
// If no new items were added, mark end reached
const addedCount = Object.values(incoming).reduce((a: number, arr: any[]) => a + (arr?.length || 0), 0);
if (addedCount === 0) this.endReached.set(true);
} else {
this.groups.set(incoming);
// If first page is empty for all providers, end reached
const total = Object.values(incoming).reduce((a: number, arr: any[]) => a + (arr?.length || 0), 0);
this.endReached.set(total === 0);
}
this.loading.set(false);
this.error.set(null);
},
@@ -338,6 +393,11 @@ export class SearchComponent {
this.unified.setQuery(q);
this.unified.setProviders(providersToUse);
// Determine if this is a new search (signature excludes page)
const signature = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}|${sortNorm}`;
const isNewSearch = this.lastSearchSignature !== signature || pageNum === 1;
this.lastSearchSignature = signature;
// Record this search once per unique (q, providers) combination
try {
const key = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}`;
@@ -355,13 +415,17 @@ export class SearchComponent {
this.showUnified.set(true);
this.loading.set(true);
this.groups.set({} as any); // Clear previous results
if (isNewSearch) {
this.groups.set({} as any); // Clear only for a new search
this.endReached.set(false);
}
this.error.set(null);
} else {
this.loading.set(false);
this.groups.set({} as any);
this.showUnified.set(false);
this.error.set(null);
this.endReached.set(false);
}
});
@@ -430,6 +494,7 @@ export class SearchComponent {
}
@ViewChild('searchInput', { static: false }) searchInput?: ElementRef<HTMLInputElement>;
@ViewChild('infiniteScrollAnchor', { static: false }) infiniteScrollAnchor?: ElementRef<HTMLDivElement>;
// Legacy input handlers removed; SearchBox handles keyboard and submit
@@ -452,6 +517,22 @@ export class SearchComponent {
}
// Pagination controls
private loadNextPage() {
const current = this.pageParam() || 1;
const next = current + 1;
if (this.loading() || this.endReached()) return;
const cleanParams = this.cleanUrlParams({
...this.route.snapshot.queryParams,
page: next
});
this.router.navigate([], {
relativeTo: this.route,
queryParams: cleanParams,
queryParamsHandling: '',
replaceUrl: true
});
}
nextPage() {
const next = (this.pageParam() || 1) + 1;
const cleanParams = this.cleanUrlParams({
@@ -104,8 +104,8 @@
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">
{{ v.duration | number:'1.0-0' }}:{{ (v.duration % 60) | number:'2.0-0' }}
<div *ngIf="v.duration && v.duration > 0" class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">
{{ v.duration | duration }}
</div>
<div class="absolute top-2 left-2">
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
@@ -8,13 +8,42 @@ import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video } from '../../models/video.model';
import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
import { map, of, switchMap } from 'rxjs';
const TWITCH_THEME_KEYWORDS: Record<string, string[]> = {
trending: ['just chatting', 'highlights'],
live: ['live', 'stream'],
gaming: ['gaming', 'playthrough'],
sports: ['sports', 'esports'],
'finance-crypto': ['finance', 'crypto'],
business: ['business', 'entrepreneur'],
tech: ['technology', 'coding'],
science: ['science', 'space'],
health: ['fitness', 'wellness'],
music: ['music', 'performance'],
podcasts: ['podcast', 'talk show'],
'movies-tv': ['movie review', 'tv discussion'],
education: ['education', 'tutorial'],
travel: ['travel vlog'],
food: ['cooking', 'food'],
'diy-makers': ['diy', 'maker'],
autos: ['car build', 'automotive'],
'nature-animals': ['outdoors', 'wildlife'],
comedy: ['comedy', 'standup'],
'art-design': ['art stream', 'drawing'],
vlogs: ['vlog', 'lifestyle'],
reviews: ['tech review', 'unboxing'],
documentary: ['history', 'documentary'],
'family-kids': ['family', 'kids'],
news: ['news', 'politics'],
};
@Component({
selector: 'app-provider-theme',
standalone: true,
templateUrl: './provider-theme-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent]
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe]
})
export class ProviderThemePageComponent implements OnDestroy {
private route = inject(ActivatedRoute);
@@ -76,9 +105,21 @@ export class ProviderThemePageComponent implements OnDestroy {
this.loadMore();
}
private buildQuery(): string {
const tokens = this.themes.tokensFor(this.theme());
return tokens.join(' ').trim();
private buildQuery(provider: Provider): string {
const slug = this.theme();
const baseTokens = this.themes.tokensFor(slug);
if (provider === 'twitch') {
const twitchTokens = TWITCH_THEME_KEYWORDS[slug] || [];
const merged = [...new Set([...twitchTokens, ...baseTokens])];
const value = merged.join(' ').trim();
if (value) return value;
if (twitchTokens.length) return twitchTokens.join(' ');
return slug.replace(/-/g, ' ');
}
const value = baseTokens.join(' ').trim();
return value || slug.replace(/-/g, ' ');
}
loadMore() {
@@ -93,12 +134,33 @@ export class ProviderThemePageComponent implements OnDestroy {
this.loading.set(true);
this.error.set(null);
const snapshotVersion = this.version;
const query = this.buildQuery();
const provider = this.provider();
const query = this.buildQuery(provider);
const cursor = this.nextCursor();
const themeSlug = this.theme();
const isLiveTheme = themeSlug === 'live';
const apiCall = query
? this.api.searchVideosPage(query, this.nextCursor(), provider)
: this.api.getTrendingPage(this.nextCursor(), provider);
let apiCall;
if (provider === 'twitch') {
if (isLiveTheme) {
apiCall = this.api.searchTwitchChannelsPage(query || 'live', cursor || undefined);
} else {
apiCall = this.api.searchTwitchVodsPage(query, cursor || undefined).pipe(
switchMap((res) => {
if ((res?.items?.length || 0) >= 8) {
return of(res);
}
return this.api.searchTwitchClipsPage(query, cursor || undefined).pipe(
map((clips) => ({ ...clips, items: [...(res?.items || []), ...(clips.items || [])] }))
);
})
);
}
} else {
apiCall = query
? this.api.searchVideosPage(query, cursor, provider)
: this.api.getTrendingPage(cursor, provider);
}
apiCall.subscribe({
next: (res) => {
+23 -23
View File
@@ -7,31 +7,31 @@ export interface ThemeDef {
}
export const THEMES: ThemeDef[] = [
{ label: 'Tendances & Viral', slug: 'trending', emoji: '🔥', tokens: ['Trending', 'Viral'] },
{ label: 'Tendances & Viral', slug: 'trending', emoji: '🔥', tokens: ['Trending', 'Highlight', 'Best moments'] },
{ label: 'En direct', slug: 'live', emoji: '🎥', tokens: ['Live', 'Streaming'] },
{ label: 'Jeux vidéo & Esports', slug: 'gaming', emoji: '🎮', tokens: ['Gaming', 'Esports'] },
{ label: 'Sports', slug: 'sports', emoji: '🏅' },
{ label: 'Actualités & Politique', slug: 'news', emoji: '🗞️', tokens: ['News', 'Politics'] },
{ label: 'Finance & Crypto', slug: 'finance-crypto', emoji: '💰', tokens: ['Finance', 'Crypto'] },
{ label: 'Business & Startups', slug: 'business', emoji: '🏢', tokens: ['Business', 'Startups'] },
{ label: 'Tech & Gadgets', slug: 'tech', emoji: '🧠', tokens: ['Tech', 'Gadgets'] },
{ label: 'Science & Espace', slug: 'science', emoji: '🚀', tokens: ['Science', 'Space'] },
{ label: 'Santé & Bien-être', slug: 'health', emoji: '🩺', tokens: ['Health', 'Wellness'] },
{ label: 'Musique', slug: 'music', emoji: '🎵', tokens: ['Music'] },
{ label: 'Podcasts & Talk-shows', slug: 'podcasts', emoji: '🎙️', tokens: ['Podcasts', 'Talk shows'] },
{ label: 'Cinéma & Séries', slug: 'movies-tv', emoji: '🎬', tokens: ['Movies', 'TV'] },
{ label: 'Éducation & Tutoriels', slug: 'education', emoji: '🎓', tokens: ['Education', 'Tutorials'] },
{ label: 'Voyage', slug: 'travel', emoji: '🧳', tokens: ['Travel'] },
{ label: 'Cuisine & Alimentation', slug: 'food', emoji: '🍳', tokens: ['Food', 'Cooking'] },
{ label: 'DIY, Bricolage & Makers', slug: 'diy-makers', emoji: '🛠️', tokens: ['DIY', 'Makers'] },
{ label: 'Auto, Moto & Mécanique', slug: 'autos', emoji: '🚗', tokens: ['Cars', 'Auto', 'Moto'] },
{ label: 'Nature & Animaux', slug: 'nature-animals', emoji: '🌿', tokens: ['Nature', 'Animals'] },
{ label: 'Humour & Sketches', slug: 'comedy', emoji: '😂', tokens: ['Comedy'] },
{ label: 'Jeux vidéo & Esports', slug: 'gaming', emoji: '🎮', tokens: ['Gaming', 'Esports', 'Gameplay', 'Speedrun'] },
{ label: 'Sports', slug: 'sports', emoji: '🏅', tokens: ['Sports', 'Highlights', 'Recap'] },
{ label: 'Actualités & Politique', slug: 'news', emoji: '🗞️', tokens: ['News', 'Politics', 'Debate'] },
{ label: 'Finance & Crypto', slug: 'finance-crypto', emoji: '💰', tokens: ['Finance', 'Crypto', 'Investing'] },
{ label: 'Business & Startups', slug: 'business', emoji: '🏢', tokens: ['Business', 'Startups', 'Entrepreneurship'] },
{ label: 'Tech & Gadgets', slug: 'tech', emoji: '🧠', tokens: ['Tech', 'Gadgets', 'Programming'] },
{ label: 'Science & Espace', slug: 'science', emoji: '🚀', tokens: ['Science', 'Space', 'Discovery'] },
{ label: 'Santé & Bien-être', slug: 'health', emoji: '🩺', tokens: ['Health', 'Wellness', 'Fitness'] },
{ label: 'Musique', slug: 'music', emoji: '🎵', tokens: ['Music', 'Concert', 'Performance'] },
{ label: 'Podcasts & Talk-shows', slug: 'podcasts', emoji: '🎙️', tokens: ['Podcasts', 'Talk show', 'Interview'] },
{ label: 'Cinéma & Séries', slug: 'movies-tv', emoji: '🎬', tokens: ['Movies', 'TV', 'Behind the scenes'] },
{ label: 'Éducation & Tutoriels', slug: 'education', emoji: '🎓', tokens: ['Education', 'Tutorial', 'How to'] },
{ label: 'Voyage', slug: 'travel', emoji: '🧳', tokens: ['Travel', 'Adventure', 'Exploration'] },
{ label: 'Cuisine & Alimentation', slug: 'food', emoji: '🍳', tokens: ['Food', 'Cooking', 'Recipe'] },
{ label: 'DIY, Bricolage & Makers', slug: 'diy-makers', emoji: '🛠️', tokens: ['DIY', 'Makers', 'Workshop'] },
{ label: 'Auto, Moto & Mécanique', slug: 'autos', emoji: '🚗', tokens: ['Cars', 'Automotive', 'Moto'] },
{ label: 'Nature & Animaux', slug: 'nature-animals', emoji: '🌿', tokens: ['Nature', 'Animals', 'Wildlife'] },
{ label: 'Humour & Sketches', slug: 'comedy', emoji: '😂', tokens: ['Comedy', 'Sketch', 'Standup'] },
{ label: 'Art, Design & Photo', slug: 'art-design', emoji: '🖌️', tokens: ['Art', 'Design', 'Photography'] },
{ label: 'Vlogs & Lifestyle', slug: 'vlogs', emoji: '📹', tokens: ['Vlogs', 'Lifestyle'] },
{ label: 'Tests, Avis & Unboxings', slug: 'reviews', emoji: '📦', tokens: ['Reviews', 'Unboxing'] },
{ label: 'Documentaires', slug: 'documentary', emoji: '📜', tokens: ['Documentary'] },
{ label: 'Famille & Enfants', slug: 'family-kids', emoji: '👨‍👩‍👧', tokens: ['Family', 'Kids'] },
{ label: 'Vlogs & Lifestyle', slug: 'vlogs', emoji: '📹', tokens: ['Vlog', 'Lifestyle', 'Daily life'] },
{ label: 'Tests, Avis & Unboxings', slug: 'reviews', emoji: '📦', tokens: ['Review', 'Unboxing', 'Hands on'] },
{ label: 'Documentaires', slug: 'documentary', emoji: '📜', tokens: ['Documentary', 'Investigation', 'History'] },
{ label: 'Famille & Enfants', slug: 'family-kids', emoji: '👨‍👩‍👧', tokens: ['Family', 'Kids', 'Parenting'] },
];
export function slugifyThemeLabel(label: string): string {
+49 -3
View File
@@ -36,6 +36,45 @@ export class YoutubeApiService {
this.ytKeys = this.loadYouTubeKeys();
}
private normalizeDuration(raw: any): number {
if (typeof raw === 'number' && Number.isFinite(raw)) {
return raw > 0 ? Math.round(raw) : 0;
}
if (typeof raw === 'string') {
const value = raw.trim();
if (!value) return 0;
const numeric = Number(value);
if (Number.isFinite(numeric)) {
return numeric > 0 ? Math.round(numeric) : 0;
}
const spaced = value.replace(/[,]/g, '').trim();
const spaceMatch = spaced.match(/^(\d+)[\s]+(\d{1,2}):(\d{2})$/);
if (spaceMatch) {
const hours = Number(spaceMatch[1]);
const minutes = Number(spaceMatch[2]);
const seconds = Number(spaceMatch[3]);
const total = hours * 3600 + minutes * 60 + seconds;
if (total > 0) return total;
}
const colonized = value.replace(/[^0-9:]/g, '');
if (colonized.includes(':')) {
const segments = colonized.split(':').filter(Boolean).map(part => Number(part));
if (segments.length >= 2 && segments.every(segment => Number.isFinite(segment))) {
while (segments.length < 3) segments.unshift(0);
const [hours, minutes, seconds] = segments.slice(-3);
const total = hours * 3600 + minutes * 60 + seconds;
if (total > 0) return total;
}
}
}
return 0;
}
// Some Odysee API responses return thumbnail URLs on hosts like "thumbs.odycdn.com"
// that can be flaky or blocked by the browser. Prefer the canonical
// "thumbnails.odycdn.com" host and ensure https scheme.
@@ -659,7 +698,12 @@ export class YoutubeApiService {
const thumbRaw = val?.thumbnail?.url || '';
const thumb = this.proxiedOdyseeThumb(thumbRaw);
const publishedAt = val?.release_time ? new Date(val.release_time * 1000).toISOString() : '';
const duration = val?.video?.duration || 0;
const rawDuration = val?.video?.duration
?? i?.duration
?? val?.duration
?? i?.meta?.duration
?? (typeof i?.value?.duration !== 'undefined' ? i.value.duration : undefined);
const duration = this.normalizeDuration(rawDuration);
const views = (i?.meta?.view_count != null) ? Number(i.meta.view_count) : 0;
const uploaderName = channelVal?.title || channel?.name || '';
@@ -674,7 +718,7 @@ export class YoutubeApiService {
uploaderUrl: channel?.permanent_url ? this.lbryToOdyseeUrl(channel.permanent_url) : '',
uploaderAvatar: this.proxiedOdyseeThumb(channelVal?.thumbnail?.url) || thumb,
uploadedDate: publishedAt,
duration: Number(duration || 0),
duration,
views: views,
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
} as Video;
@@ -689,7 +733,9 @@ export class YoutubeApiService {
const uploaderAvatar = i?.author_avatar || i?.uploader_avatar || i?.channel?.avatar || i?.uploaderAvatar || thumb || '';
const uploadedDate = i?.published_at || i?.created_at || i?.uploadedDate || '';
const views = Number(i?.views || i?.view_count || 0);
const duration = Number(i?.duration || 0);
const duration = this.normalizeDuration(
i?.duration ?? i?.durationSeconds ?? i?.duration_seconds ?? i?.video_duration ?? i?.length ?? i?.meta?.duration
);
const url = i?.url || (id ? `https://rumble.com/${id}` : '');
if (!id && url) {
try {