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:
@@ -13,10 +13,7 @@ import { DownloadService } from '../../services/download.service';
|
||||
import { SubscriptionsService, GROUP_ICON_KEYWORDS } from '../../services/subscriptions.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { Video } from '../../models/video.model';
|
||||
import { isShortVideo, YOUTUBE_SHORT_MAX_SECONDS } from '../../app/shared/utils/video-kind';
|
||||
|
||||
/** Durée max d'un Short Rumble natif (rumble.com/shorts : vertical ≤ 90 s). */
|
||||
const RUMBLE_SHORT_MAX_SECONDS = 90;
|
||||
import { shortsFeedAccepts, isPlayable as isPlayablePolicy, type PlayableContext } from '../../app/shared/utils/section-policy';
|
||||
|
||||
/** Provider long (youtube…) -> id court d'abonnement ('yt'…). */
|
||||
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);
|
||||
try {
|
||||
const durations = await firstValueFrom(this.api.getYouTubeDurations(ids));
|
||||
return list.filter(v => {
|
||||
const d = durations?.[v.videoId] ?? 0;
|
||||
return d > 0 && d <= YOUTUBE_SHORT_MAX_SECONDS;
|
||||
});
|
||||
// Durée issue de videos.list appliquée à l'item, puis classification centrale.
|
||||
return list.filter(v => shortsFeedAccepts(
|
||||
{ ...(v as object), duration: durations?.[v.videoId] ?? 0, provider: 'youtube' } as Video,
|
||||
'youtube',
|
||||
));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -390,15 +388,13 @@ export class WatchShortComponent implements OnDestroy {
|
||||
* Les durées -1 (= non-embeddable, erreur 153) sont toujours exclues.
|
||||
*/
|
||||
private async pickYouTubeShorts(list: Video[]): Promise<Video[]> {
|
||||
const shorts = list.filter(v =>
|
||||
typeof v.duration === 'number' && v.duration > 0 && v.duration <= YOUTUBE_SHORT_MAX_SECONDS
|
||||
);
|
||||
const shorts = list.filter(v => shortsFeedAccepts({ ...(v as object), provider: 'youtube' } as Video, 'youtube'));
|
||||
const allKnown = list.every(v => typeof v.duration === 'number' && v.duration !== 0);
|
||||
if (shorts.length > 0 || allKnown) return shorts;
|
||||
// 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 fetched = await this.filterYouTubeShorts(missing);
|
||||
return [...shorts, ...fetched.filter(v => this.isShort(v))];
|
||||
return [...shorts, ...fetched.filter(v => this.accepts(v))];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@@ -815,7 +811,7 @@ export class WatchShortComponent implements OnDestroy {
|
||||
const byProvider: Video[][] = defs.map((d, i) => {
|
||||
const raw = ((pages[i]?.items || []) as Video[]).filter(v => !!v?.videoId);
|
||||
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))
|
||||
);
|
||||
// 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 }) => {
|
||||
const raw: Video[] = (res.items || []).filter((v: Video) => !!v.videoId);
|
||||
// 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) => {
|
||||
const list: Video[] = this.dedup(filtered);
|
||||
this.items.set(list);
|
||||
@@ -966,7 +962,7 @@ export class WatchShortComponent implements OnDestroy {
|
||||
this.api.searchTwitchClipsPage(q)?.subscribe({
|
||||
next: (res) => {
|
||||
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).
|
||||
this.twitchShortsReady(shorts).subscribe({
|
||||
next: (filtered) => {
|
||||
@@ -1021,7 +1017,7 @@ export class WatchShortComponent implements OnDestroy {
|
||||
};
|
||||
if (this.provider() === 'youtube' && raw.length) {
|
||||
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);
|
||||
else if (allowAltQuery) this.tryNextShortsQueryOrTrending();
|
||||
else this.loadTrendingFallback();
|
||||
@@ -1031,7 +1027,7 @@ export class WatchShortComponent implements OnDestroy {
|
||||
});
|
||||
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);
|
||||
else if (allowAltQuery) this.tryNextShortsQueryOrTrending();
|
||||
else this.loadTrendingFallback();
|
||||
@@ -1067,9 +1063,9 @@ export class WatchShortComponent implements OnDestroy {
|
||||
this.loading.set(false);
|
||||
};
|
||||
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 {
|
||||
apply(raw.filter(v => this.isShort(v)));
|
||||
apply(raw.filter(v => this.accepts(v)));
|
||||
}
|
||||
},
|
||||
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
|
||||
// scraper renvoie 0) afin de ne jamais afficher une page vide.
|
||||
private isShort(v: Video): boolean {
|
||||
const p = (v as any).provider || this.provider();
|
||||
// Dailymotion : embed désactivé par le propriétaire (allow_embed=false)
|
||||
// => le player répond "Forbidden" : on exclut avant même l'embed.
|
||||
if (String(p) === 'dailymotion' && (v as any)?.raw?.allowEmbed === false) return false;
|
||||
if (String(p) === 'twitch') {
|
||||
const kind = String((v as any).kind || '').toLowerCase();
|
||||
if (kind === 'clip') return true;
|
||||
// Les lives/channels ne sont jamais des Shorts.
|
||||
const type = String((v as any).type || '').toLowerCase();
|
||||
if (type === 'live' || type === 'channel' || type === 'stream') return false;
|
||||
/**
|
||||
* Le feed accepte-t-il cet item ? Séparation stricte :
|
||||
* - classification : `shortsFeedAccepts` (centre video-kind/section-policy,
|
||||
* tolérance « durée inconnue » Rumble/Odysee incluse) ;
|
||||
* - jouabilité : `isPlayable` (allowEmbed, lives/chaînes Twitch, Rumble 404).
|
||||
*/
|
||||
private accepts(v: Video): boolean {
|
||||
const p = String((v as any).provider || this.provider()).toLowerCase();
|
||||
const withProvider = { ...(v as object), provider: p } as Video;
|
||||
return shortsFeedAccepts(withProvider, p) && this.isPlayable(withProvider);
|
||||
}
|
||||
|
||||
/** Jouabilité : l'embed répondra-t-il ? (indépendant du type de contenu) */
|
||||
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);
|
||||
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]);
|
||||
return isPlayablePolicy(v, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1267,16 +1255,16 @@ export class WatchShortComponent implements OnDestroy {
|
||||
this.busyMore.set(false);
|
||||
};
|
||||
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) {
|
||||
// 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({
|
||||
next: (clean) => apply(clean),
|
||||
error: () => apply(shorts),
|
||||
});
|
||||
} else {
|
||||
apply(raw.filter((v: Video) => this.isShort(v)));
|
||||
apply(raw.filter((v: Video) => this.accepts(v)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user