chore: update Angular cache and TypeScript build info
This commit is contained in:
parent
d372b7d509
commit
9a3983a253
2
.angular/cache/20.2.2/app/.tsbuildinfo
vendored
2
.angular/cache/20.2.2/app/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
BIN
db/newtube.db
BIN
db/newtube.db
Binary file not shown.
@ -6,18 +6,21 @@ const handler = {
|
|||||||
label: 'Dailymotion',
|
label: 'Dailymotion',
|
||||||
/**
|
/**
|
||||||
* @param {string} q
|
* @param {string} q
|
||||||
* @param {{ limit: number, page?: number }} opts
|
* @param {{ limit: number, page?: number, sort?: 'relevance'|'date'|'views' }} opts
|
||||||
* @returns {Promise<Array<any>>}
|
* @returns {Promise<Array<any>>}
|
||||||
*/
|
*/
|
||||||
async search(q, opts) {
|
async search(q, opts) {
|
||||||
const { limit = 10 } = opts;
|
const { limit = 10, page = 1, sort = 'relevance' } = opts || {};
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://api.dailymotion.com/videos?` +
|
`https://api.dailymotion.com/videos?` +
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
search: q,
|
search: q,
|
||||||
limit: Math.min(limit, 100).toString(),
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
return (data.list || []).map(item => ({
|
return (data.list || []).map(item => ({
|
||||||
title: item.title,
|
title: item.title || '',
|
||||||
id: item.id,
|
id: item.id || '',
|
||||||
url: `https://www.dailymotion.com/video/${item.id}`,
|
url: `https://www.dailymotion.com/video/${item.id}`,
|
||||||
thumbnail: item.thumbnail_360_url || item.thumbnail_180_url || item.thumbnail_url,
|
thumbnail: item.thumbnail_720_url || item.thumbnail_480_url || item.thumbnail_360_url || item.thumbnail_url || '',
|
||||||
uploaderName: item.owner.screenname || item.owner.username,
|
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'
|
type: 'video'
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -10,16 +10,19 @@ const handler = {
|
|||||||
* @returns {Promise<Array<any>>}
|
* @returns {Promise<Array<any>>}
|
||||||
*/
|
*/
|
||||||
async search(q, opts) {
|
async search(q, opts) {
|
||||||
const { limit = 10 } = opts;
|
const { limit = 10, page = 1 } = opts || {};
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
||||||
`https://lighthouse.odysee.tv/content/search?` +
|
const pageNum = Math.max(1, Number(page || 1));
|
||||||
new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
query: q,
|
s: q,
|
||||||
size: Math.min(limit, 50).toString(),
|
size: perPage.toString(),
|
||||||
page: '1'
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(`Odysee API error: ${response.status}`);
|
throw new Error(`Odysee API error: ${response.status}`);
|
||||||
@ -27,14 +30,29 @@ const handler = {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
return (data || []).map(item => ({
|
return (Array.isArray(data) ? data : []).map(item => {
|
||||||
title: item.title,
|
const rawThumb = item.thumbnail_url || '';
|
||||||
id: item.claimId,
|
const thumbnail = rawThumb
|
||||||
url: `https://odysee.com/${item.canonical_url}`,
|
? rawThumb.startsWith('http')
|
||||||
thumbnail: item.thumbnail_url,
|
? rawThumb
|
||||||
uploaderName: item.channel_name || item.publisher_name,
|
: `https://thumbnails.odycdn.com/optimize/s:390:0/quality:85/plain/${rawThumb.replace(/^\//, '')}`
|
||||||
type: 'video'
|
: 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) {
|
} catch (error) {
|
||||||
console.error('Odysee search error:', error);
|
console.error('Odysee search error:', error);
|
||||||
return [];
|
return [];
|
||||||
|
@ -10,14 +10,15 @@ const handler = {
|
|||||||
* @returns {Promise<Array<any>>}
|
* @returns {Promise<Array<any>>}
|
||||||
*/
|
*/
|
||||||
async search(q, opts) {
|
async search(q, opts) {
|
||||||
const { limit = 10 } = opts;
|
const { limit = 10, page = 1 } = opts || {};
|
||||||
try {
|
try {
|
||||||
// Use PeerTube API search
|
// Use PeerTube API search
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://sepiasearch.org/api/v1/search/videos?` +
|
`https://sepiasearch.org/api/v1/search/videos?` +
|
||||||
new URLSearchParams({
|
new URLSearchParams({
|
||||||
search: q,
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
return (data.data || []).map(item => ({
|
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,
|
title: item.name,
|
||||||
id: item.uuid,
|
id: item.uuid,
|
||||||
url: item.url,
|
url: item.url,
|
||||||
thumbnail: item.thumbnailPath,
|
thumbnail,
|
||||||
uploaderName: item.account.displayName || item.account.name,
|
uploaderName: (item.account && (item.account.displayName || item.account.name)) || undefined,
|
||||||
type: 'video'
|
type: 'video',
|
||||||
}));
|
duration: typeof item.duration === 'number' && item.duration > 0 ? Math.round(item.duration) : undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PeerTube search error:', error);
|
console.error('PeerTube search error:', error);
|
||||||
return [];
|
return [];
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { load } from 'cheerio';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal Rumble provider handler
|
* Minimal Rumble provider handler
|
||||||
*/
|
*/
|
||||||
@ -10,30 +12,139 @@ const handler = {
|
|||||||
* @returns {Promise<Array<any>>}
|
* @returns {Promise<Array<any>>}
|
||||||
*/
|
*/
|
||||||
async search(q, opts) {
|
async search(q, opts) {
|
||||||
const { limit = 10 } = opts;
|
const { limit = 10, page = 1 } = opts || {};
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
||||||
`https://rumble.com/api/search/videos?` +
|
const pageNum = Math.max(1, Number(page || 1));
|
||||||
new URLSearchParams({
|
const params = new URLSearchParams({ q: q });
|
||||||
q: q,
|
if (pageNum > 1) params.set('page', pageNum.toString());
|
||||||
size: Math.min(limit, 50).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) {
|
if (!response.ok) {
|
||||||
throw new Error(`Rumble API error: ${response.status}`);
|
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 => ({
|
const parseDurationToSeconds = (raw) => {
|
||||||
title: item.title,
|
if (raw == null) return undefined;
|
||||||
id: item.id,
|
const value = String(raw).trim();
|
||||||
url: `https://rumble.com${item.url}`,
|
if (!value) return undefined;
|
||||||
thumbnail: item.thumbnail,
|
|
||||||
uploaderName: item.author.name,
|
// Plain numeric seconds
|
||||||
type: 'video'
|
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) {
|
} catch (error) {
|
||||||
console.error('Rumble search error:', error);
|
console.error('Rumble search error:', error);
|
||||||
return [];
|
return [];
|
||||||
|
@ -10,7 +10,7 @@ const handler = {
|
|||||||
* @returns {Promise<Array<any>>}
|
* @returns {Promise<Array<any>>}
|
||||||
*/
|
*/
|
||||||
async search(q, opts) {
|
async search(q, opts) {
|
||||||
const { limit = 10 } = opts;
|
const { limit = 10, page = 1 } = opts || {};
|
||||||
try {
|
try {
|
||||||
// First, get OAuth token
|
// First, get OAuth token
|
||||||
const authResponse = await fetch('https://id.twitch.tv/oauth2/token', {
|
const authResponse = await fetch('https://id.twitch.tv/oauth2/token', {
|
||||||
@ -31,13 +31,22 @@ const handler = {
|
|||||||
|
|
||||||
const { access_token } = await authResponse.json();
|
const { access_token } = await authResponse.json();
|
||||||
|
|
||||||
// Then search streams
|
// Then search channels with cursor-based pagination
|
||||||
const response = await fetch(
|
const perPage = Math.min(Math.max(1, Number(limit || 10)), 100);
|
||||||
`https://api.twitch.tv/helix/search/channels?` +
|
const targetPage = Math.max(1, Number(page || 1));
|
||||||
new URLSearchParams({
|
let after = '';
|
||||||
|
let currentPage = 1;
|
||||||
|
let lastData = [];
|
||||||
|
|
||||||
|
while (currentPage <= targetPage) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
query: q,
|
query: q,
|
||||||
first: Math.min(limit, 100).toString()
|
first: String(perPage)
|
||||||
}),
|
});
|
||||||
|
if (after) params.set('after', after);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.twitch.tv/helix/search/channels?` + params.toString(),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Client-ID': process.env.TWITCH_CLIENT_ID,
|
'Client-ID': process.env.TWITCH_CLIENT_ID,
|
||||||
@ -49,10 +58,22 @@ const handler = {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Twitch API error: ${response.status}`);
|
throw new Error(`Twitch API error: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
return data.data?.map(item => ({
|
if (currentPage === targetPage) {
|
||||||
|
lastData = Array.isArray(data.data) ? data.data : [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const nextCursor = data?.pagination?.cursor;
|
||||||
|
if (!nextCursor) {
|
||||||
|
lastData = [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
after = String(nextCursor);
|
||||||
|
currentPage++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastData.map(item => ({
|
||||||
title: item.title || item.display_name,
|
title: item.title || item.display_name,
|
||||||
id: item.id,
|
id: item.id,
|
||||||
url: `https://www.twitch.tv/${item.broadcaster_login}`,
|
url: `https://www.twitch.tv/${item.broadcaster_login}`,
|
||||||
@ -60,7 +81,7 @@ const handler = {
|
|||||||
uploaderName: item.display_name,
|
uploaderName: item.display_name,
|
||||||
type: 'stream',
|
type: 'stream',
|
||||||
isLive: item.is_live || item.started_at
|
isLive: item.is_live || item.started_at
|
||||||
})) || [];
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Twitch search error:', error);
|
console.error('Twitch search error:', error);
|
||||||
return [];
|
return [];
|
||||||
|
@ -10,12 +10,22 @@
|
|||||||
* @property {string=} type
|
* @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[]> }} */
|
/** @type {{ id: 'yt', label: string, search: (q: string, opts: { limit: number, page?: number, sort?: 'relevance'|'date'|'views' }) => Promise<Suggestion[]> }} */
|
||||||
const handler = {
|
const handler = {
|
||||||
id: 'yt',
|
id: 'yt',
|
||||||
label: 'YouTube',
|
label: 'YouTube',
|
||||||
async search(q, opts) {
|
async search(q, opts) {
|
||||||
const { limit = 10, sort = 'relevance' } = opts || {};
|
const { limit = 10, page = 1, sort = 'relevance' } = opts || {};
|
||||||
try {
|
try {
|
||||||
const API_KEY = process.env.YOUTUBE_API_KEY;
|
const API_KEY = process.env.YOUTUBE_API_KEY;
|
||||||
if (!API_KEY) {
|
if (!API_KEY) {
|
||||||
@ -26,32 +36,92 @@ const handler = {
|
|||||||
if (sort === 'date') order = 'date';
|
if (sort === 'date') order = 'date';
|
||||||
else if (sort === 'views') order = 'viewCount';
|
else if (sort === 'views') order = 'viewCount';
|
||||||
|
|
||||||
const response = await fetch(
|
// Iterate nextPageToken to reach the requested page (1-based)
|
||||||
`https://www.googleapis.com/youtube/v3/search?` +
|
const perPage = Math.min(Math.max(1, Number(limit || 10)), 50);
|
||||||
new URLSearchParams({
|
const targetPage = Math.max(1, Number(page || 1));
|
||||||
|
let pageToken = '';
|
||||||
|
let currentPage = 1;
|
||||||
|
let lastItems = [];
|
||||||
|
|
||||||
|
while (currentPage <= targetPage) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
part: 'snippet',
|
part: 'snippet',
|
||||||
q: q,
|
q: q,
|
||||||
type: 'video',
|
type: 'video',
|
||||||
maxResults: Math.min(limit, 50).toString(),
|
maxResults: String(perPage),
|
||||||
key: API_KEY,
|
key: API_KEY,
|
||||||
order
|
order
|
||||||
})
|
});
|
||||||
);
|
if (pageToken) params.set('pageToken', pageToken);
|
||||||
|
|
||||||
|
const response = await fetch(`https://www.googleapis.com/youtube/v3/search?` + params.toString());
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`YouTube API error: ${response.status}`);
|
throw new Error(`YouTube API error: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
return (data.items || []).map(item => ({
|
if (currentPage === targetPage) {
|
||||||
title: item.snippet.title,
|
lastItems = Array.isArray(data.items) ? data.items : [];
|
||||||
id: item.id.videoId,
|
break;
|
||||||
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,
|
// Prepare for next iteration
|
||||||
type: 'video'
|
const next = data.nextPageToken;
|
||||||
}));
|
if (!next) {
|
||||||
|
// No more pages; requested page beyond available results
|
||||||
|
lastItems = [];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pageToken = String(next);
|
||||||
|
currentPage++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoIds = (lastItems || [])
|
||||||
|
.map(item => item?.id?.videoId)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('YouTube search error:', error);
|
console.error('YouTube search error:', error);
|
||||||
return [];
|
return [];
|
||||||
|
@ -309,37 +309,41 @@ async function scrapeRumbleList({ q, page = 1, limit = 24, sort = 'viral' }) {
|
|||||||
if (thumb && thumb.startsWith('//')) thumb = 'https:' + thumb;
|
if (thumb && thumb.startsWith('//')) thumb = 'https:' + thumb;
|
||||||
|
|
||||||
// Essayer plusieurs sélecteurs pour la durée, y compris les attributs data-
|
// Essayer plusieurs sélecteurs pour la durée, y compris les attributs data-
|
||||||
let durationText = '';
|
// Ajout de plus de sélecteurs spécifiques à Rumble pour la durée
|
||||||
// Ajout de plus de sélecteurs spécifiques à Rumble
|
|
||||||
const durationElement = card.find(
|
const durationElement = card.find(
|
||||||
'.video-item--duration, .video-duration, .duration, .video-item__duration, ' +
|
'.video-item--duration, .video-duration, .duration, .video-item__duration, ' +
|
||||||
'[data-duration], .videoDuration, .video-time, .time, ' +
|
'[data-duration], .videoDuration, .video-time, .time, ' +
|
||||||
'.video-card__duration, .media__duration, .thumb-time, ' +
|
'.video-card__duration, .media__duration, .thumb-time, ' +
|
||||||
'.video-listing-entry__duration, .video-item__duration'
|
'.video-listing-entry__duration, .video-item__duration, time'
|
||||||
).first();
|
).first();
|
||||||
|
|
||||||
|
const durationCandidates = [];
|
||||||
if (durationElement.length) {
|
if (durationElement.length) {
|
||||||
// Essayer d'abord les attributs data-
|
durationCandidates.push(
|
||||||
durationText = durationElement.attr('data-duration') ||
|
durationElement.attr('data-duration'),
|
||||||
durationElement.attr('data-time') ||
|
durationElement.attr('data-time'),
|
||||||
// Essayer aussi les attributs style ou autres qui pourraient contenir la durée
|
durationElement.attr('datetime'),
|
||||||
durationElement.attr('aria-label') ||
|
durationElement.attr('aria-label'),
|
||||||
durationElement.attr('title') ||
|
durationElement.attr('title'),
|
||||||
// Essayer le contenu textuel
|
durationElement.text()?.trim()
|
||||||
durationElement.text().trim();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si on n'a pas trouvé de durée, essayer de la trouver dans le contenu de la card
|
// Chercher dans le HTML de la carte un motif HH:MM(:SS)
|
||||||
if (!durationText) {
|
try {
|
||||||
// Chercher un élément qui ressemble à une durée (mm:ss ou hh:mm:ss)
|
const htmlSnippet = card.html() || '';
|
||||||
const timeMatch = card.html().match(/>\s*([0-9]+:[0-9]{2}(?::[0-9]{2})?)\s*</);
|
const match = />\s*([0-9]+:[0-9]{2}(?::[0-9]{2})?)\s*</.exec(htmlSnippet);
|
||||||
if (timeMatch && timeMatch[1]) {
|
if (match && match[1]) durationCandidates.push(match[1]);
|
||||||
durationText = timeMatch[1];
|
} catch {}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nettoyer le texte de durée avant de le parser
|
let durationSeconds = 0;
|
||||||
const cleanDurationText = durationText.replace(/[^0-9:]/g, '').trim();
|
for (const candidate of durationCandidates) {
|
||||||
|
const parsed = parseDurationToSeconds(candidate);
|
||||||
|
if (parsed > 0) {
|
||||||
|
durationSeconds = parsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extraire les vues
|
// Extraire les vues
|
||||||
const viewsText =
|
const viewsText =
|
||||||
@ -347,8 +351,6 @@ async function scrapeRumbleList({ q, page = 1, limit = 24, sort = 'viral' }) {
|
|||||||
.attr('data-views') ||
|
.attr('data-views') ||
|
||||||
card.find('.video-item--views, .rumbles-views, .views, .video-item__views, .video-views').first().text().trim();
|
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;
|
const views = parseInt((viewsText || '').replace(/[^\d]/g, ''), 10) || 0;
|
||||||
|
|
||||||
// Important: on renvoie TOUJOURS une URL canonique cohérente
|
// 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,
|
thumbnail: thumb,
|
||||||
uploaderName: '',
|
uploaderName: '',
|
||||||
views,
|
views,
|
||||||
duration,
|
duration: durationSeconds,
|
||||||
uploadedDate: '',
|
uploadedDate: '',
|
||||||
url,
|
url,
|
||||||
type: 'video'
|
type: 'video'
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
<a [routerLink]="['/watch', video.id]" [queryParams]="buildQueryParams(video)" [state]="{ video }" class="group flex flex-col gap-2">
|
<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">
|
<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" />
|
<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">
|
<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 | date:'mm:ss' }}
|
{{ video.durationSec | duration }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -2,13 +2,14 @@ import { Component, Input } from '@angular/core';
|
|||||||
import { VideoItem } from '../../models/video-item.model';
|
import { VideoItem } from '../../models/video-item.model';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { DurationPipe } from '../../pipes/duration.pipe';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-video-card',
|
selector: 'app-video-card',
|
||||||
templateUrl: './video-card.component.html',
|
templateUrl: './video-card.component.html',
|
||||||
styleUrls: ['./video-card.component.scss'],
|
styleUrls: ['./video-card.component.scss'],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink],
|
imports: [CommonModule, RouterLink, DurationPipe],
|
||||||
})
|
})
|
||||||
export class VideoCardComponent {
|
export class VideoCardComponent {
|
||||||
@Input() video!: VideoItem;
|
@Input() video!: VideoItem;
|
||||||
|
58
src/app/shared/pipes/duration.pipe.ts
Normal file
58
src/app/shared/pipes/duration.pipe.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -52,12 +52,6 @@
|
|||||||
<option value="views">Vues</option>
|
<option value="views">Vues</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
@if (error()) {
|
@if (error()) {
|
||||||
@ -68,5 +62,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<app-search-result-grid [videos]="filteredResults()" [loading]="loading()"></app-search-result-grid>
|
<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>
|
</div>
|
||||||
|
@ -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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, Router, ParamMap } from '@angular/router';
|
import { ActivatedRoute, Router, ParamMap } from '@angular/router';
|
||||||
@ -21,7 +21,7 @@ import { Observable, Subscription } from 'rxjs';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [CommonModule, TranslatePipe, SearchResultGridComponent]
|
imports: [CommonModule, TranslatePipe, SearchResultGridComponent]
|
||||||
})
|
})
|
||||||
export class SearchComponent {
|
export class SearchComponent implements AfterViewInit {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private api = inject(YoutubeApiService);
|
private api = inject(YoutubeApiService);
|
||||||
@ -43,9 +43,13 @@ export class SearchComponent {
|
|||||||
groups = signal<Record<string, any[]>>({});
|
groups = signal<Record<string, any[]>>({});
|
||||||
showUnified = signal<boolean>(false);
|
showUnified = signal<boolean>(false);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
endReached = signal<boolean>(false);
|
||||||
|
|
||||||
// Dedup key to avoid recording the same search multiple times
|
// Dedup key to avoid recording the same search multiple times
|
||||||
private lastRecordedKey: string | null = null;
|
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);
|
hasQuery = computed(() => this.q().length > 0);
|
||||||
providerLabel = computed(() => {
|
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
|
// Page title with search query
|
||||||
pageHeading = computed(() => {
|
pageHeading = computed(() => {
|
||||||
const q = this.q();
|
const q = this.q();
|
||||||
@ -280,14 +321,28 @@ export class SearchComponent {
|
|||||||
.pipe(takeUntilDestroyed())
|
.pipe(takeUntilDestroyed())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (resp: any) => {
|
next: (resp: any) => {
|
||||||
// Convertir la réponse en un format compatible avec notre modèle
|
// Convertir la réponse et concaténer si page > 1 (scroll infini)
|
||||||
const groups: Record<string, any[]> = {};
|
const incoming: Record<string, any[]> = {};
|
||||||
if (resp.groups) {
|
if (resp.groups) {
|
||||||
Object.entries(resp.groups).forEach(([key, value]) => {
|
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.loading.set(false);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
},
|
},
|
||||||
@ -338,6 +393,11 @@ export class SearchComponent {
|
|||||||
this.unified.setQuery(q);
|
this.unified.setQuery(q);
|
||||||
this.unified.setProviders(providersToUse);
|
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
|
// Record this search once per unique (q, providers) combination
|
||||||
try {
|
try {
|
||||||
const key = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}`;
|
const key = `${q}|${Array.isArray(providersToUse) ? providersToUse.join(',') : String(providersToUse)}`;
|
||||||
@ -355,13 +415,17 @@ export class SearchComponent {
|
|||||||
|
|
||||||
this.showUnified.set(true);
|
this.showUnified.set(true);
|
||||||
this.loading.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);
|
this.error.set(null);
|
||||||
} else {
|
} else {
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.groups.set({} as any);
|
this.groups.set({} as any);
|
||||||
this.showUnified.set(false);
|
this.showUnified.set(false);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
|
this.endReached.set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -430,6 +494,7 @@ export class SearchComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewChild('searchInput', { static: false }) searchInput?: ElementRef<HTMLInputElement>;
|
@ViewChild('searchInput', { static: false }) searchInput?: ElementRef<HTMLInputElement>;
|
||||||
|
@ViewChild('infiniteScrollAnchor', { static: false }) infiniteScrollAnchor?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
// Legacy input handlers removed; SearchBox handles keyboard and submit
|
// Legacy input handlers removed; SearchBox handles keyboard and submit
|
||||||
|
|
||||||
@ -452,6 +517,22 @@ export class SearchComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pagination controls
|
// 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() {
|
nextPage() {
|
||||||
const next = (this.pageParam() || 1) + 1;
|
const next = (this.pageParam() || 1) + 1;
|
||||||
const cleanParams = this.cleanUrlParams({
|
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">
|
<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">
|
<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">
|
<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">
|
<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 | number:'1.0-0' }}:{{ (v.duration % 60) | number:'2.0-0' }}
|
{{ v.duration | duration }}
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-2 left-2">
|
<div class="absolute top-2 left-2">
|
||||||
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
|
<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 { Video } from '../../models/video.model';
|
||||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||||
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
|
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({
|
@Component({
|
||||||
selector: 'app-provider-theme',
|
selector: 'app-provider-theme',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: './provider-theme-page.component.html',
|
templateUrl: './provider-theme-page.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent]
|
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent, DurationPipe]
|
||||||
})
|
})
|
||||||
export class ProviderThemePageComponent implements OnDestroy {
|
export class ProviderThemePageComponent implements OnDestroy {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
@ -76,9 +105,21 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
this.loadMore();
|
this.loadMore();
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildQuery(): string {
|
private buildQuery(provider: Provider): string {
|
||||||
const tokens = this.themes.tokensFor(this.theme());
|
const slug = this.theme();
|
||||||
return tokens.join(' ').trim();
|
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() {
|
loadMore() {
|
||||||
@ -93,12 +134,33 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
const snapshotVersion = this.version;
|
const snapshotVersion = this.version;
|
||||||
const query = this.buildQuery();
|
|
||||||
const provider = this.provider();
|
const provider = this.provider();
|
||||||
|
const query = this.buildQuery(provider);
|
||||||
|
const cursor = this.nextCursor();
|
||||||
|
const themeSlug = this.theme();
|
||||||
|
const isLiveTheme = themeSlug === 'live';
|
||||||
|
|
||||||
const apiCall = query
|
let apiCall;
|
||||||
? this.api.searchVideosPage(query, this.nextCursor(), provider)
|
if (provider === 'twitch') {
|
||||||
: this.api.getTrendingPage(this.nextCursor(), provider);
|
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({
|
apiCall.subscribe({
|
||||||
next: (res) => {
|
next: (res) => {
|
||||||
|
@ -7,31 +7,31 @@ export interface ThemeDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const THEMES: 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: 'En direct', slug: 'live', emoji: '🎥', tokens: ['Live', 'Streaming'] },
|
||||||
{ label: 'Jeux vidéo & Esports', slug: 'gaming', emoji: '🎮', tokens: ['Gaming', 'Esports'] },
|
{ label: 'Jeux vidéo & Esports', slug: 'gaming', emoji: '🎮', tokens: ['Gaming', 'Esports', 'Gameplay', 'Speedrun'] },
|
||||||
{ label: 'Sports', slug: 'sports', emoji: '🏅' },
|
{ label: 'Sports', slug: 'sports', emoji: '🏅', tokens: ['Sports', 'Highlights', 'Recap'] },
|
||||||
{ label: 'Actualités & Politique', slug: 'news', emoji: '🗞️', tokens: ['News', 'Politics'] },
|
{ label: 'Actualités & Politique', slug: 'news', emoji: '🗞️', tokens: ['News', 'Politics', 'Debate'] },
|
||||||
{ label: 'Finance & Crypto', slug: 'finance-crypto', emoji: '💰', tokens: ['Finance', 'Crypto'] },
|
{ label: 'Finance & Crypto', slug: 'finance-crypto', emoji: '💰', tokens: ['Finance', 'Crypto', 'Investing'] },
|
||||||
{ label: 'Business & Startups', slug: 'business', emoji: '🏢', tokens: ['Business', 'Startups'] },
|
{ label: 'Business & Startups', slug: 'business', emoji: '🏢', tokens: ['Business', 'Startups', 'Entrepreneurship'] },
|
||||||
{ label: 'Tech & Gadgets', slug: 'tech', emoji: '🧠', tokens: ['Tech', 'Gadgets'] },
|
{ label: 'Tech & Gadgets', slug: 'tech', emoji: '🧠', tokens: ['Tech', 'Gadgets', 'Programming'] },
|
||||||
{ label: 'Science & Espace', slug: 'science', emoji: '🚀', tokens: ['Science', 'Space'] },
|
{ label: 'Science & Espace', slug: 'science', emoji: '🚀', tokens: ['Science', 'Space', 'Discovery'] },
|
||||||
{ label: 'Santé & Bien-être', slug: 'health', emoji: '🩺', tokens: ['Health', 'Wellness'] },
|
{ label: 'Santé & Bien-être', slug: 'health', emoji: '🩺', tokens: ['Health', 'Wellness', 'Fitness'] },
|
||||||
{ label: 'Musique', slug: 'music', emoji: '🎵', tokens: ['Music'] },
|
{ label: 'Musique', slug: 'music', emoji: '🎵', tokens: ['Music', 'Concert', 'Performance'] },
|
||||||
{ label: 'Podcasts & Talk-shows', slug: 'podcasts', emoji: '🎙️', tokens: ['Podcasts', 'Talk shows'] },
|
{ label: 'Podcasts & Talk-shows', slug: 'podcasts', emoji: '🎙️', tokens: ['Podcasts', 'Talk show', 'Interview'] },
|
||||||
{ label: 'Cinéma & Séries', slug: 'movies-tv', emoji: '🎬', tokens: ['Movies', 'TV'] },
|
{ label: 'Cinéma & Séries', slug: 'movies-tv', emoji: '🎬', tokens: ['Movies', 'TV', 'Behind the scenes'] },
|
||||||
{ label: 'Éducation & Tutoriels', slug: 'education', emoji: '🎓', tokens: ['Education', 'Tutorials'] },
|
{ label: 'Éducation & Tutoriels', slug: 'education', emoji: '🎓', tokens: ['Education', 'Tutorial', 'How to'] },
|
||||||
{ label: 'Voyage', slug: 'travel', emoji: '🧳', tokens: ['Travel'] },
|
{ label: 'Voyage', slug: 'travel', emoji: '🧳', tokens: ['Travel', 'Adventure', 'Exploration'] },
|
||||||
{ label: 'Cuisine & Alimentation', slug: 'food', emoji: '🍳', tokens: ['Food', 'Cooking'] },
|
{ label: 'Cuisine & Alimentation', slug: 'food', emoji: '🍳', tokens: ['Food', 'Cooking', 'Recipe'] },
|
||||||
{ label: 'DIY, Bricolage & Makers', slug: 'diy-makers', emoji: '🛠️', tokens: ['DIY', 'Makers'] },
|
{ label: 'DIY, Bricolage & Makers', slug: 'diy-makers', emoji: '🛠️', tokens: ['DIY', 'Makers', 'Workshop'] },
|
||||||
{ label: 'Auto, Moto & Mécanique', slug: 'autos', emoji: '🚗', tokens: ['Cars', 'Auto', 'Moto'] },
|
{ label: 'Auto, Moto & Mécanique', slug: 'autos', emoji: '🚗', tokens: ['Cars', 'Automotive', 'Moto'] },
|
||||||
{ label: 'Nature & Animaux', slug: 'nature-animals', emoji: '🌿', tokens: ['Nature', 'Animals'] },
|
{ label: 'Nature & Animaux', slug: 'nature-animals', emoji: '🌿', tokens: ['Nature', 'Animals', 'Wildlife'] },
|
||||||
{ label: 'Humour & Sketches', slug: 'comedy', emoji: '😂', tokens: ['Comedy'] },
|
{ label: 'Humour & Sketches', slug: 'comedy', emoji: '😂', tokens: ['Comedy', 'Sketch', 'Standup'] },
|
||||||
{ label: 'Art, Design & Photo', slug: 'art-design', emoji: '🖌️', tokens: ['Art', 'Design', 'Photography'] },
|
{ label: 'Art, Design & Photo', slug: 'art-design', emoji: '🖌️', tokens: ['Art', 'Design', 'Photography'] },
|
||||||
{ label: 'Vlogs & Lifestyle', slug: 'vlogs', emoji: '📹', tokens: ['Vlogs', 'Lifestyle'] },
|
{ label: 'Vlogs & Lifestyle', slug: 'vlogs', emoji: '📹', tokens: ['Vlog', 'Lifestyle', 'Daily life'] },
|
||||||
{ label: 'Tests, Avis & Unboxings', slug: 'reviews', emoji: '📦', tokens: ['Reviews', 'Unboxing'] },
|
{ label: 'Tests, Avis & Unboxings', slug: 'reviews', emoji: '📦', tokens: ['Review', 'Unboxing', 'Hands on'] },
|
||||||
{ label: 'Documentaires', slug: 'documentary', emoji: '📜', tokens: ['Documentary'] },
|
{ label: 'Documentaires', slug: 'documentary', emoji: '📜', tokens: ['Documentary', 'Investigation', 'History'] },
|
||||||
{ label: 'Famille & Enfants', slug: 'family-kids', emoji: '👨👩👧', tokens: ['Family', 'Kids'] },
|
{ label: 'Famille & Enfants', slug: 'family-kids', emoji: '👨👩👧', tokens: ['Family', 'Kids', 'Parenting'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function slugifyThemeLabel(label: string): string {
|
export function slugifyThemeLabel(label: string): string {
|
||||||
|
@ -36,6 +36,45 @@ export class YoutubeApiService {
|
|||||||
this.ytKeys = this.loadYouTubeKeys();
|
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"
|
// 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
|
// that can be flaky or blocked by the browser. Prefer the canonical
|
||||||
// "thumbnails.odycdn.com" host and ensure https scheme.
|
// "thumbnails.odycdn.com" host and ensure https scheme.
|
||||||
@ -659,7 +698,12 @@ export class YoutubeApiService {
|
|||||||
const thumbRaw = val?.thumbnail?.url || '';
|
const thumbRaw = val?.thumbnail?.url || '';
|
||||||
const thumb = this.proxiedOdyseeThumb(thumbRaw);
|
const thumb = this.proxiedOdyseeThumb(thumbRaw);
|
||||||
const publishedAt = val?.release_time ? new Date(val.release_time * 1000).toISOString() : '';
|
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 views = (i?.meta?.view_count != null) ? Number(i.meta.view_count) : 0;
|
||||||
const uploaderName = channelVal?.title || channel?.name || '';
|
const uploaderName = channelVal?.title || channel?.name || '';
|
||||||
|
|
||||||
@ -674,7 +718,7 @@ export class YoutubeApiService {
|
|||||||
uploaderUrl: channel?.permanent_url ? this.lbryToOdyseeUrl(channel.permanent_url) : '',
|
uploaderUrl: channel?.permanent_url ? this.lbryToOdyseeUrl(channel.permanent_url) : '',
|
||||||
uploaderAvatar: this.proxiedOdyseeThumb(channelVal?.thumbnail?.url) || thumb,
|
uploaderAvatar: this.proxiedOdyseeThumb(channelVal?.thumbnail?.url) || thumb,
|
||||||
uploadedDate: publishedAt,
|
uploadedDate: publishedAt,
|
||||||
duration: Number(duration || 0),
|
duration,
|
||||||
views: views,
|
views: views,
|
||||||
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
|
uploaded: publishedAt ? Date.parse(publishedAt) : 0,
|
||||||
} as Video;
|
} as Video;
|
||||||
@ -689,7 +733,9 @@ export class YoutubeApiService {
|
|||||||
const uploaderAvatar = i?.author_avatar || i?.uploader_avatar || i?.channel?.avatar || i?.uploaderAvatar || thumb || '';
|
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 uploadedDate = i?.published_at || i?.created_at || i?.uploadedDate || '';
|
||||||
const views = Number(i?.views || i?.view_count || 0);
|
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}` : '');
|
const url = i?.url || (id ? `https://rumble.com/${id}` : '');
|
||||||
if (!id && url) {
|
if (!id && url) {
|
||||||
try {
|
try {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user