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
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.
+14 -7
View File
@@ -6,18 +6,21 @@ const handler = {
label: 'Dailymotion',
/**
* @param {string} q
* @param {{ limit: number, page?: number }} opts
* @param {{ limit: number, page?: number, sort?: 'relevance'|'date'|'views' }} opts
* @returns {Promise<Array<any>>}
*/
async search(q, opts) {
const { limit = 10 } = opts;
const { limit = 10, page = 1, sort = 'relevance' } = opts || {};
try {
const response = await fetch(
`https://api.dailymotion.com/videos?` +
new URLSearchParams({
search: q,
limit: Math.min(limit, 100).toString(),
sort: 'relevance'
page: Math.max(1, Number(page || 1)).toString(),
// Map our sort to Dailymotion API sort: relevance | visited (views) | recent (date)
sort: (sort === 'date' ? 'recent' : (sort === 'views' ? 'visited' : 'relevance')),
fields: 'id,title,thumbnail_url,thumbnail_360_url,thumbnail_480_url,thumbnail_720_url,duration,views_total,owner.screenname,owner.avatar_80_url,created_time'
})
);
@@ -28,11 +31,15 @@ const handler = {
const data = await response.json();
return (data.list || []).map(item => ({
title: item.title,
id: item.id,
title: item.title || '',
id: item.id || '',
url: `https://www.dailymotion.com/video/${item.id}`,
thumbnail: item.thumbnail_360_url || item.thumbnail_180_url || item.thumbnail_url,
uploaderName: item.owner.screenname || item.owner.username,
thumbnail: item.thumbnail_720_url || item.thumbnail_480_url || item.thumbnail_360_url || item.thumbnail_url || '',
uploaderName: item['owner.screenname'] || item['owner.username'] || '',
uploaderAvatar: item['owner.avatar_80_url'] || '',
duration: Number(item.duration || 0),
views: Number(item.views_total || 0),
uploadedDate: item.created_time ? new Date(item.created_time * 1000).toISOString() : '',
type: 'video'
}));
} catch (error) {
+35 -17
View File
@@ -10,16 +10,19 @@ const handler = {
* @returns {Promise<Array<any>>}
*/
async search(q, opts) {
const { limit = 10 } = opts;
const { limit = 10, page = 1 } = opts || {};
try {
const response = await fetch(
`https://lighthouse.odysee.tv/content/search?` +
new URLSearchParams({
query: q,
size: Math.min(limit, 50).toString(),
page: '1'
})
);
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
const pageNum = Math.max(1, Number(page || 1));
const params = new URLSearchParams({
s: q,
size: perPage.toString(),
from: ((pageNum - 1) * perPage).toString(),
include: 'channel,thumbnail_url,title,description,duration,release_time,claimId,name',
mediaType: 'video'
});
const response = await fetch(`https://lighthouse.odysee.tv/search?${params.toString()}`);
if (!response.ok) {
throw new Error(`Odysee API error: ${response.status}`);
@@ -27,14 +30,29 @@ const handler = {
const data = await response.json();
return (data || []).map(item => ({
title: item.title,
id: item.claimId,
url: `https://odysee.com/${item.canonical_url}`,
thumbnail: item.thumbnail_url,
uploaderName: item.channel_name || item.publisher_name,
type: 'video'
}));
return (Array.isArray(data) ? data : []).map(item => {
const rawThumb = item.thumbnail_url || '';
const thumbnail = rawThumb
? rawThumb.startsWith('http')
? rawThumb
: `https://thumbnails.odycdn.com/optimize/s:390:0/quality:85/plain/${rawThumb.replace(/^\//, '')}`
: undefined;
const name = item.name || '';
const claimId = item.claimId || item.claim_id || '';
const urlSegment = claimId ? `${encodeURIComponent(name)}:${claimId}` : encodeURIComponent(name);
return {
title: item.title || name,
id: claimId || name,
url: claimId ? `https://odysee.com/${urlSegment}` : `https://odysee.com/${encodeURIComponent(name)}`,
thumbnail,
uploaderName: item.channel || undefined,
type: 'video',
duration: typeof item.duration === 'number' && item.duration > 0
? Math.round(item.duration)
: (typeof item.video?.duration === 'number' && item.video.duration > 0 ? Math.round(item.video.duration) : undefined)
};
});
} catch (error) {
console.error('Odysee search error:', error);
return [];
+23 -10
View File
@@ -10,14 +10,15 @@ const handler = {
* @returns {Promise<Array<any>>}
*/
async search(q, opts) {
const { limit = 10 } = opts;
const { limit = 10, page = 1 } = opts || {};
try {
// Use PeerTube API search
const response = await fetch(
`https://sepiasearch.org/api/v1/search/videos?` +
new URLSearchParams({
search: q,
count: Math.min(limit, 50).toString()
count: Math.min(limit, 50).toString(),
start: Math.max(0, (Math.max(1, Number(page || 1)) - 1) * Math.min(limit, 50)).toString()
})
);
@@ -27,14 +28,26 @@ const handler = {
const data = await response.json();
return (data.data || []).map(item => ({
title: item.name,
id: item.uuid,
url: item.url,
thumbnail: item.thumbnailPath,
uploaderName: item.account.displayName || item.account.name,
type: 'video'
}));
return (data.data || []).map(item => {
const rawThumb = item.thumbnailUrl || item.thumbnailPath || '';
const thumbnail = rawThumb
? rawThumb.startsWith('http')
? rawThumb
: rawThumb.startsWith('//')
? `https:${rawThumb}`
: new URL(rawThumb, item.url || 'https://sepiasearch.org').toString()
: undefined;
return {
title: item.name,
id: item.uuid,
url: item.url,
thumbnail,
uploaderName: (item.account && (item.account.displayName || item.account.name)) || undefined,
type: 'video',
duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : undefined
};
});
} catch (error) {
console.error('PeerTube search error:', error);
return [];
+128 -17
View File
@@ -1,3 +1,5 @@
import { load } from 'cheerio';
/**
* Minimal Rumble provider handler
*/
@@ -10,30 +12,139 @@ const handler = {
* @returns {Promise<Array<any>>}
*/
async search(q, opts) {
const { limit = 10 } = opts;
const { limit = 10, page = 1 } = opts || {};
try {
const response = await fetch(
`https://rumble.com/api/search/videos?` +
new URLSearchParams({
q: q,
size: Math.min(limit, 50).toString()
})
);
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
const pageNum = Math.max(1, Number(page || 1));
const params = new URLSearchParams({ q: q });
if (pageNum > 1) params.set('page', pageNum.toString());
const response = await fetch(`https://rumble.com/search/video?${params.toString()}` , {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8'
}
});
if (!response.ok) {
throw new Error(`Rumble API error: ${response.status}`);
}
const data = await response.json();
const html = await response.text();
const $ = load(html);
const items = [];
return (data.videos || []).map(item => ({
title: item.title,
id: item.id,
url: `https://rumble.com${item.url}`,
thumbnail: item.thumbnail,
uploaderName: item.author.name,
type: 'video'
}));
const parseDurationToSeconds = (raw) => {
if (raw == null) return undefined;
const value = String(raw).trim();
if (!value) return undefined;
// Plain numeric seconds
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) return Math.floor(numeric);
// ISO-8601 style: PT#H#M#S
const isoMatch = value.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/i);
if (isoMatch) {
const hours = Number(isoMatch[1] || 0);
const minutes = Number(isoMatch[2] || 0);
const seconds = Number(isoMatch[3] || 0);
const totalIso = (hours * 3600) + (minutes * 60) + seconds;
if (totalIso > 0) return totalIso;
}
// Text formats like "1h 2m 3s" or "15m13s"
const textMatch = value.match(/^(?:(\d+)\s*h(?:ours?)?)?\s*(?:(\d+)\s*m(?:in(?:utes)?)?)?\s*(?:(\d+)\s*s(?:ec(?:onds)?)?)?$/i);
if (textMatch && (textMatch[1] || textMatch[2] || textMatch[3])) {
const hours = Number(textMatch[1] || 0);
const minutes = Number(textMatch[2] || 0);
const seconds = Number(textMatch[3] || 0);
const totalText = (hours * 3600) + (minutes * 60) + seconds;
if (totalText > 0) return totalText;
}
// Colon separated HH:MM:SS or MM:SS
const colonCandidate = value.replace(/[^0-9:]/g, '');
if (colonCandidate.includes(':')) {
const segments = colonCandidate.split(':').filter(Boolean).map(s => Number(s));
if (segments.length >= 2 && segments.every(n => Number.isFinite(n))) {
while (segments.length < 3) segments.unshift(0);
const [hours, minutes, seconds] = segments.slice(-3);
const totalColon = (hours * 3600) + (minutes * 60) + seconds;
if (totalColon > 0) return totalColon;
}
}
// Fallback: first integer found, assume seconds if >0
const fallbackDigits = value.match(/(\d+)/);
if (fallbackDigits) {
const seconds = Number(fallbackDigits[1]);
if (Number.isFinite(seconds) && seconds > 0) return seconds;
}
return undefined;
};
$('li.video-listing-entry').each((_idx, el) => {
if (items.length >= perPage) return false;
const $el = $(el);
const anchor = ($el.find('a.video-item--a').attr('href') || '').trim();
if (!anchor) return;
const img = $el.find('img.video-item--img');
const rawThumbnail =
img.attr('data-src') ||
img.attr('data-original') ||
img.attr('src') ||
'';
const normalizedThumb = rawThumbnail.replace(/\s+/g, '').trim();
const title = $el.find('h3.video-item--title').text().replace(/\s+/g, ' ').trim();
const uploaderName = $el.find('.ellipsis-1').text().replace(/\s+/g, ' ').trim();
const url = anchor.startsWith('http') ? anchor : `https://rumble.com${anchor}`;
const id =
$el.attr('data-id') ||
url.split('/').filter(Boolean).pop() ||
String(Math.random());
const thumbnail = normalizedThumb
? normalizedThumb.startsWith('//')
? `https:${normalizedThumb}`
: normalizedThumb
: undefined;
const durationCandidates = [
$el.attr('data-duration'),
$el.attr('data-video-duration'),
$el.data('duration'),
$el.find('[data-duration]').attr('data-duration'),
$el.find('[data-video-duration]').attr('data-video-duration'),
$el.find('time[datetime]').attr('datetime'),
$el.find('.video-item--duration, .video-item--meta time, .video-item--meta .duration, .video-item--meta-duration, .video-item--length').first().text(),
];
let durationSeconds;
for (const candidate of durationCandidates) {
const parsed = parseDurationToSeconds(candidate);
if (typeof parsed === 'number' && parsed > 0) {
durationSeconds = parsed;
break;
}
}
items.push({
title: title || url,
id,
url,
thumbnail,
uploaderName: uploaderName || undefined,
type: 'video',
duration: durationSeconds
});
});
return items;
} catch (error) {
console.error('Rumble search error:', error);
return [];
+42 -21
View File
@@ -10,7 +10,7 @@ const handler = {
* @returns {Promise<Array<any>>}
*/
async search(q, opts) {
const { limit = 10 } = opts;
const { limit = 10, page = 1 } = opts || {};
try {
// First, get OAuth token
const authResponse = await fetch('https://id.twitch.tv/oauth2/token', {
@@ -31,28 +31,49 @@ const handler = {
const { access_token } = await authResponse.json();
// Then search streams
const response = await fetch(
`https://api.twitch.tv/helix/search/channels?` +
new URLSearchParams({
query: q,
first: Math.min(limit, 100).toString()
}),
{
headers: {
'Client-ID': process.env.TWITCH_CLIENT_ID,
'Authorization': `Bearer ${access_token}`
}
}
);
// Then search channels with cursor-based pagination
const perPage = Math.min(Math.max(1, Number(limit || 10)), 100);
const targetPage = Math.max(1, Number(page || 1));
let after = '';
let currentPage = 1;
let lastData = [];
if (!response.ok) {
throw new Error(`Twitch API error: ${response.status}`);
while (currentPage <= targetPage) {
const params = new URLSearchParams({
query: q,
first: String(perPage)
});
if (after) params.set('after', after);
const response = await fetch(
`https://api.twitch.tv/helix/search/channels?` + params.toString(),
{
headers: {
'Client-ID': process.env.TWITCH_CLIENT_ID,
'Authorization': `Bearer ${access_token}`
}
}
);
if (!response.ok) {
throw new Error(`Twitch API error: ${response.status}`);
}
const data = await response.json();
if (currentPage === targetPage) {
lastData = Array.isArray(data.data) ? data.data : [];
break;
}
const nextCursor = data?.pagination?.cursor;
if (!nextCursor) {
lastData = [];
break;
}
after = String(nextCursor);
currentPage++;
}
const data = await response.json();
return data.data?.map(item => ({
return lastData.map(item => ({
title: item.title || item.display_name,
id: item.id,
url: `https://www.twitch.tv/${item.broadcaster_login}`,
@@ -60,7 +81,7 @@ const handler = {
uploaderName: item.display_name,
type: 'stream',
isLive: item.is_live || item.started_at
})) || [];
}));
} catch (error) {
console.error('Twitch search error:', error);
return [];
+88 -18
View File
@@ -10,12 +10,22 @@
* @property {string=} type
*/
function parseISODurationToSeconds(iso) {
if (typeof iso !== 'string' || !iso) return 0;
const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
if (!match) return 0;
const hours = Number(match[1] || 0);
const minutes = Number(match[2] || 0);
const seconds = Number(match[3] || 0);
return (hours * 3600) + (minutes * 60) + seconds;
}
/** @type {{ id: 'yt', label: string, search: (q: string, opts: { limit: number, page?: number, sort?: 'relevance'|'date'|'views' }) => Promise<Suggestion[]> }} */
const handler = {
id: 'yt',
label: 'YouTube',
async search(q, opts) {
const { limit = 10, sort = 'relevance' } = opts || {};
const { limit = 10, page = 1, sort = 'relevance' } = opts || {};
try {
const API_KEY = process.env.YOUTUBE_API_KEY;
if (!API_KEY) {
@@ -26,32 +36,92 @@ const handler = {
if (sort === 'date') order = 'date';
else if (sort === 'views') order = 'viewCount';
const response = await fetch(
`https://www.googleapis.com/youtube/v3/search?` +
new URLSearchParams({
// Iterate nextPageToken to reach the requested page (1-based)
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
const targetPage = Math.max(1, Number(page || 1));
let pageToken = '';
let currentPage = 1;
let lastItems = [];
while (currentPage <= targetPage) {
const params = new URLSearchParams({
part: 'snippet',
q: q,
type: 'video',
maxResults: Math.min(limit, 50).toString(),
maxResults: String(perPage),
key: API_KEY,
order
})
);
});
if (pageToken) params.set('pageToken', pageToken);
if (!response.ok) {
throw new Error(`YouTube API error: ${response.status}`);
const response = await fetch(`https://www.googleapis.com/youtube/v3/search?` + params.toString());
if (!response.ok) {
throw new Error(`YouTube API error: ${response.status}`);
}
const data = await response.json();
if (currentPage === targetPage) {
lastItems = Array.isArray(data.items) ? data.items : [];
break;
}
// Prepare for next iteration
const next = data.nextPageToken;
if (!next) {
// No more pages; requested page beyond available results
lastItems = [];
break;
}
pageToken = String(next);
currentPage++;
}
const data = await response.json();
const videoIds = (lastItems || [])
.map(item => item?.id?.videoId)
.filter(Boolean);
return (data.items || []).map(item => ({
title: item.snippet.title,
id: item.id.videoId,
url: `https://www.youtube.com/watch?v=${item.id.videoId}`,
thumbnail: item.snippet.thumbnails?.medium?.url || item.snippet.thumbnails?.default?.url,
uploaderName: item.snippet.channelTitle,
type: 'video'
}));
const detailsMap = new Map();
if (videoIds.length > 0) {
const detailsParams = new URLSearchParams({
part: 'contentDetails,statistics',
id: videoIds.join(','),
key: API_KEY
});
const detailsResp = await fetch(`https://www.googleapis.com/youtube/v3/videos?${detailsParams.toString()}`);
if (detailsResp.ok) {
const detailsData = await detailsResp.json();
for (const vid of detailsData?.items || []) {
if (vid?.id) detailsMap.set(vid.id, vid);
}
}
}
return (lastItems || []).map(item => {
const videoId = item?.id?.videoId;
const snippet = item?.snippet || {};
const thumb = snippet.thumbnails?.high?.url
|| snippet.thumbnails?.medium?.url
|| snippet.thumbnails?.default?.url
|| undefined;
const details = videoId ? detailsMap.get(videoId) : null;
const isoDuration = details?.contentDetails?.duration || '';
const duration = parseISODurationToSeconds(isoDuration);
const views = details?.statistics?.viewCount != null ? Number(details.statistics.viewCount) : undefined;
return {
title: snippet.title || '',
id: videoId,
url: videoId ? `https://www.youtube.com/watch?v=${videoId}` : undefined,
thumbnail: thumb,
uploaderName: snippet.channelTitle || undefined,
type: 'video',
duration: duration > 0 ? duration : undefined,
views,
publishedAt: snippet.publishedAt || undefined
};
});
} catch (error) {
console.error('YouTube search error:', error);
return [];
+28 -26
View File
@@ -309,46 +309,48 @@ async function scrapeRumbleList({ q, page = 1, limit = 24, sort = 'viral' }) {
if (thumb && thumb.startsWith('//')) thumb = 'https:' + thumb;
// Essayer plusieurs sélecteurs pour la durée, y compris les attributs data-
let durationText = '';
// Ajout de plus de sélecteurs spécifiques à Rumble
// Ajout de plus de sélecteurs spécifiques à Rumble pour la durée
const durationElement = card.find(
'.video-item--duration, .video-duration, .duration, .video-item__duration, ' +
'[data-duration], .videoDuration, .video-time, .time, ' +
'.video-card__duration, .media__duration, .thumb-time, ' +
'.video-listing-entry__duration, .video-item__duration'
'.video-listing-entry__duration, .video-item__duration, time'
).first();
const durationCandidates = [];
if (durationElement.length) {
// Essayer d'abord les attributs data-
durationText = durationElement.attr('data-duration') ||
durationElement.attr('data-time') ||
// Essayer aussi les attributs style ou autres qui pourraient contenir la durée
durationElement.attr('aria-label') ||
durationElement.attr('title') ||
// Essayer le contenu textuel
durationElement.text().trim();
durationCandidates.push(
durationElement.attr('data-duration'),
durationElement.attr('data-time'),
durationElement.attr('datetime'),
durationElement.attr('aria-label'),
durationElement.attr('title'),
durationElement.text()?.trim()
);
}
// Si on n'a pas trouvé de durée, essayer de la trouver dans le contenu de la card
if (!durationText) {
// Chercher un élément qui ressemble à une durée (mm:ss ou hh:mm:ss)
const timeMatch = card.html().match(/>\s*([0-9]+:[0-9]{2}(?::[0-9]{2})?)\s*</);
if (timeMatch && timeMatch[1]) {
durationText = timeMatch[1];
// Chercher dans le HTML de la carte un motif HH:MM(:SS)
try {
const htmlSnippet = card.html() || '';
const match = />\s*([0-9]+:[0-9]{2}(?::[0-9]{2})?)\s*</.exec(htmlSnippet);
if (match && match[1]) durationCandidates.push(match[1]);
} catch {}
let durationSeconds = 0;
for (const candidate of durationCandidates) {
const parsed = parseDurationToSeconds(candidate);
if (parsed > 0) {
durationSeconds = parsed;
break;
}
}
// Nettoyer le texte de durée avant de le parser
const cleanDurationText = durationText.replace(/[^0-9:]/g, '').trim();
// Extraire les vues
const viewsText =
card.find('.video-item--views, .rumbles-views, .views, .video-item__views, [data-views]').first()
.attr('data-views') ||
card.find('.video-item--views, .rumbles-views, .views, .video-item__views, .video-views').first().text().trim();
// Parser la durée
const duration = parseDurationToSeconds(cleanDurationText);
const views = parseInt((viewsText || '').replace(/[^\d]/g, ''), 10) || 0;
// Important: on renvoie TOUJOURS une URL canonique cohérente
@@ -372,7 +374,7 @@ async function scrapeRumbleList({ q, page = 1, limit = 24, sort = 'viral' }) {
thumbnail: thumb,
uploaderName: '',
views,
duration,
duration: durationSeconds,
uploadedDate: '',
url,
type: 'video'
@@ -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 {