refactor(video): uniformiser classification et routage par section
CI / build-and-test (push) Successful in 14m9s
CI / build-and-test (push) Successful in 14m9s
Source unique de classification (video-kind.ts) + politique de section centralisee (section-policy.ts). Les composants ne portent plus de seuils ni de regles de type maison. - section-policy.ts: sectionAccepts / routeForVideo / shortsFeedAccepts / isPlayable + classifyOnce (enrichissement _kind a l'ingestion) - watch-short: separe classification (shortsFeedAccepts) et jouabilite (isPlayable: allowEmbed DM, live/channel Twitch, Rumble 404); supprime le seuil Rumble 90 s et l'import YOUTUBE_SHORT_MAX_SECONDS du composant - home: filtre sectionAccepts(...,'home') (plus de shorts dans le trending), routeForVideo, pagination bouclee (min 20, max 5 iterations) - themes: _kind pre-calcule une fois, sectionAccepts, capacites live lues depuis le registre providers (fin de LIVE_PROVIDERS codes en dur) - video-kind: helpers isNativeVerticalProvider / hasKnownDuration - routes: alias /shorts/:id et /live/:id pour routeForVideo - tests: section-policy.spec.ts + video-kind.spec.ts etendus; npm run test:section
This commit is contained in:
@@ -26,6 +26,7 @@
|
|||||||
"test:history": "node server/tests/history_filters.test.mjs",
|
"test:history": "node server/tests/history_filters.test.mjs",
|
||||||
"test:highlight": "node --loader ts-node/esm --experimental-specifier-resolution=node src/components/account/history/highlight.util.spec.ts",
|
"test:highlight": "node --loader ts-node/esm --experimental-specifier-resolution=node src/components/account/history/highlight.util.spec.ts",
|
||||||
"test:kind": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/shared/utils/video-kind.spec.ts",
|
"test:kind": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/shared/utils/video-kind.spec.ts",
|
||||||
|
"test:section": "node --loader ts-node/esm --experimental-specifier-resolution=node src/app/shared/utils/section-policy.spec.ts",
|
||||||
"test:ytscrape": "node server/tests/youtube-scrape.test.mjs",
|
"test:ytscrape": "node server/tests/youtube-scrape.test.mjs",
|
||||||
"test:ytinnertube": "node server/tests/youtube-innertube.test.mjs",
|
"test:ytinnertube": "node server/tests/youtube-innertube.test.mjs",
|
||||||
"ytdlp:update": "yt-dlp -U || python3 -m yt_dlp -U || echo \"yt-dlp update: installez yt-dlp puis relancez\""
|
"ytdlp:update": "yt-dlp -U || python3 -m yt_dlp -U || echo \"yt-dlp update: installez yt-dlp puis relancez\""
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ export const APP_ROUTES: Routes = [
|
|||||||
loadComponent: () => import('./components/shorts/watch-short.component').then(m => m.WatchShortComponent),
|
loadComponent: () => import('./components/shorts/watch-short.component').then(m => m.WatchShortComponent),
|
||||||
title: 'NewTube - Shorts'
|
title: 'NewTube - Shorts'
|
||||||
},
|
},
|
||||||
|
// Alias de deep-link produits par `routeForVideo` (mêmes pages Watch).
|
||||||
|
{
|
||||||
|
path: 'shorts/:id',
|
||||||
|
loadComponent: () => import('./components/watch/watch.component').then(m => m.WatchComponent),
|
||||||
|
title: 'NewTube - Short'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'live/:id',
|
||||||
|
loadComponent: () => import('./components/watch/watch.component').then(m => m.WatchComponent),
|
||||||
|
title: 'NewTube - Live'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'channel/:provider/:externalId',
|
path: 'channel/:provider/:externalId',
|
||||||
loadComponent: () => import('./components/channel/channel-page.component').then(m => m.ChannelPageComponent),
|
loadComponent: () => import('./components/channel/channel-page.component').then(m => m.ChannelPageComponent),
|
||||||
|
|||||||
@@ -38,3 +38,19 @@ export const PROVIDERS: ProviderSpec[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const PROVIDER_MAP = new Map(PROVIDERS.map(p => [p.id, p]));
|
export const PROVIDER_MAP = new Map(PROVIDERS.map(p => [p.id, p]));
|
||||||
|
|
||||||
|
/** Provider long (instance.service) -> id court du registre. */
|
||||||
|
const LONG_TO_SHORT: Record<string, ProviderId> = {
|
||||||
|
youtube: 'yt', dailymotion: 'dm', twitch: 'tw', peertube: 'pt', rumble: 'ru', odysee: 'od',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le provider supporte-t-il le direct ? Source unique = le registre.
|
||||||
|
* Provider inconnu ⇒ false (jamais de crash), l'ajout d'un provider live
|
||||||
|
* ne touche que ce registre.
|
||||||
|
*/
|
||||||
|
export function providerSupportsLive(provider?: string | null): boolean {
|
||||||
|
const key = String(provider || '').toLowerCase();
|
||||||
|
const id = LONG_TO_SHORT[key] || (key as ProviderId);
|
||||||
|
return PROVIDER_MAP.get(id)?.supports.live === true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
sectionAccepts, routeForVideo, shortsFeedAccepts, shortsFeedAcceptsWith, isPlayable, classifyOnce,
|
||||||
|
} from './section-policy';
|
||||||
|
import { classifyVideo } from './video-kind';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Politique de section (classification + routage + jouabilité).
|
||||||
|
* Run with: npm run test:section
|
||||||
|
*/
|
||||||
|
|
||||||
|
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||||
|
const a = JSON.stringify(actual);
|
||||||
|
const b = JSON.stringify(expected);
|
||||||
|
if (a !== b) {
|
||||||
|
console.error(`FAIL: ${message}\n actual: ${a}\n expected: ${b}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log(`ok: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Classification pure (rappel : miroir de video-kind) ---
|
||||||
|
assertEqual(classifyVideo({ duration: 60, width: 608, height: 1080 }), 'short', 'verticale 60 s ⇒ short');
|
||||||
|
// NB : la doctrine centrale qualifie AUSSI une horizontale ≤ 75 s de short
|
||||||
|
// (repli durée historique) ; on prend donc une vraie vidéo longue comme référence.
|
||||||
|
assertEqual(classifyVideo({ duration: 200, width: 1280, height: 720 }), 'video', 'horizontale 200 s ⇒ video');
|
||||||
|
assertEqual(classifyVideo({ provider: 'rumble' }), 'video', 'durée inconnue provider muet ⇒ video');
|
||||||
|
assertEqual(classifyVideo({ isLive: true }), 'live', 'live natif ⇒ live');
|
||||||
|
|
||||||
|
// --- classifyOnce ---
|
||||||
|
const enriched = classifyOnce({ duration: 60, width: 608, height: 1080, videoId: 's1' } as any);
|
||||||
|
assertEqual(enriched._kind, 'short', 'classifyOnce pose _kind');
|
||||||
|
assertEqual(classifyOnce({ duration: 500, videoId: 'v1' } as any)._kind, 'video', 'classifyOnce 500 s ⇒ video');
|
||||||
|
|
||||||
|
// --- sectionAccepts ---
|
||||||
|
const vertical = { duration: 60, width: 608, height: 1080, videoId: 's1' };
|
||||||
|
const horizontal = { duration: 200, width: 1280, height: 720, videoId: 'v1' };
|
||||||
|
const live = { isLive: true, videoId: 'l1' };
|
||||||
|
|
||||||
|
assertEqual(sectionAccepts(vertical, 'home'), false, 'verticale ⇒ exclue de home');
|
||||||
|
assertEqual(sectionAccepts(vertical, 'theme-shorts'), true, 'verticale ⇒ incluse theme-shorts');
|
||||||
|
assertEqual(sectionAccepts(vertical, 'shorts-feed'), true, 'verticale ⇒ incluse shorts-feed');
|
||||||
|
assertEqual(sectionAccepts(horizontal, 'home'), true, 'horizontale ⇒ incluse home');
|
||||||
|
assertEqual(sectionAccepts(horizontal, 'theme-videos'), true, 'horizontale ⇒ incluse theme-videos');
|
||||||
|
assertEqual(sectionAccepts(horizontal, 'theme-shorts'), false, 'horizontale ⇒ exclue theme-shorts');
|
||||||
|
assertEqual(sectionAccepts(live, 'home'), false, 'live ⇒ exclu home');
|
||||||
|
assertEqual(sectionAccepts(live, 'theme-live'), true, 'live ⇒ inclus theme-live');
|
||||||
|
assertEqual(sectionAccepts({ type: 'channel', videoId: 'c1' }, 'theme-live'), false, 'channel hors-live ⇒ exclu theme-live');
|
||||||
|
assertEqual(sectionAccepts({ provider: 'rumble', videoId: 'r1' }, 'home'), true, 'durée inconnue muette ⇒ home (video)');
|
||||||
|
|
||||||
|
// `_kind` pré-calculé prime sur classifyVideo (sinon 500 s serait 'video').
|
||||||
|
assertEqual(
|
||||||
|
sectionAccepts({ duration: 500, _kind: 'short', videoId: 'x' } as any, 'home'),
|
||||||
|
false,
|
||||||
|
'_kind pré-calculé prioritaire (pas de reclassification)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- routeForVideo ---
|
||||||
|
assertEqual(routeForVideo(vertical, 'shorts-feed'), '/shorts/s1', 'short ⇒ /shorts/:id');
|
||||||
|
assertEqual(routeForVideo(live, 'theme-live'), '/live/l1', 'live ⇒ /live/:id');
|
||||||
|
assertEqual(routeForVideo(horizontal, 'home'), '/watch/v1', 'video ⇒ /watch/:id');
|
||||||
|
|
||||||
|
// --- shortsFeedAccepts (feed uniquement) ---
|
||||||
|
assertEqual(shortsFeedAccepts(vertical, 'youtube'), true, 'short classifié ⇒ accepté');
|
||||||
|
assertEqual(
|
||||||
|
shortsFeedAccepts({ provider: 'rumble', videoId: 'r1' }, 'rumble'),
|
||||||
|
true,
|
||||||
|
'durée inconnue Rumble (option ON) ⇒ accepté',
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
shortsFeedAcceptsWith({ provider: 'rumble', videoId: 'r1' }, 'rumble', false),
|
||||||
|
false,
|
||||||
|
'durée inconnue Rumble (option OFF) ⇒ refusé',
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
shortsFeedAccepts({ provider: 'youtube', videoId: 'y1' }, 'youtube'),
|
||||||
|
false,
|
||||||
|
'durée inconnue provider non-vertical ⇒ refusé',
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- isPlayable (jouabilité) ---
|
||||||
|
assertEqual(
|
||||||
|
isPlayable({ raw: { allowEmbed: false } }, { provider: 'dailymotion' }),
|
||||||
|
false,
|
||||||
|
'Dailymotion allowEmbed=false ⇒ non jouable',
|
||||||
|
);
|
||||||
|
assertEqual(isPlayable({ type: 'channel' } as any, { provider: 'twitch' }), false, 'Twitch chaîne ⇒ non jouable');
|
||||||
|
assertEqual(isPlayable({ isLive: true } as any, { provider: 'twitch' }), false, 'Twitch live ⇒ non jouable');
|
||||||
|
assertEqual(isPlayable({ type: 'video' } as any, { provider: 'twitch' }), true, 'Twitch clip/VOD ⇒ jouable');
|
||||||
|
assertEqual(isPlayable({ videoId: 'r1' } as any, { provider: 'rumble', rumbleFailed: true }), false, 'Rumble 404 ⇒ non jouable');
|
||||||
|
assertEqual(isPlayable({ videoId: 'r1' } as any, { provider: 'rumble' }), true, 'Rumble non résolu ⇒ jouable (attente asynchrone)');
|
||||||
|
assertEqual(isPlayable({ duration: 60 }, { provider: 'youtube' }), true, 'cas nominal ⇒ jouable');
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Politique de routage et d'acceptation par section.
|
||||||
|
*
|
||||||
|
* Centralise ce que les pages faisaient chacune dans leur coin :
|
||||||
|
* - quelle classification une section accepte (`sectionAccepts`) ;
|
||||||
|
* - vers quelle URL pointer une carte (`routeForVideo`) ;
|
||||||
|
* - la tolérance « durée inconnue » propre au feed Shorts (`shortsFeedAccepts`) ;
|
||||||
|
* - la jouabilité d'un item (embed réellement lisible) (`isPlayable`).
|
||||||
|
*
|
||||||
|
* Lire d'abord `_kind` s'il est pré-calculé à l'ingestion ; sinon classifier
|
||||||
|
* à la volée (repli pour les appelants qui n'ont pas encore enrichi).
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
classifyVideo,
|
||||||
|
hasKnownDuration,
|
||||||
|
isNativeVerticalProvider,
|
||||||
|
type ClassifiableVideo,
|
||||||
|
type VideoKind,
|
||||||
|
} from './video-kind';
|
||||||
|
|
||||||
|
export type SectionKind =
|
||||||
|
| 'home'
|
||||||
|
| 'theme-videos'
|
||||||
|
| 'theme-shorts'
|
||||||
|
| 'theme-live'
|
||||||
|
| 'shorts-feed'
|
||||||
|
| 'search'
|
||||||
|
| 'watch-related';
|
||||||
|
|
||||||
|
/** Un item enrichi à l'ingestion : `_kind` évite de reclassifier à chaque render. */
|
||||||
|
export type ClassifiedVideo = ClassifiableVideo & { _kind?: VideoKind };
|
||||||
|
|
||||||
|
/** Item dont la classification est garantie présente. */
|
||||||
|
export type WithKind<T> = T & { _kind: VideoKind };
|
||||||
|
|
||||||
|
/** Enrichit un item une seule fois (à l'ingestion) avec sa classification. */
|
||||||
|
export function classifyOnce<T extends ClassifiableVideo>(v: T): WithKind<T> {
|
||||||
|
return { ...v, _kind: classifyVideo(v) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La section accepte-t-elle cet item ? `_kind` pré-calculé prime ; sinon on
|
||||||
|
* classifie à la volée (repli documenté).
|
||||||
|
*/
|
||||||
|
export function sectionAccepts(v: ClassifiedVideo, section: SectionKind): boolean {
|
||||||
|
const kind = v._kind ?? classifyVideo(v);
|
||||||
|
switch (section) {
|
||||||
|
case 'home':
|
||||||
|
// Trending = vidéos classiques uniquement ; les shorts verticaux
|
||||||
|
// appartiennent à /shorts (lien latéral existant, pas de nouveau bloc).
|
||||||
|
return kind === 'video';
|
||||||
|
case 'theme-videos':
|
||||||
|
return kind === 'video';
|
||||||
|
case 'theme-shorts':
|
||||||
|
return kind === 'short';
|
||||||
|
case 'theme-live':
|
||||||
|
// Garde historique : type channel hors-live exclu.
|
||||||
|
return kind === 'live' && v.type !== 'channel';
|
||||||
|
case 'shorts-feed':
|
||||||
|
return kind === 'short';
|
||||||
|
case 'search':
|
||||||
|
case 'watch-related':
|
||||||
|
// Filtres backend déjà en place ; la recherche mélange volontairement.
|
||||||
|
return true;
|
||||||
|
default: {
|
||||||
|
// Exhaustivité TypeScript : un nouveau SectionKind doit casser ici.
|
||||||
|
const _exhaustive: never = section;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routage d'une carte. Un short affiché dans une carte pointe vers /shorts,
|
||||||
|
* un live vers /live, sinon /watch (routes déjà déclarées dans app.routes.ts).
|
||||||
|
*/
|
||||||
|
export function routeForVideo(v: ClassifiedVideo, _section: SectionKind): string {
|
||||||
|
const kind = v._kind ?? classifyVideo(v);
|
||||||
|
const id = v.id ?? v.videoId ?? '';
|
||||||
|
if (kind === 'short') return `/shorts/${id}`;
|
||||||
|
if (kind === 'live') return `/live/${id}`;
|
||||||
|
return `/watch/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tolérance flux-infini propre au feed Shorts ; ne PAS répliquer ailleurs.
|
||||||
|
* `true` = la doctrine centrale gagne pour les providers muets.
|
||||||
|
*/
|
||||||
|
export const SHORTS_FEED_ALLOW_UNKNOWN_VERTICAL = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le feed Shorts accepte-t-il cet item ?
|
||||||
|
* - classification centrale = short ⇒ oui ;
|
||||||
|
* - sinon, tolerance explicite : provider nativement vertical ET durée
|
||||||
|
* inconnue (Rumble/Odysee dont le scraper renvoie 0) ⇒ on garde pour ne
|
||||||
|
* jamais casser le défilement infini.
|
||||||
|
*/
|
||||||
|
export function shortsFeedAccepts(v: ClassifiableVideo, provider?: string | null): boolean {
|
||||||
|
return shortsFeedAcceptsWith(v, provider, SHORTS_FEED_ALLOW_UNKNOWN_VERTICAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variante testable : `allowUnknown` explicite l'option du feed. */
|
||||||
|
export function shortsFeedAcceptsWith(
|
||||||
|
v: ClassifiableVideo,
|
||||||
|
provider: string | null | undefined,
|
||||||
|
allowUnknown: boolean,
|
||||||
|
): boolean {
|
||||||
|
const p = provider ?? v?.provider ?? null;
|
||||||
|
const withProvider = { ...v, provider: p };
|
||||||
|
if (classifyVideo(withProvider) === 'short') return true;
|
||||||
|
if (!allowUnknown) return false;
|
||||||
|
return isNativeVerticalProvider(p) && !hasKnownDuration(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contexte de jouabilité, fourni par le composant (état de résolution). */
|
||||||
|
export interface PlayableContext {
|
||||||
|
provider?: string | null;
|
||||||
|
/** Rumble : id d'embed résolu via /api/rumble/video (embed fiable). */
|
||||||
|
rumbleResolved?: boolean;
|
||||||
|
/** Rumble : pré-validation 404 => la vidéo ne doit plus être proposée. */
|
||||||
|
rumbleFailed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'embed répondra-t-il réellement ? (indépendant de la classification)
|
||||||
|
* - Dailymotion `allow_embed === false` ⇒ le player répond "Forbidden" ;
|
||||||
|
* - Twitch live / chaîne ⇒ jamais dans le feed Shorts (clips uniquement) ;
|
||||||
|
* - Rumble pré-validé en 404 ⇒ retiré.
|
||||||
|
* Un Rumble non encore résolu reste jouable : la résolution est asynchrone et
|
||||||
|
* laisser l'item visible évite de vider le flux infini pendant l'attente.
|
||||||
|
*/
|
||||||
|
export function isPlayable(v: ClassifiableVideo, ctx: PlayableContext = {}): boolean {
|
||||||
|
const p = (ctx.provider ?? v?.provider ?? '') as string;
|
||||||
|
const provider = String(p).toLowerCase();
|
||||||
|
|
||||||
|
// Dailymotion : embed désactivé par le propriétaire.
|
||||||
|
if (provider === 'dailymotion' && v?.raw?.allowEmbed === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Twitch : les lives/chaînes ne sont jamais jouables dans le flux Shorts.
|
||||||
|
if (provider === 'twitch') {
|
||||||
|
const type = String(v?.type || '').toLowerCase();
|
||||||
|
if (v?.isLive === true) return false;
|
||||||
|
if (type === 'live' || type === 'stream' || type === 'channel') return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rumble : une pré-validation 404 est certaine => non jouable.
|
||||||
|
if (provider === 'rumble' && ctx.rumbleFailed === true) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
isShortVideo, isLiveVideo, classifyVideo,
|
isShortVideo, isLiveVideo, classifyVideo,
|
||||||
|
hasKnownDuration, isNativeVerticalProvider,
|
||||||
SHORT_MAX_SECONDS, YOUTUBE_SHORT_MAX_SECONDS,
|
SHORT_MAX_SECONDS, YOUTUBE_SHORT_MAX_SECONDS,
|
||||||
VERTICAL_SHORT_MAX_SECONDS, VERTICAL_MAX_RATIO,
|
VERTICAL_SHORT_MAX_SECONDS, VERTICAL_MAX_RATIO,
|
||||||
} from './video-kind';
|
} from './video-kind';
|
||||||
@@ -74,5 +75,16 @@ assertEqual(isShortVideo(null), false, 'null ⇒ pas short');
|
|||||||
assertEqual(isLiveVideo({ isLive: true }), true, 'isLive ⇒ live');
|
assertEqual(isLiveVideo({ isLive: true }), true, 'isLive ⇒ live');
|
||||||
assertEqual(isLiveVideo({ type: 'channel' }), true, 'channel ⇒ live (direct ou chaîne)');
|
assertEqual(isLiveVideo({ type: 'channel' }), true, 'channel ⇒ live (direct ou chaîne)');
|
||||||
assertEqual(classifyVideo({ duration: 60, width: 608, height: 1080 }), 'short', 'classify verticale 60 s');
|
assertEqual(classifyVideo({ duration: 60, width: 608, height: 1080 }), 'short', 'classify verticale 60 s');
|
||||||
|
assertEqual(classifyVideo({ duration: 200, width: 1280, height: 720 }), 'video', 'classify horizontale 200 s (repli durée historique : 60 s resterait short)');
|
||||||
assertEqual(classifyVideo({ duration: 500 }), 'video', 'classify 500 s');
|
assertEqual(classifyVideo({ duration: 500 }), 'video', 'classify 500 s');
|
||||||
|
assertEqual(classifyVideo({ provider: 'rumble' }), 'video', 'classify durée inconnue provider muet');
|
||||||
assertEqual(classifyVideo({ isLive: true }), 'live', 'classify live');
|
assertEqual(classifyVideo({ isLive: true }), 'live', 'classify live');
|
||||||
|
|
||||||
|
// Helpers de seuil / providers verticaux (source unique pour le feed Shorts).
|
||||||
|
assertEqual(hasKnownDuration({ duration: 60 }), true, 'hasKnownDuration 60 s');
|
||||||
|
assertEqual(hasKnownDuration({ duration: 0 }), false, 'hasKnownDuration 0');
|
||||||
|
assertEqual(hasKnownDuration({ duration: null }), false, 'hasKnownDuration null');
|
||||||
|
assertEqual(hasKnownDuration(null), false, 'hasKnownDuration null video');
|
||||||
|
assertEqual(isNativeVerticalProvider('rumble'), true, 'Rumble vertical natif');
|
||||||
|
assertEqual(isNativeVerticalProvider('odysee'), true, 'Odysee vertical natif');
|
||||||
|
assertEqual(isNativeVerticalProvider('youtube'), false, 'YouTube non vertical natif');
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export interface ClassifiableVideo {
|
|||||||
isLive?: boolean | null;
|
isLive?: boolean | null;
|
||||||
width?: number | null;
|
width?: number | null;
|
||||||
height?: number | null;
|
height?: number | null;
|
||||||
|
videoId?: string | null;
|
||||||
|
id?: string | null;
|
||||||
|
/** Métadonnées brutes provider (ex. Dailymotion `allowEmbed`). */
|
||||||
|
raw?: { allowEmbed?: boolean } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SHORT_MAX_SECONDS = 75;
|
export const SHORT_MAX_SECONDS = 75;
|
||||||
@@ -32,6 +36,25 @@ export const VERTICAL_SHORT_MAX_SECONDS = 90;
|
|||||||
// ponytail: ratio 0.8 arbitré, 0.75 si de faux carrés Instagram apparaissent.
|
// ponytail: ratio 0.8 arbitré, 0.75 si de faux carrés Instagram apparaissent.
|
||||||
export const VERTICAL_MAX_RATIO = 0.8;
|
export const VERTICAL_MAX_RATIO = 0.8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fournisseurs à production nativement verticale dont le scraper renvoie
|
||||||
|
* souvent une durée inconnue (0). La tolérance « durée inconnue » du flux
|
||||||
|
* Shorts s'appuie sur cette liste, PAS sur une règle de type (cf.
|
||||||
|
* `shortsFeedAccepts` dans section-policy.ts).
|
||||||
|
*/
|
||||||
|
export const NATIVE_VERTICAL_PROVIDERS = new Set(['rumble', 'odysee']);
|
||||||
|
|
||||||
|
/** Le provider est-il nativement vertical (flux Shorts dédié) ? */
|
||||||
|
export function isNativeVerticalProvider(provider?: string | null): boolean {
|
||||||
|
return NATIVE_VERTICAL_PROVIDERS.has(String(provider || '').toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Durée connue = nombre strictement positif (0 / null / undefined = inconnu). */
|
||||||
|
export function hasKnownDuration(v: ClassifiableVideo | null | undefined): boolean {
|
||||||
|
const d = Number((v as { duration?: unknown })?.duration ?? 0);
|
||||||
|
return Number.isFinite(d) && d > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function numOrZero(v: unknown): number {
|
function numOrZero(v: unknown): number {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-8">
|
||||||
@for (video of trendingVideos(); track video.videoId) {
|
@for (video of trendingVideos(); track video.videoId) {
|
||||||
<a [routerLink]="['/watch', video.videoId]" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
|
<a [routerLink]="routeForVideo(video)" [queryParams]="watchQueryParams(video)" [state]="{ video }" class="group flex flex-col bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
|
<img [src]="video.thumbnail" [alt]="video.title" class="w-full h-48 object-cover transition-transform duration-300 group-hover:scale-105">
|
||||||
<div class="absolute top-2 right-2">
|
<div class="absolute top-2 right-2">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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 { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
||||||
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||||
|
import { sectionAccepts, routeForVideo as routeForVideoPolicy, classifyOnce } from '../../app/shared/utils/section-policy';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
@@ -71,6 +72,10 @@ export class HomeComponent {
|
|||||||
this.fetchNextPage();
|
this.fetchNextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: garde-fous anti-boucle si le trending est saturé de shorts verticaux.
|
||||||
|
private static readonly HOME_PAGE_MIN = 20;
|
||||||
|
private static readonly HOME_MAX_ITERATIONS = 5;
|
||||||
|
|
||||||
fetchNextPage() {
|
fetchNextPage() {
|
||||||
if (this.busyMore()) return;
|
if (this.busyMore()) return;
|
||||||
const readiness = this.instances.getProviderReadiness();
|
const readiness = this.instances.getProviderReadiness();
|
||||||
@@ -79,28 +84,47 @@ export class HomeComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.busyMore.set(true);
|
this.busyMore.set(true);
|
||||||
|
this.fetchHomePage(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le trending brut mélange shorts verticaux et vidéos : on classe chaque
|
||||||
|
* item UNE fois (`classifyOnce`) puis on n'accepte que les vidéos classiques
|
||||||
|
* (`sectionAccepts(..., 'home')`). On enchaîne les pages tant que la page
|
||||||
|
* reste incomplète, avec garde-fou pour ne jamais produire de vide silencieux.
|
||||||
|
*/
|
||||||
|
private fetchHomePage(iteration: number): void {
|
||||||
this.apiService.getTrendingPage(this.nextCursor()).subscribe({
|
this.apiService.getTrendingPage(this.nextCursor()).subscribe({
|
||||||
next: res => {
|
next: res => {
|
||||||
const merged = [...this.trendingVideos(), ...res.items];
|
const accepted = (res.items || [])
|
||||||
this.trendingVideos.set(merged);
|
.map((v: Video) => classifyOnce(v))
|
||||||
this.nextCursor.set(res.nextCursor || null);
|
.filter(v => sectionAccepts(v, 'home'));
|
||||||
this.busyMore.set(false);
|
const merged = [...this.trendingVideos(), ...accepted];
|
||||||
this.loading.set(false);
|
this.trendingVideos.set(merged);
|
||||||
const provider = this.instances.selectedProvider();
|
this.nextCursor.set(res.nextCursor || null);
|
||||||
if (merged.length === 0 && !this.notice()) {
|
const hasMore = !!res.nextCursor;
|
||||||
const readiness2 = this.instances.getProviderReadiness();
|
const needMore = accepted.length === 0 || merged.length < HomeComponent.HOME_PAGE_MIN;
|
||||||
if (!readiness2.ready) {
|
if (hasMore && needMore && iteration < HomeComponent.HOME_MAX_ITERATIONS - 1) {
|
||||||
this.notice.set(readiness2.reason || 'Le provider sélectionné n\'est pas prêt.');
|
this.fetchHomePage(iteration + 1);
|
||||||
} else if (provider === 'youtube') {
|
return;
|
||||||
this.notice.set('Aucune vidéo tendance YouTube chargée. Vérifiez que votre YOUTUBE_API_KEY est valide et que les restrictions HTTP referrer incluent http://localhost:4200/*.');
|
}
|
||||||
} else if (provider === 'peertube') {
|
this.busyMore.set(false);
|
||||||
const inst = this.instances.activePeerTubeInstance();
|
this.loading.set(false);
|
||||||
this.notice.set(`PeerTube: les vidéos ne sont pas disponibles depuis l'instance "${inst}" pour le moment. Essayez une autre instance dans l'en-tête.`);
|
const provider = this.instances.selectedProvider();
|
||||||
} else if (provider === 'rumble') {
|
if (merged.length === 0 && !this.notice()) {
|
||||||
const label = this.instances.selectedProviderLabel();
|
const readiness2 = this.instances.getProviderReadiness();
|
||||||
this.notice.set(`Les vidéos ne sont pas disponibles pour le provider "${label}" pour le moment. Réessayez plus tard ou choisissez un autre provider.`);
|
if (!readiness2.ready) {
|
||||||
|
this.notice.set(readiness2.reason || 'Le provider sélectionné n\'est pas prêt.');
|
||||||
|
} else if (provider === 'youtube') {
|
||||||
|
this.notice.set('Aucune vidéo tendance YouTube chargée. Vérifiez que votre YOUTUBE_API_KEY est valide et que les restrictions HTTP referrer incluent http://localhost:4200/*.');
|
||||||
|
} else if (provider === 'peertube') {
|
||||||
|
const inst = this.instances.activePeerTubeInstance();
|
||||||
|
this.notice.set(`PeerTube: les vidéos ne sont pas disponibles depuis l'instance "${inst}" pour le moment. Essayez une autre instance dans l'en-tête.`);
|
||||||
|
} else if (provider === 'rumble') {
|
||||||
|
const label = this.instances.selectedProviderLabel();
|
||||||
|
this.notice.set(`Les vidéos ne sont pas disponibles pour le provider "${label}" pour le moment. Réessayez plus tard ou choisissez un autre provider.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// Never leave the infinite scroll stuck: release flags so scrolling retries.
|
// Never leave the infinite scroll stuck: release flags so scrolling retries.
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -115,6 +139,11 @@ export class HomeComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Routage d'une carte (policy centrale) : vidéo classique ⇒ /watch. */
|
||||||
|
routeForVideo(v: Video): string {
|
||||||
|
return routeForVideoPolicy(v, 'home');
|
||||||
|
}
|
||||||
|
|
||||||
formatViews(views: number): string {
|
formatViews(views: number): string {
|
||||||
if (views >= 1_000_000_000) {
|
if (views >= 1_000_000_000) {
|
||||||
return (views / 1_000_000_000).toFixed(1) + 'B';
|
return (views / 1_000_000_000).toFixed(1) + 'B';
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ import { DownloadService } from '../../services/download.service';
|
|||||||
import { SubscriptionsService, GROUP_ICON_KEYWORDS } from '../../services/subscriptions.service';
|
import { SubscriptionsService, GROUP_ICON_KEYWORDS } from '../../services/subscriptions.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { Video } from '../../models/video.model';
|
import { Video } from '../../models/video.model';
|
||||||
import { isShortVideo, YOUTUBE_SHORT_MAX_SECONDS } from '../../app/shared/utils/video-kind';
|
import { shortsFeedAccepts, isPlayable as isPlayablePolicy, type PlayableContext } from '../../app/shared/utils/section-policy';
|
||||||
|
|
||||||
/** Durée max d'un Short Rumble natif (rumble.com/shorts : vertical ≤ 90 s). */
|
|
||||||
const RUMBLE_SHORT_MAX_SECONDS = 90;
|
|
||||||
|
|
||||||
/** Provider long (youtube…) -> id court d'abonnement ('yt'…). */
|
/** Provider long (youtube…) -> id court d'abonnement ('yt'…). */
|
||||||
const SUB_PROVIDER_OF: Record<string, 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru'> = {
|
const SUB_PROVIDER_OF: Record<string, 'yt' | 'dm' | 'tw' | 'pt' | 'od' | 'ru'> = {
|
||||||
@@ -374,10 +371,11 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
const ids = Array.from(new Set(list.map(v => v.videoId))).slice(0, 50);
|
const ids = Array.from(new Set(list.map(v => v.videoId))).slice(0, 50);
|
||||||
try {
|
try {
|
||||||
const durations = await firstValueFrom(this.api.getYouTubeDurations(ids));
|
const durations = await firstValueFrom(this.api.getYouTubeDurations(ids));
|
||||||
return list.filter(v => {
|
// Durée issue de videos.list appliquée à l'item, puis classification centrale.
|
||||||
const d = durations?.[v.videoId] ?? 0;
|
return list.filter(v => shortsFeedAccepts(
|
||||||
return d > 0 && d <= YOUTUBE_SHORT_MAX_SECONDS;
|
{ ...(v as object), duration: durations?.[v.videoId] ?? 0, provider: 'youtube' } as Video,
|
||||||
});
|
'youtube',
|
||||||
|
));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -390,15 +388,13 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
* Les durées -1 (= non-embeddable, erreur 153) sont toujours exclues.
|
* Les durées -1 (= non-embeddable, erreur 153) sont toujours exclues.
|
||||||
*/
|
*/
|
||||||
private async pickYouTubeShorts(list: Video[]): Promise<Video[]> {
|
private async pickYouTubeShorts(list: Video[]): Promise<Video[]> {
|
||||||
const shorts = list.filter(v =>
|
const shorts = list.filter(v => shortsFeedAccepts({ ...(v as object), provider: 'youtube' } as Video, 'youtube'));
|
||||||
typeof v.duration === 'number' && v.duration > 0 && v.duration <= YOUTUBE_SHORT_MAX_SECONDS
|
|
||||||
);
|
|
||||||
const allKnown = list.every(v => typeof v.duration === 'number' && v.duration !== 0);
|
const allKnown = list.every(v => typeof v.duration === 'number' && v.duration !== 0);
|
||||||
if (shorts.length > 0 || allKnown) return shorts;
|
if (shorts.length > 0 || allKnown) return shorts;
|
||||||
// Certaines durées manquent : on les récupère au lieu de jeter les vidéos.
|
// Certaines durées manquent : on les récupère au lieu de jeter les vidéos.
|
||||||
const missing = list.filter(v => typeof v.duration !== 'number' || v.duration === 0);
|
const missing = list.filter(v => typeof v.duration !== 'number' || v.duration === 0);
|
||||||
const fetched = await this.filterYouTubeShorts(missing);
|
const fetched = await this.filterYouTubeShorts(missing);
|
||||||
return [...shorts, ...fetched.filter(v => this.isShort(v))];
|
return [...shorts, ...fetched.filter(v => this.accepts(v))];
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -815,7 +811,7 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
const byProvider: Video[][] = defs.map((d, i) => {
|
const byProvider: Video[][] = defs.map((d, i) => {
|
||||||
const raw = ((pages[i]?.items || []) as Video[]).filter(v => !!v?.videoId);
|
const raw = ((pages[i]?.items || []) as Video[]).filter(v => !!v?.videoId);
|
||||||
const list = raw.filter(v =>
|
const list = raw.filter(v =>
|
||||||
this.isShort({ ...v, provider: d.provider } as Video) &&
|
this.accepts({ ...v, provider: d.provider } as Video) &&
|
||||||
(d.provider !== 'twitch' || this.shortsLangOk({ ...v, provider: d.provider } as Video))
|
(d.provider !== 'twitch' || this.shortsLangOk({ ...v, provider: d.provider } as Video))
|
||||||
);
|
);
|
||||||
// Mélange léger par sujet pour varier l'ordre à chaque visite.
|
// Mélange léger par sujet pour varier l'ordre à chaque visite.
|
||||||
@@ -927,7 +923,7 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
next: (res: { items: Video[]; nextCursor?: string | null }) => {
|
next: (res: { items: Video[]; nextCursor?: string | null }) => {
|
||||||
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
|
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
|
||||||
// Langue utilisateur + double sécurité anti "time machine".
|
// Langue utilisateur + double sécurité anti "time machine".
|
||||||
this.twitchShortsReady(raw.filter((v: Video) => this.isShort(v))).subscribe({
|
this.twitchShortsReady(raw.filter((v: Video) => this.accepts(v))).subscribe({
|
||||||
next: (filtered) => {
|
next: (filtered) => {
|
||||||
const list: Video[] = this.dedup(filtered);
|
const list: Video[] = this.dedup(filtered);
|
||||||
this.items.set(list);
|
this.items.set(list);
|
||||||
@@ -966,7 +962,7 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
this.api.searchTwitchClipsPage(q)?.subscribe({
|
this.api.searchTwitchClipsPage(q)?.subscribe({
|
||||||
next: (res) => {
|
next: (res) => {
|
||||||
const raw = (res?.items || []).filter(v => !!v.videoId);
|
const raw = (res?.items || []).filter(v => !!v.videoId);
|
||||||
const shorts = raw.filter(v => this.isShort(v));
|
const shorts = raw.filter(v => this.accepts(v));
|
||||||
// Langue utilisateur + validation (recherche non validée côté service).
|
// Langue utilisateur + validation (recherche non validée côté service).
|
||||||
this.twitchShortsReady(shorts).subscribe({
|
this.twitchShortsReady(shorts).subscribe({
|
||||||
next: (filtered) => {
|
next: (filtered) => {
|
||||||
@@ -1021,7 +1017,7 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
};
|
};
|
||||||
if (this.provider() === 'youtube' && raw.length) {
|
if (this.provider() === 'youtube' && raw.length) {
|
||||||
this.pickYouTubeShorts(raw).then(list => {
|
this.pickYouTubeShorts(raw).then(list => {
|
||||||
const shorts = this.dedup(list.filter(v => this.isShort(v)));
|
const shorts = this.dedup(list.filter(v => this.accepts(v)));
|
||||||
if (shorts.length > 0) show(shorts);
|
if (shorts.length > 0) show(shorts);
|
||||||
else if (allowAltQuery) this.tryNextShortsQueryOrTrending();
|
else if (allowAltQuery) this.tryNextShortsQueryOrTrending();
|
||||||
else this.loadTrendingFallback();
|
else this.loadTrendingFallback();
|
||||||
@@ -1031,7 +1027,7 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const list = this.dedup(raw.filter(v => this.isShort(v)));
|
const list = this.dedup(raw.filter(v => this.accepts(v)));
|
||||||
if (list.length > 0) show(list);
|
if (list.length > 0) show(list);
|
||||||
else if (allowAltQuery) this.tryNextShortsQueryOrTrending();
|
else if (allowAltQuery) this.tryNextShortsQueryOrTrending();
|
||||||
else this.loadTrendingFallback();
|
else this.loadTrendingFallback();
|
||||||
@@ -1067,9 +1063,9 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
};
|
};
|
||||||
if (p === 'youtube' && raw.length) {
|
if (p === 'youtube' && raw.length) {
|
||||||
this.filterYouTubeShorts(raw).then(list => apply(list.filter(v => this.isShort(v)))).catch(() => apply([]));
|
this.filterYouTubeShorts(raw).then(list => apply(list.filter(v => this.accepts(v)))).catch(() => apply([]));
|
||||||
} else {
|
} else {
|
||||||
apply(raw.filter(v => this.isShort(v)));
|
apply(raw.filter(v => this.accepts(v)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -1079,38 +1075,30 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtre Shorts — classification partagée, avec tolérance pour les
|
/**
|
||||||
// providers dont les durées sont souvent inconnues (Rumble/Odysee : le
|
* Le feed accepte-t-il cet item ? Séparation stricte :
|
||||||
// scraper renvoie 0) afin de ne jamais afficher une page vide.
|
* - classification : `shortsFeedAccepts` (centre video-kind/section-policy,
|
||||||
private isShort(v: Video): boolean {
|
* tolérance « durée inconnue » Rumble/Odysee incluse) ;
|
||||||
const p = (v as any).provider || this.provider();
|
* - jouabilité : `isPlayable` (allowEmbed, lives/chaînes Twitch, Rumble 404).
|
||||||
// Dailymotion : embed désactivé par le propriétaire (allow_embed=false)
|
*/
|
||||||
// => le player répond "Forbidden" : on exclut avant même l'embed.
|
private accepts(v: Video): boolean {
|
||||||
if (String(p) === 'dailymotion' && (v as any)?.raw?.allowEmbed === false) return false;
|
const p = String((v as any).provider || this.provider()).toLowerCase();
|
||||||
if (String(p) === 'twitch') {
|
const withProvider = { ...(v as object), provider: p } as Video;
|
||||||
const kind = String((v as any).kind || '').toLowerCase();
|
return shortsFeedAccepts(withProvider, p) && this.isPlayable(withProvider);
|
||||||
if (kind === 'clip') return true;
|
}
|
||||||
// Les lives/channels ne sont jamais des Shorts.
|
|
||||||
const type = String((v as any).type || '').toLowerCase();
|
/** Jouabilité : l'embed répondra-t-il ? (indépendant du type de contenu) */
|
||||||
if (type === 'live' || type === 'channel' || type === 'stream') return false;
|
private isPlayable(v: Video): boolean {
|
||||||
|
const p = String((v as any).provider || this.provider()).toLowerCase();
|
||||||
|
const ctx: PlayableContext = { provider: p };
|
||||||
|
if (p === 'rumble') {
|
||||||
|
const id = this.rumbleIdOf(v);
|
||||||
|
if (id) {
|
||||||
|
ctx.rumbleResolved = !!this.resolvedRumbleEmbed()[id];
|
||||||
|
ctx.rumbleFailed = this.rumbleEmbedFailed.has(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const d = Number((v as any).duration || 0);
|
return isPlayablePolicy(v, ctx);
|
||||||
if (String(p) === 'rumble') {
|
|
||||||
// Shorts natifs Rumble : vertical ≤ 90 s. Durée inconnue : on garde
|
|
||||||
// (le flux /shorts natif ne contient que du vertical).
|
|
||||||
if ((v as any).isShort === true && (d <= 0 || d <= RUMBLE_SHORT_MAX_SECONDS)) return true;
|
|
||||||
if (!(d > 0)) return true;
|
|
||||||
return d <= RUMBLE_SHORT_MAX_SECONDS;
|
|
||||||
}
|
|
||||||
if ((v as any).isShort === true) {
|
|
||||||
if (d > 0 && d > 75) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (String(p) === 'odysee' && !(d > 0)) {
|
|
||||||
// Durée inconnue : on garde la vidéo (flux infini) plutôt qu'un vide.
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return isShortVideo({ ...(v as object), provider: p } as Parameters<typeof isShortVideo>[0]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1267,16 +1255,16 @@ export class WatchShortComponent implements OnDestroy {
|
|||||||
this.busyMore.set(false);
|
this.busyMore.set(false);
|
||||||
};
|
};
|
||||||
if (p === 'youtube' && raw.length) {
|
if (p === 'youtube' && raw.length) {
|
||||||
this.pickYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.isShort(v)))).catch(() => { apply([]); });
|
this.pickYouTubeShorts(raw).then((list: Video[]) => apply(list.filter((v: Video) => this.accepts(v)))).catch(() => { apply([]); });
|
||||||
} else if (p === 'twitch' && raw.length) {
|
} else if (p === 'twitch' && raw.length) {
|
||||||
// Langue utilisateur + clips paginés non tous validés côté service.
|
// Langue utilisateur + clips paginés non tous validés côté service.
|
||||||
const shorts = raw.filter((v: Video) => this.isShort(v));
|
const shorts = raw.filter((v: Video) => this.accepts(v));
|
||||||
this.twitchShortsReady(shorts).subscribe({
|
this.twitchShortsReady(shorts).subscribe({
|
||||||
next: (clean) => apply(clean),
|
next: (clean) => apply(clean),
|
||||||
error: () => apply(shorts),
|
error: () => apply(shorts),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
apply(raw.filter((v: Video) => this.isShort(v)));
|
apply(raw.filter((v: Video) => this.accepts(v)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@
|
|||||||
<div class="absolute top-2 left-2 flex gap-1">
|
<div class="absolute top-2 left-2 flex gap-1">
|
||||||
<span *ngIf="isLiveCard(v)" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
<span *ngIf="isLiveCard(v)" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||||
<span *ngIf="!isLiveCard(v) && isClipCard(v)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span>
|
<span *ngIf="!isLiveCard(v) && isClipCard(v)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span>
|
||||||
<span *ngIf="!isLiveCard(v) && kindOf(v) === 'short' && !isClipCard(v)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
<span *ngIf="!isLiveCard(v) && v._kind === 'short' && !isClipCard(v)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
||||||
<span *ngIf="isChannelCard(v)" class="bg-slate-700/90 text-slate-200 text-[11px] font-bold px-1.5 py-0.5 rounded ring-1 ring-slate-500/50">CHAÎNE</span>
|
<span *ngIf="isChannelCard(v)" class="bg-slate-700/90 text-slate-200 text-[11px] font-bold px-1.5 py-0.5 rounded ring-1 ring-slate-500/50">CHAÎNE</span>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<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">
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
|||||||
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
||||||
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
|
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
|
||||||
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
|
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
|
||||||
import { classifyVideo, isLiveVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
import { classifyOnce, type WithKind } from '../../app/shared/utils/section-policy';
|
||||||
|
import { providerSupportsLive } from '../../app/core/providers/provider-registry';
|
||||||
import { map, of, switchMap, catchError, forkJoin, Observable } from 'rxjs';
|
import { map, of, switchMap, catchError, forkJoin, Observable } from 'rxjs';
|
||||||
import { timeout } from 'rxjs/operators';
|
import { timeout } from 'rxjs/operators';
|
||||||
import { PagedResult } from '../../services/youtube-api.service';
|
import { PagedResult } from '../../services/youtube-api.service';
|
||||||
@@ -73,7 +74,7 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
|
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
allVideos = signal<Video[]>([]);
|
allVideos = signal<WithKind<Video>[]>([]);
|
||||||
nextCursor = signal<string | null>(null);
|
nextCursor = signal<string | null>(null);
|
||||||
// Curseurs par section Twitch (les paginations Helix sont indépendantes).
|
// Curseurs par section Twitch (les paginations Helix sont indépendantes).
|
||||||
private twitchCursors = { live: null as string | null, vod: null as string | null, clip: null as string | null, channel: null as string | null };
|
private twitchCursors = { live: null as string | null, vod: null as string | null, clip: null as string | null, channel: null as string | null };
|
||||||
@@ -96,39 +97,20 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
return this.applyFiltersWithType(this.allVideos(), 'any');
|
return this.applyFiltersWithType(this.allVideos(), 'any');
|
||||||
}
|
}
|
||||||
|
|
||||||
twitchLives = computed(() => {
|
// Sections Twitch regroupées sur `_kind` pré-calculé (les chaînes hors-ligne
|
||||||
const p = this.provider();
|
// ont leur propre onglet, plus de doublon dans "En direct").
|
||||||
return this.twitchFilteredNoType().filter(v =>
|
twitchLives = computed(() =>
|
||||||
isLiveVideo({ ...v, provider: (v.provider as string) || p }));
|
this.twitchFilteredNoType().filter(v => v._kind === 'live' && !this.isChannelCard(v))
|
||||||
});
|
);
|
||||||
twitchVods = computed(() => {
|
twitchVods = computed(() =>
|
||||||
const p = this.provider();
|
this.twitchFilteredNoType().filter(v => v._kind === 'video')
|
||||||
return this.twitchFilteredNoType().filter(v => {
|
);
|
||||||
const w = { ...v, provider: (v.provider as string) || p };
|
twitchClips = computed(() =>
|
||||||
if (isLiveVideo(w)) return false;
|
this.twitchFilteredNoType().filter(v => v._kind === 'short')
|
||||||
if (isShortVideo(w)) return false;
|
);
|
||||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
twitchChannels = computed(() =>
|
||||||
return true;
|
this.twitchFilteredNoType().filter(v => this.isChannelCard(v))
|
||||||
});
|
);
|
||||||
});
|
|
||||||
twitchClips = computed(() => {
|
|
||||||
const p = this.provider();
|
|
||||||
return this.twitchFilteredNoType().filter(v => {
|
|
||||||
const w = { ...v, provider: (v.provider as string) || p };
|
|
||||||
if (isLiveVideo(w)) return false;
|
|
||||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
|
||||||
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
|
|
||||||
return isShortVideo(w);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
twitchChannels = computed(() => {
|
|
||||||
const p = this.provider();
|
|
||||||
return this.twitchFilteredNoType().filter(v => {
|
|
||||||
if ((v as any).isLive === true) return false;
|
|
||||||
if (isLiveVideo({ ...v, provider: (v.provider as string) || p })) return false;
|
|
||||||
return String(v.type || '').toLowerCase() === 'channel';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Onglet Twitch synchronisé avec le filtre "type" existant. */
|
/** Onglet Twitch synchronisé avec le filtre "type" existant. */
|
||||||
twitchTab = computed<'all' | 'live' | 'video' | 'clip' | 'channel'>(() => {
|
twitchTab = computed<'all' | 'live' | 'video' | 'clip' | 'channel'>(() => {
|
||||||
@@ -231,8 +213,7 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
const isLiveTheme = themeSlug === 'live';
|
const isLiveTheme = themeSlug === 'live';
|
||||||
|
|
||||||
// Fournisseurs sans aucun direct : aucune vidéo, message explicite.
|
// Fournisseurs sans aucun direct : aucune vidéo, message explicite.
|
||||||
const LIVE_PROVIDERS: Provider[] = ['youtube', 'twitch'];
|
if (isLiveTheme && !providerSupportsLive(provider)) {
|
||||||
if (isLiveTheme && !LIVE_PROVIDERS.includes(provider)) {
|
|
||||||
this.error.set('Ce fournisseur ne propose pas de directs.');
|
this.error.set('Ce fournisseur ne propose pas de directs.');
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.loadingMore.set(false);
|
this.loadingMore.set(false);
|
||||||
@@ -262,15 +243,13 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
next: (res: PagedResult<Video>) => {
|
next: (res: PagedResult<Video>) => {
|
||||||
if (snapshotVersion !== this.version) return;
|
if (snapshotVersion !== this.version) return;
|
||||||
|
|
||||||
// Thème live : garde-fou, ne jamais mélanger des VODs.
|
// Classification calculée UNE fois par item, puis thème live garde-fou.
|
||||||
let items = res.items || [];
|
let items: WithKind<Video>[] = (res.items || []).map((v: Video) =>
|
||||||
if (isLiveTheme) {
|
classifyOnce({ ...(v as object), provider: (v.provider as string) || provider } as Video)
|
||||||
items = items.filter((v: Video) =>
|
);
|
||||||
isLiveVideo({ ...v, provider: (v.provider as string) || provider })
|
if (isLiveTheme) items = items.filter(v => v._kind === 'live');
|
||||||
);
|
|
||||||
}
|
|
||||||
const existingIds = new Set(this.allVideos().map((v: Video) => v.videoId));
|
const existingIds = new Set(this.allVideos().map((v: Video) => v.videoId));
|
||||||
const newVideos = items.filter((v: Video) => !existingIds.has(v.videoId));
|
const newVideos = items.filter((v: WithKind<Video>) => !existingIds.has(v.videoId));
|
||||||
|
|
||||||
this.allVideos.update(current => [...current, ...newVideos]);
|
this.allVideos.update(current => [...current, ...newVideos]);
|
||||||
this.nextCursor.set(res.nextCursor || null);
|
this.nextCursor.set(res.nextCursor || null);
|
||||||
@@ -309,7 +288,10 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
if (snapshotVersion !== this.version) return;
|
if (snapshotVersion !== this.version) return;
|
||||||
if (err) this.error.set(err);
|
if (err) this.error.set(err);
|
||||||
const existing = new Set(this.allVideos().map(v => `${v.type}:${v.videoId}`));
|
const existing = new Set(this.allVideos().map(v => `${v.type}:${v.videoId}`));
|
||||||
const fresh = items.filter(v => !existing.has(`${v.type}:${v.videoId}`));
|
// `_kind` posé ici (ingestion), plus aucune reclassification au render.
|
||||||
|
const fresh = items
|
||||||
|
.filter(v => !existing.has(`${v.type}:${v.videoId}`))
|
||||||
|
.map(v => classifyOnce({ ...(v as object), provider: (v.provider as string) || provider } as Video));
|
||||||
if (fresh.length) this.allVideos.update(cur => [...cur, ...fresh]);
|
if (fresh.length) this.allVideos.update(cur => [...cur, ...fresh]);
|
||||||
this.nextCursor.set(next);
|
this.nextCursor.set(next);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
@@ -419,33 +401,22 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtres de type basés sur `_kind` (classification pré-calculée, plus de
|
||||||
|
// règle maison ici). Les chaînes hors-ligne gardent leur traitement brut.
|
||||||
const typeFilter = typeOverride;
|
const typeFilter = typeOverride;
|
||||||
if (typeFilter !== 'any') {
|
if (typeFilter !== 'any') {
|
||||||
const p = this.provider();
|
|
||||||
arr = arr.filter(v => {
|
arr = arr.filter(v => {
|
||||||
const withProvider = { ...v, provider: (v.provider as string) || p };
|
const type = String(v.type || '').toLowerCase();
|
||||||
if (typeFilter === 'video') return !isShortVideo(withProvider) && !isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel' && (v.type === 'video' || v.type === 'recorded');
|
if (typeFilter === 'video') return v._kind === 'video' && type !== 'channel' && (type === 'video' || type === 'recorded');
|
||||||
if (typeFilter === 'live') return isLiveVideo(withProvider);
|
if (typeFilter === 'live') return v._kind === 'live';
|
||||||
if (typeFilter === 'recorded') return !isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel' && (v.type === 'recorded' || (v.type === 'video' && !isShortVideo(withProvider)));
|
if (typeFilter === 'recorded') return v._kind !== 'live' && type !== 'channel' && (type === 'recorded' || (type === 'video' && v._kind !== 'short'));
|
||||||
// Shorts / Clips : classification partagée + kind='clip' (durée parfois absente).
|
if (typeFilter === 'short') return v._kind === 'short';
|
||||||
if (typeFilter === 'short') {
|
if (typeFilter === 'channel') return this.isChannelCard(v);
|
||||||
if (isLiveVideo(withProvider)) return false;
|
|
||||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
|
||||||
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
|
|
||||||
return isShortVideo(withProvider);
|
|
||||||
}
|
|
||||||
// Chaînes : hors-ligne uniquement (les lives restent dans "Direct").
|
|
||||||
if (typeFilter === 'channel') {
|
|
||||||
if ((v as any).isLive === true) return false;
|
|
||||||
if (isLiveVideo(withProvider) && String(v.type || '').toLowerCase() !== 'channel') return false;
|
|
||||||
return String(v.type || '').toLowerCase() === 'channel';
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
} else if (this.theme() === 'live') {
|
} else if (this.theme() === 'live') {
|
||||||
// Thème live même si l'utilisateur remet le filtre sur "Any" : LIVE seulement.
|
// Thème live même si l'utilisateur remet le filtre sur "Any" : LIVE seulement.
|
||||||
const p = this.provider();
|
arr = arr.filter(v => v._kind === 'live');
|
||||||
arr = arr.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || p }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const langFilter = this.language();
|
const langFilter = this.language();
|
||||||
@@ -504,19 +475,9 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
this.type.set(v === 'shorts' ? 'short' : v === 'videos' ? 'video' : 'any');
|
this.type.set(v === 'shorts' ? 'short' : v === 'videos' ? 'video' : 'any');
|
||||||
}
|
}
|
||||||
|
|
||||||
kindOf(v: Video): string {
|
|
||||||
const p = this.provider();
|
|
||||||
return classifyVideo({ ...v, provider: (v.provider as string) || p });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Badges Twitch : LIVE seulement si réellement en direct (pas les chaînes hors-ligne). */
|
/** Badges Twitch : LIVE seulement si réellement en direct (pas les chaînes hors-ligne). */
|
||||||
isLiveCard(v: Video): boolean {
|
isLiveCard(v: Video): boolean {
|
||||||
if ((v as any).isLive === true) return true;
|
return v._kind === 'live' && !this.isChannelCard(v);
|
||||||
if (String(v.type || '').toLowerCase() === 'live') return true;
|
|
||||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
|
||||||
const p = this.provider();
|
|
||||||
return isLiveVideo({ ...v, provider: (v.provider as string) || p })
|
|
||||||
&& String(v.type || '').toLowerCase() !== 'channel';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isChannelCard(v: Video): boolean {
|
isChannelCard(v: Video): boolean {
|
||||||
@@ -526,17 +487,14 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
|
|
||||||
isClipCard(v: Video): boolean {
|
isClipCard(v: Video): boolean {
|
||||||
if (this.isLiveCard(v) || this.isChannelCard(v)) return false;
|
if (this.isLiveCard(v) || this.isChannelCard(v)) return false;
|
||||||
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
|
return v._kind === 'short';
|
||||||
const p = this.provider();
|
|
||||||
return isShortVideo({ ...v, provider: (v.provider as string) || p });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Affichage "Tout" Twitch : sections empilées façon directory. */
|
/** Affichage "Tout" Twitch : sections empilées façon directory. */
|
||||||
showTwitchSectionsAll = computed(() => this.isTwitchSections() && this.twitchTab() === 'all');
|
showTwitchSectionsAll = computed(() => this.isTwitchSections() && this.twitchTab() === 'all');
|
||||||
|
|
||||||
shortsCount(): number {
|
shortsCount(): number {
|
||||||
const p = this.provider();
|
return this.allVideos().filter(v => v._kind === 'short').length;
|
||||||
return this.allVideos().filter(v => isShortVideo({ ...v, provider: (v.provider as string) || p })).length;
|
|
||||||
}
|
}
|
||||||
onLanguageChange(val: any) { this.language.set(val as any); }
|
onLanguageChange(val: any) { this.language.set(val as any); }
|
||||||
onDateChange(val: any) { this.date.set(val as any); }
|
onDateChange(val: any) { this.date.set(val as any); }
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
|
|
||||||
<!-- Error / empty -->
|
<!-- Error / empty -->
|
||||||
<div *ngIf="!b.loading && b.error" class="text-amber-300">{{ b.error }}</div>
|
<div *ngIf="!b.loading && b.error" class="text-amber-300">{{ b.error }}</div>
|
||||||
<div *ngIf="!b.loading && !b.error && b.items.length === 0 && themeSlug() === 'live' && !isLiveProvider(b.provider)" class="text-slate-400">Ce fournisseur ne propose pas de directs.</div>
|
<div *ngIf="!b.loading && !b.error && b.items.length === 0 && themeSlug() === 'live' && !providerSupportsLive(b.provider)" class="text-slate-400">Ce fournisseur ne propose pas de directs.</div>
|
||||||
<div *ngIf="!b.loading && !b.error && b.items.length === 0 && !(themeSlug() === 'live' && !isLiveProvider(b.provider))" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
<div *ngIf="!b.loading && !b.error && b.items.length === 0 && !(themeSlug() === 'live' && !providerSupportsLive(b.provider))" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
||||||
<div *ngIf="!b.loading && !b.error && b.items.length > 0 && filteredItems(b).length === 0" class="text-slate-400">
|
<div *ngIf="!b.loading && !b.error && b.items.length > 0 && filteredItems(b).length === 0" class="text-slate-400">
|
||||||
Aucun {{ contentFilter() === 'shorts' ? 'Short' : 'vidéo' }} pour ce fournisseur — essayez l'autre filtre.
|
Aucun {{ contentFilter() === 'shorts' ? 'Short' : 'vidéo' }} pour ce fournisseur — essayez l'autre filtre.
|
||||||
</div>
|
</div>
|
||||||
@@ -56,18 +56,18 @@
|
|||||||
<app-provider-badge [provider]="b.provider"></app-provider-badge>
|
<app-provider-badge [provider]="b.provider"></app-provider-badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-2 left-2 flex gap-1">
|
<div class="absolute top-2 left-2 flex gap-1">
|
||||||
<span *ngIf="isLiveCard(v, b.provider)" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
<span *ngIf="isLiveCard(v)" class="bg-purple-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">LIVE</span>
|
||||||
<span *ngIf="!isLiveCard(v, b.provider) && isClipCard(v, b.provider)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span>
|
<span *ngIf="!isLiveCard(v) && isClipCard(v)" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">CLIP</span>
|
||||||
<span *ngIf="!isLiveCard(v, b.provider) && !isClipCard(v, b.provider) && kindOf(v, b.provider) === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
<span *ngIf="!isLiveCard(v) && !isClipCard(v) && v._kind === 'short'" class="bg-red-600 text-white text-[11px] font-bold px-1.5 py-0.5 rounded">SHORT</span>
|
||||||
<span *ngIf="isChannelCard(v)" class="bg-slate-700/90 text-slate-200 text-[11px] font-bold px-1.5 py-0.5 rounded ring-1 ring-slate-500/50">CHAÎNE</span>
|
<span *ngIf="isChannelCard(v)" class="bg-slate-700/90 text-slate-200 text-[11px] font-bold px-1.5 py-0.5 rounded ring-1 ring-slate-500/50">CHAÎNE</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded" *ngIf="v.duration && v.duration > 0">
|
<div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded" *ngIf="v.duration && v.duration > 0">
|
||||||
{{ v.duration | duration }}
|
{{ v.duration | duration }}
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="isLiveCard(v, b.provider) && v.views > 0" class="absolute bottom-2 left-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded">
|
<div *ngIf="isLiveCard(v) && v.views > 0" class="absolute bottom-2 left-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded">
|
||||||
{{ v.views | number }} spectateurs
|
{{ v.views | number }} spectateurs
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute bottom-2 left-2" *ngIf="!isLiveCard(v, b.provider)">
|
<div class="absolute bottom-2 left-2" *ngIf="!isLiveCard(v)">
|
||||||
<app-like-button [videoId]="v.videoId" [provider]="b.provider" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
|
<app-like-button [videoId]="v.videoId" [provider]="b.provider" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ import { LikeButtonComponent } from '../shared/components/like-button/like-butto
|
|||||||
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
||||||
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
|
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
|
||||||
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||||
import { classifyVideo, isLiveVideo, isShortVideo } from '../../app/shared/utils/video-kind';
|
import { sectionAccepts, classifyOnce, type WithKind } from '../../app/shared/utils/section-policy';
|
||||||
|
import { providerSupportsLive } from '../../app/core/providers/provider-registry';
|
||||||
|
|
||||||
interface ProviderBlock {
|
interface ProviderBlock {
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
label: string;
|
label: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
items: Video[];
|
items: WithKind<Video>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -48,42 +49,37 @@ export class ThemePageComponent {
|
|||||||
this.contentFilter.set(v);
|
this.contentFilter.set(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fournisseurs capables de fournir de vrais directs. */
|
/** Fournisseurs capables de fournir de vrais directs (source unique = registre). */
|
||||||
private static readonly LIVE_PROVIDERS: Provider[] = ['youtube', 'twitch'];
|
readonly providerSupportsLive = providerSupportsLive;
|
||||||
|
|
||||||
isLiveTheme(): boolean {
|
isLiveTheme(): boolean {
|
||||||
return this.themeSlug() === 'live';
|
return this.themeSlug() === 'live';
|
||||||
}
|
}
|
||||||
|
|
||||||
isLiveProvider(p: Provider): boolean {
|
/** Enrichit une page : `_kind` calculé UNE fois à l'ingestion. */
|
||||||
return (ThemePageComponent.LIVE_PROVIDERS as Provider[]).includes(p);
|
private enrich(items: Video[], provider: Provider): WithKind<Video>[] {
|
||||||
}
|
return (items || []).map(v =>
|
||||||
|
classifyOnce({ ...(v as object), provider: (v.provider as string) || provider } as Video)
|
||||||
/** Items du bloc après application du filtre Vidéos/Shorts (+ LIVE seulement sur thème live). */
|
|
||||||
filteredItems(b: ProviderBlock): Video[] {
|
|
||||||
let items = b.items;
|
|
||||||
// Thème "En direct" : ne jamais mélanger avec des VODs (garde-fou si une API renvoie du non-live).
|
|
||||||
if (this.themeSlug() === 'live') {
|
|
||||||
items = items.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || b.provider }));
|
|
||||||
}
|
|
||||||
const f = this.contentFilter();
|
|
||||||
if (f === 'all') return items;
|
|
||||||
return items.filter(v =>
|
|
||||||
f === 'shorts' ? isShortVideo({ ...v, provider: (v.provider as string) || b.provider }) : !isShortVideo({ ...v, provider: (v.provider as string) || b.provider })
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
kindOf(v: Video, provider: Provider): string {
|
/** Items du bloc après filtre Vidéos/Shorts (+ LIVE seulement sur thème live). */
|
||||||
return classifyVideo({ ...v, provider: (v.provider as string) || provider });
|
filteredItems(b: ProviderBlock): Video[] {
|
||||||
|
let items: Video[] = b.items;
|
||||||
|
// Thème "En direct" : ne jamais mélanger avec des VODs (garde-fou si une API renvoie du non-live).
|
||||||
|
if (this.themeSlug() === 'live') {
|
||||||
|
items = items.filter(v => sectionAccepts(v, 'theme-live'));
|
||||||
|
}
|
||||||
|
const f = this.contentFilter();
|
||||||
|
if (f === 'all') return items;
|
||||||
|
return items.filter(v => f === 'shorts'
|
||||||
|
? sectionAccepts(v, 'theme-shorts')
|
||||||
|
: sectionAccepts(v, 'theme-videos'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LIVE seulement si réellement en direct (les chaînes hors-ligne ne le sont pas). */
|
/** LIVE seulement si réellement en direct (les chaînes hors-ligne ne le sont pas). */
|
||||||
isLiveCard(v: Video, provider: Provider): boolean {
|
isLiveCard(v: Video): boolean {
|
||||||
if ((v as any).isLive === true) return true;
|
return v._kind === 'live' && !this.isChannelCard(v);
|
||||||
if (String(v.type || '').toLowerCase() === 'live') return true;
|
|
||||||
if (String(v.type || '').toLowerCase() === 'channel') return false;
|
|
||||||
return isLiveVideo({ ...v, provider: (v.provider as string) || provider })
|
|
||||||
&& String(v.type || '').toLowerCase() !== 'channel';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isChannelCard(v: Video): boolean {
|
isChannelCard(v: Video): boolean {
|
||||||
@@ -91,14 +87,13 @@ export class ThemePageComponent {
|
|||||||
return String(v.type || '').toLowerCase() === 'channel';
|
return String(v.type || '').toLowerCase() === 'channel';
|
||||||
}
|
}
|
||||||
|
|
||||||
isClipCard(v: Video, provider: Provider): boolean {
|
isClipCard(v: Video): boolean {
|
||||||
if (this.isLiveCard(v, provider) || this.isChannelCard(v)) return false;
|
if (this.isLiveCard(v) || this.isChannelCard(v)) return false;
|
||||||
if (String((v as any).kind || '').toLowerCase() === 'clip') return true;
|
return v._kind === 'short';
|
||||||
return isShortVideo({ ...v, provider: (v.provider as string) || provider });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
shortsCount(b: ProviderBlock): number {
|
shortsCount(b: ProviderBlock): number {
|
||||||
return b.items.filter(v => isShortVideo({ ...v, provider: (v.provider as string) || b.provider })).length;
|
return b.items.filter(v => v._kind === 'short').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -137,7 +132,7 @@ export class ThemePageComponent {
|
|||||||
|
|
||||||
private fetchForProvider(block: ProviderBlock, slug: string) {
|
private fetchForProvider(block: ProviderBlock, slug: string) {
|
||||||
// Thème "En direct" : les fournisseurs sans live n'affichent aucune vidéo.
|
// Thème "En direct" : les fournisseurs sans live n'affichent aucune vidéo.
|
||||||
if (slug === 'live' && !this.isLiveProvider(block.provider)) {
|
if (slug === 'live' && !providerSupportsLive(block.provider)) {
|
||||||
block.loading = false;
|
block.loading = false;
|
||||||
block.error = null;
|
block.error = null;
|
||||||
block.items = [];
|
block.items = [];
|
||||||
@@ -168,7 +163,7 @@ export class ThemePageComponent {
|
|||||||
if (slug === 'trending') {
|
if (slug === 'trending') {
|
||||||
this.api.getTrendingPage(null, block.provider).subscribe({
|
this.api.getTrendingPage(null, block.provider).subscribe({
|
||||||
next: res => {
|
next: res => {
|
||||||
block.items = (res.items || []).slice(0, max);
|
block.items = this.enrich(res.items || [], block.provider).slice(0, max);
|
||||||
block.loading = false;
|
block.loading = false;
|
||||||
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug });
|
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug });
|
||||||
this.blocks.update(a => [...a]);
|
this.blocks.update(a => [...a]);
|
||||||
@@ -195,8 +190,8 @@ export class ThemePageComponent {
|
|||||||
const mode = block.provider === 'youtube' ? 'youtube_eventType_live' : 'twitch_top_streams';
|
const mode = block.provider === 'youtube' ? 'youtube_eventType_live' : 'twitch_top_streams';
|
||||||
liveCall.subscribe({
|
liveCall.subscribe({
|
||||||
next: res => {
|
next: res => {
|
||||||
block.items = (res.items || [])
|
block.items = this.enrich(res.items || [], block.provider)
|
||||||
.filter(v => isLiveVideo({ ...v, provider: (v.provider as string) || block.provider }))
|
.filter(v => v._kind === 'live')
|
||||||
.slice(0, max);
|
.slice(0, max);
|
||||||
block.loading = false;
|
block.loading = false;
|
||||||
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode });
|
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode });
|
||||||
@@ -220,7 +215,7 @@ export class ThemePageComponent {
|
|||||||
const raw = tokens.join(' ').trim();
|
const raw = tokens.join(' ').trim();
|
||||||
const q = (raw.split(/[^A-Za-z0-9à-ÿ]+/).filter(w => w.length >= 3)[0] || raw).trim();
|
const q = (raw.split(/[^A-Za-z0-9à-ÿ]+/).filter(w => w.length >= 3)[0] || raw).trim();
|
||||||
const show = (items: Video[]) => {
|
const show = (items: Video[]) => {
|
||||||
block.items = (items || []).slice(0, max);
|
block.items = this.enrich(items || [], block.provider).slice(0, max);
|
||||||
block.loading = false;
|
block.loading = false;
|
||||||
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
|
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
|
||||||
this.blocks.update(a => [...a]);
|
this.blocks.update(a => [...a]);
|
||||||
@@ -254,7 +249,7 @@ export class ThemePageComponent {
|
|||||||
// fallback to trending
|
// fallback to trending
|
||||||
this.api.getTrendingPage(null, block.provider).subscribe({
|
this.api.getTrendingPage(null, block.provider).subscribe({
|
||||||
next: res => {
|
next: res => {
|
||||||
block.items = (res.items || []).slice(0, max);
|
block.items = this.enrich(res.items || [], block.provider).slice(0, max);
|
||||||
block.loading = false;
|
block.loading = false;
|
||||||
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode: 'fallback_trending' });
|
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, mode: 'fallback_trending' });
|
||||||
this.blocks.update(a => [...a]);
|
this.blocks.update(a => [...a]);
|
||||||
@@ -272,7 +267,7 @@ export class ThemePageComponent {
|
|||||||
} else {
|
} else {
|
||||||
this.api.searchVideosPage(q, null, block.provider).subscribe({
|
this.api.searchVideosPage(q, null, block.provider).subscribe({
|
||||||
next: res => {
|
next: res => {
|
||||||
block.items = (res.items || []).slice(0, max);
|
block.items = this.enrich(res.items || [], block.provider).slice(0, max);
|
||||||
block.loading = false;
|
block.loading = false;
|
||||||
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
|
if (!block.items.length) console.info('[ThemePage] Empty block page', { provider: block.provider, theme: slug, query: q });
|
||||||
this.blocks.update(a => [...a]);
|
this.blocks.update(a => [...a]);
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export interface Video {
|
|||||||
isShort?: boolean; // indicateur natif si disponible
|
isShort?: boolean; // indicateur natif si disponible
|
||||||
isLive?: boolean; // true si diffusion en direct (YouTube eventType=live, Twitch streams)
|
isLive?: boolean; // true si diffusion en direct (YouTube eventType=live, Twitch streams)
|
||||||
kind?: 'clip'|'vod'|'short'|'channel'; // ex. Twitch=clip
|
kind?: 'clip'|'vod'|'short'|'channel'; // ex. Twitch=clip
|
||||||
raw?: any; // métadonnées brutes si besoin
|
raw?: any; // m�tadonn�es brutes si besoin
|
||||||
|
// Classification pr�-calcul�e � l'ingestion (cf. section-policy.classifyOnce).
|
||||||
|
_kind?: 'short'|'live'|'video';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoDetail extends Video {
|
export interface VideoDetail extends Video {
|
||||||
|
|||||||
Reference in New Issue
Block a user