feat: détection short par orientation vidéo
CI / build-and-test (push) Successful in 14m6s

Règle partagée front/back : flag natif, verticale stricte (ratio ≤ 0.8, ≤ 90 s), verticale sans durée jamais short, repli durée (70 YT / 75 autres). Dimensions alimentées par Dailymotion/Odysee/PeerTube, flag maison Rumble supprimé, condition innertube morte simplifiée. Shorts : langues utilisateur (UI + transcripts) via relevanceLanguage/languageOneOf/Twitch language, son activé par défaut.
This commit is contained in:
2026-09-29 12:17:10 -04:00
parent 3f4cd56f14
commit f443a470f9
22 changed files with 454 additions and 73 deletions
+60 -12
View File
@@ -5,6 +5,7 @@ import { catchError, map, switchMap, shareReplay, first, tap, timeout } from 'rx
import { Video, VideoDetail, Comment } from '../models/video.model';
import { InstanceService, Provider } from './instance.service';
import { I18nService } from './i18n.service';
import { UserService } from './user.service';
export interface PagedResult<T> {
items: T[];
@@ -33,11 +34,45 @@ export class YoutubeApiService {
try { const v = Number((globalThis as any)?.YT_KEY_BAN_MS); return Number.isFinite(v) && v > 0 ? v : 6 * 60 * 60 * 1000; } catch { return 6 * 60 * 60 * 1000; }
})();
constructor(private http: HttpClient, private instances: InstanceService, private i18n: I18nService) {
constructor(private http: HttpClient, private instances: InstanceService, private i18n: I18nService, private users: UserService) {
this.getTwitchAuthToken().subscribe(this.twitchAuthToken$);
this.ytKeys = this.loadYouTubeKeys();
}
/**
* Langues acceptées pour les Shorts : langue d'interface + langue par
* défaut des préférences + langues de transcripts (`downloadLanguages`).
* Ex. UI en anglais + transcripts fr/en ⇒ ['en', 'fr'] : les Shorts sont
* cherchés dans ces langues, jamais dans d'autres quand l'API le permet
* (YouTube relevanceLanguage, PeerTube languageOneOf, Twitch language).
*/
public getShortsLanguages(): string[] {
const out: string[] = [];
const push = (v: unknown) => {
const base = String(v || '').trim().toLowerCase().split('-')[0];
if (/^[a-z]{2,3}$/.test(base) && !out.includes(base)) out.push(base);
};
try { push(this.i18n.lang()); } catch {}
try {
const prefs = this.users.preferences() as { language?: unknown; downloadLanguages?: unknown } | null;
push((prefs as any)?.language);
const dl = (prefs as any)?.downloadLanguages;
if (Array.isArray(dl)) for (const l of dl) push(l);
} catch {}
return out.length ? out : ['en'];
}
/** Langue primaire (2 lettres) pour les APIs qui n'acceptent qu'un code ISO 639-1. */
private primaryShortsLang2(): string | null {
try {
const langs = this.getShortsLanguages();
const two = langs.find(l => l.length === 2);
return two || null;
} catch {
return null;
}
}
private normalizeDuration(raw: any): number {
if (typeof raw === 'number' && Number.isFinite(raw)) {
return raw > 0 ? Math.round(raw) : 0;
@@ -263,8 +298,9 @@ export class YoutubeApiService {
if (!token) return of({ items: [], nextCursor: null });
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const primary = `/api/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}`;
const fallback = `/proxy/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}`;
const langParam = this.primaryShortsLang2() ? `&language=${encodeURIComponent(this.primaryShortsLang2() as string)}` : '';
const primary = `/api/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}${langParam}`;
const fallback = `/proxy/twitch-api/helix/streams?game_id=${encodeURIComponent(gameId)}&first=${limit}${after}${langParam}`;
return this.http.get<any>(primary, { headers }).pipe(
catchError(() => this.http.get<any>(fallback, { headers })),
map(res => ({
@@ -357,6 +393,8 @@ export class YoutubeApiService {
}
}
const userIds: string[] = channels.map((c: any) => c?.id).filter(Boolean).slice(0, 6);
// Langue de l'utilisateur (filtre natif Helix /videos).
const vodLang = this.primaryShortsLang2() ? `&language=${encodeURIComponent(this.primaryShortsLang2() as string)}` : '';
const fetchVodsForUsers = (ids: string[]) => {
if (!ids.length) return of([] as any[]);
// Fan-out réduit : 6 chaînes × 6 VODs suffisent, le budget
@@ -364,8 +402,8 @@ export class YoutubeApiService {
// remercient (une page Explore en lance déjà 4 sections).
const perUserRequests = ids.slice(0, 6).map(uid => {
// type=all to include archive, upload, highlight; fetch a few more to increase hit rate
const vPrimary = `/api/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all`;
const vFallback = `/proxy/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all`;
const vPrimary = `/api/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all${vodLang}`;
const vFallback = `/proxy/twitch-api/helix/videos?user_id=${encodeURIComponent(uid)}&first=6&type=all${vodLang}`;
return this.http.get<any>(vPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(vFallback, { headers })),
map(vres => Array.isArray(vres?.data) ? vres.data : []),
@@ -390,8 +428,8 @@ export class YoutubeApiService {
}
if (!gameIds.length) return of([]);
const gameCalls = gameIds.slice(0, 2).map(gid => {
const gPrimary = `/api/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all`;
const gFallback = `/proxy/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all`;
const gPrimary = `/api/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all${vodLang}`;
const gFallback = `/proxy/twitch-api/helix/videos?game_id=${encodeURIComponent(gid)}&first=10&sort=trending&type=all${vodLang}`;
return this.http.get<any>(gPrimary, { headers }).pipe(
catchError(() => this.http.get<any>(gFallback, { headers })),
map(vres => Array.isArray(vres?.data) ? vres.data : []),
@@ -1005,11 +1043,12 @@ export class YoutubeApiService {
key: String(key),
});
try {
const lang = String(this.i18n.lang()).toLowerCase();
// Langue de l'utilisateur en priorité (relevanceLanguage YouTube).
const lang = this.getShortsLanguages()[0];
if (lang) params.set('relevanceLanguage', lang);
} catch {}
if (cursor) params.set('pageToken', cursor);
const cacheKey = `yt:shorts|${region}|${q}|${cursor || ''}`;
const cacheKey = `yt:shorts|${region}|${q}|${this.getShortsLanguages().join(',')}|${cursor || ''}`;
const cached = this.searchCache.get(cacheKey);
const now = Date.now();
if (cached && (now - cached.ts) < this.CACHE_TTL_MS) {
@@ -1120,6 +1159,10 @@ export class YoutubeApiService {
isLocal: 'true',
nsfw: 'false',
});
// Langues de l'utilisateur (filtre natif PeerTube, ignoré si inconnu).
try {
for (const l of this.getShortsLanguages()) params.append('languageOneOf', l);
} catch {}
if (q) params.set('search', q);
const primary = `/api/peertube/${instance}/api/v1/videos?${params.toString()}`;
const fallback = `/proxy/api/peertube/${instance}/api/v1/videos?${params.toString()}`;
@@ -1321,7 +1364,8 @@ export class YoutubeApiService {
} as Video;
};
// Twitch Mappers
// Twitch Mappers (la langue Helix est conservée dans `raw` pour le
// filtre langue des Shorts : l'endpoint /clips n'a pas de param langue).
private mapTwitchItemToVideo = (i: any): Video => ({
url: `/watch/${i.id}`,
videoId: i.id || '',
@@ -1342,6 +1386,7 @@ export class YoutubeApiService {
views: Number(i.view_count || i.viewer_count || 0),
uploaded: i.created_at ? Date.parse(i.created_at) : 0,
isLive: false,
raw: { language: i?.language || undefined },
});
// Twitch Streams mapper (for trending popular live streams)
@@ -1433,6 +1478,7 @@ export class YoutubeApiService {
views: Number(i.view_count || 0),
uploaded: i.created_at ? Date.parse(i.created_at) : 0,
isLive: false,
raw: { language: i?.language || undefined },
});
/**
@@ -1547,8 +1593,10 @@ export class YoutubeApiService {
if (!token) return this.getTwitchServerTopStreams(limit);
const headers = new HttpHeaders({ 'Client-ID': this.resolveTwitchClientId()!, 'Authorization': `Bearer ${token}` });
const after = cursor ? `&after=${encodeURIComponent(cursor)}` : '';
const primary = `/api/twitch-api/helix/streams?first=${limit}${after}`;
const fallback = `/proxy/twitch-api/helix/streams?first=${limit}${after}`;
// Langue de l'utilisateur (filtre natif Helix /streams).
const langParam = this.primaryShortsLang2() ? `&language=${encodeURIComponent(this.primaryShortsLang2() as string)}` : '';
const primary = `/api/twitch-api/helix/streams?first=${limit}${after}${langParam}`;
const fallback = `/proxy/twitch-api/helix/streams?first=${limit}${after}${langParam}`;
return this.http.get<any>(primary, { headers }).pipe(
catchError(() => this.http.get<any>(fallback, { headers })),
map(res => ({