feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s

- Strategy Pattern front: ChannelProviderInterface + 6 providers (yt/dm/tw/pt/od/ru) + factory (ids longs/courts)
- ChannelContentService: cache par onglet/tri/recherche (TTL 5min), pagination nextPage
- ChannelPage: header (banniere/avatar/subs/description ...plus), tabs dynamiques par capabilities, grilles videos/shorts 9:16/playlists/live, recherche debounced, tri recent/populaire, infinite scroll IntersectionObserver, skeletons/empty/error
- Backend: GET /api/channels/:provider/:id/content (videos/shorts/playlists/live) natif par provider
This commit is contained in:
2026-09-25 10:15:12 -04:00
parent fe35340948
commit 97e5de74f5
147 changed files with 7954 additions and 77497 deletions
+64
View File
@@ -0,0 +1,64 @@
/**
* Classification partagée Vidéo / Short / Live.
*
* Règles (alignées front + back) :
* - `isShort === true` (flag natif) ⇒ Short, sauf durée connue > 75 s.
* - Twitch `kind === 'clip'` ⇒ Short (les clips font ≤ 60 s par construction).
* - YouTube : URL `/shorts/` ⇒ Short ; sinon durée 1..70 s ⇒ Short.
* - Autres providers : durée 1..75 s ⇒ Short.
* - IMPORTANT : une durée `0` / absente ne qualifie JAMAIS un Short
* (les scrapers renvoient 0 quand la durée est inconnue).
*/
export type VideoKind = 'short' | 'live' | 'video';
export interface ClassifiableVideo {
duration?: number | null;
type?: string | null;
kind?: string | null;
url?: string | null;
provider?: string | null;
isShort?: boolean | null;
isLive?: boolean | null;
isVertical?: boolean | null;
}
export const SHORT_MAX_SECONDS = 75;
export const YOUTUBE_SHORT_MAX_SECONDS = 70;
export function isShortVideo(v: ClassifiableVideo | null | undefined): boolean {
if (!v) return false;
const duration = Number((v as { duration?: unknown }).duration ?? 0);
const hasKnownDuration = Number.isFinite(duration) && duration > 0;
const type = String((v as { type?: unknown }).type || '').toLowerCase();
const kind = String((v as { kind?: unknown }).kind || '').toLowerCase();
const url = String((v as { url?: unknown }).url || '');
const provider = String((v as { provider?: unknown }).provider || '').toLowerCase();
// Clips Twitch = équivalent Shorts (durée max 60 s côté Twitch).
if (kind === 'clip') return true;
if (type === 'short') return true;
// Flag natif (Rumble router, YouTube, …) — ignoré si la durée connue le contredit.
if ((v as { isShort?: unknown }).isShort === true) {
if (hasKnownDuration && duration > SHORT_MAX_SECONDS) return false;
return true;
}
if (!hasKnownDuration) return false;
if (url.includes('/shorts/')) return true;
if (provider === 'youtube') return duration <= YOUTUBE_SHORT_MAX_SECONDS;
return duration <= SHORT_MAX_SECONDS;
}
export function isLiveVideo(v: ClassifiableVideo | null | undefined): boolean {
if (!v) return false;
if ((v as { isLive?: unknown }).isLive === true) return true;
const type = String((v as { type?: unknown }).type || '').toLowerCase();
return type === 'live' || type === 'stream' || type === 'channel';
}
export function classifyVideo(v: ClassifiableVideo | null | undefined): VideoKind {
if (isShortVideo(v)) return 'short';
if (isLiveVideo(v)) return 'live';
return 'video';
}