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:
@@ -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 => ({
|
||||
|
||||
Reference in New Issue
Block a user