fix(sync): 403 InnerTube + WL manquant via fallbacks
- InnerTube 403: la YOUTUBE_API_KEY restreinte par referrer etait rejetee sans Referer envoye; ajout headers navigateur + repli cle publique du client web (probe live: 401 propre = transport OK) - Watch Later: repli lecture via InnerTube (browseId WL, sans ID) quand l'API Data ne renvoie pas d'ID; repli push via editPlaylist; note 'via' affichee dans l'UI; debug cles relatedPlaylists conserve
This commit is contained in:
+29
-7
@@ -106,6 +106,7 @@ import {
|
||||
refreshAccessToken, redirectUriFor, fetchGoogleProfile, fetchGoogleSubscriptions,
|
||||
fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, hasGoogleWriteScope,
|
||||
fetchGoogleWatchLater, pushGoogleWatchLater, fetchInnerTubeHistory,
|
||||
fetchInnerTubeWatchLater, pushInnerTubeWatchLater, fetchGoogleWatchLaterId,
|
||||
} from './oauth.mjs';
|
||||
import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs';
|
||||
import { ytScrapeCacheStats } from './providers/youtube.mjs';
|
||||
@@ -660,11 +661,23 @@ r.post('/oauth/:provider/import', authMiddlewareCookieAware, oauthLimiter, async
|
||||
r.get('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
|
||||
try {
|
||||
const conn = await freshOAuthToken(req.user.id, 'google');
|
||||
const { items } = await fetchGoogleWatchLater(conn.accessToken, 50);
|
||||
return res.json({
|
||||
items, count: items.length,
|
||||
writeGranted: hasGoogleWriteScope(conn.scopes),
|
||||
});
|
||||
const writeGranted = hasGoogleWriteScope(conn.scopes);
|
||||
try {
|
||||
const { items } = await fetchGoogleWatchLater(conn.accessToken, 50);
|
||||
return res.json({ items, count: items.length, writeGranted, via: 'api' });
|
||||
} catch (apiError) {
|
||||
// L'API Data ne renvoie pas d'ID WL pour certains comptes : repli
|
||||
// InnerTube (playlist logique "WL", aucun ID requis).
|
||||
if (apiError?.message !== 'watchlater_not_found' && apiError?.message !== 'youtube_no_channel') throw apiError;
|
||||
try {
|
||||
console.warn(`[oauth] WL Data API indisponible (${apiError.message}), repli InnerTube`);
|
||||
} catch {}
|
||||
const { items } = await fetchInnerTubeWatchLater(conn.accessToken, innertubeApiKey(), 100);
|
||||
return res.json({
|
||||
items, count: items.length, writeGranted, via: 'innertube',
|
||||
note: 'Liste lue via InnerTube (l’API YouTube ne l’expose pas pour ce compte).',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const out = { error: error?.message || 'watchlater_fetch_failed' };
|
||||
if (error?.hint) out.hint = error.hint;
|
||||
@@ -719,15 +732,24 @@ r.post('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, asyn
|
||||
const ids = Array.isArray(req.body?.videoIds) ? req.body.videoIds : [];
|
||||
const clean = [...new Set(ids.map((v) => String(v || '').trim()).filter(Boolean))].slice(0, 50);
|
||||
if (!clean.length) return res.status(400).json({ error: 'videoIds_required' });
|
||||
// ID WL via Data API, sinon repli InnerTube (playlist logique "WL").
|
||||
let useInnertube = false;
|
||||
try {
|
||||
await fetchGoogleWatchLaterId(conn.accessToken);
|
||||
} catch (e) {
|
||||
if (e?.message === 'watchlater_not_found' || e?.message === 'youtube_no_channel') useInnertube = true;
|
||||
else throw e;
|
||||
}
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const videoId of clean) {
|
||||
try {
|
||||
await pushGoogleWatchLater(conn.accessToken, videoId);
|
||||
if (useInnertube) await pushInnerTubeWatchLater(conn.accessToken, innertubeApiKey(), videoId);
|
||||
else await pushGoogleWatchLater(conn.accessToken, videoId);
|
||||
added++;
|
||||
} catch { skipped++; }
|
||||
}
|
||||
return res.json({ added, skipped, total: clean.length });
|
||||
return res.json({ added, skipped, total: clean.length, via: useInnertube ? 'innertube' : 'api' });
|
||||
} catch (error) {
|
||||
const status = error?.status || 502;
|
||||
if (status === 403) {
|
||||
|
||||
+99
-17
@@ -470,9 +470,63 @@ function findHistoryContinuation(data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchInnerTubeHistory(accessToken, apiKey, max = 200) {
|
||||
const key = String(apiKey || '').trim();
|
||||
if (!key) throw Object.assign(new Error('youtube_api_key_unavailable'), { status: 503 });
|
||||
/**
|
||||
* Appel youtubei/v1/* authentifié (Bearer OAuth utilisateur).
|
||||
* La clé YOUTUBE_API_KEY est souvent restreinte par referrer HTTP : l'appel
|
||||
* serveur (sans Referer) est alors rejeté en 403. On envoie un Referer
|
||||
* navigateur et on bascule sur la clé publique du client web en repli.
|
||||
*/
|
||||
const PUBLIC_INNERTUBE_KEY = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8';
|
||||
|
||||
function innertubeKeys(apiKey) {
|
||||
const keys = [String(apiKey || '').trim(), PUBLIC_INNERTUBE_KEY].filter(Boolean);
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
|
||||
async function innertubePost(path, apiKey, accessToken, body, label = 'innertube') {
|
||||
const keys = innertubeKeys(apiKey);
|
||||
if (!keys.length) throw Object.assign(new Error('youtube_api_key_unavailable'), { status: 503 });
|
||||
let lastErr = null;
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const resp = await fetch(`https://www.youtube.com/youtubei/v1/${path}?key=${encodeURIComponent(key)}&prettyPrint=false`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
// Sans Referer/UA navigateur, les clés restreintes et l'anti-bot répondent 403.
|
||||
Referer: 'https://www.youtube.com/',
|
||||
Origin: 'https://www.youtube.com',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
const err = new Error(`${label}_failed_${resp.status}`);
|
||||
err.status = resp.status === 401 ? 401 : resp.status;
|
||||
err.details = data?.error || undefined;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
// 403 = clé rejetée (restriction referrer) ou IP filtrée : on essaie la clé suivante.
|
||||
// 401 = token invalide : inutile de réessayer.
|
||||
if (e?.status === 401) throw e;
|
||||
if (e?.status !== 403) throw e;
|
||||
}
|
||||
}
|
||||
throw lastErr || new Error(`${label}_failed`);
|
||||
}
|
||||
|
||||
function innertubeContext() {
|
||||
return { client: { clientName: 'WEB', clientVersion: '2.20260101.00.00', hl: 'fr', gl: 'FR' } };
|
||||
}
|
||||
|
||||
/** Boucle browse + continuations générique (FEhistory, WL, ...). */
|
||||
async function innertubeBrowseAll(accessToken, apiKey, browseId, max, label) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
let continuation = null;
|
||||
@@ -480,20 +534,9 @@ export async function fetchInnerTubeHistory(accessToken, apiKey, max = 200) {
|
||||
while (out.length < max && pages < 10) {
|
||||
pages++;
|
||||
const body = continuation
|
||||
? { context: { client: { clientName: 'WEB', clientVersion: '2.20260101.00.00', hl: 'fr', gl: 'FR' } }, continuation }
|
||||
: { context: { client: { clientName: 'WEB', clientVersion: '2.20260101.00.00', hl: 'fr', gl: 'FR' } }, browseId: 'FEhistory' };
|
||||
const resp = await fetch(`https://www.youtube.com/youtubei/v1/browse?key=${encodeURIComponent(key)}&prettyPrint=false`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
const err = new Error(`innertube_history_failed_${resp.status}`);
|
||||
err.status = resp.status === 401 ? 401 : 502;
|
||||
throw err;
|
||||
}
|
||||
? { context: innertubeContext(), continuation }
|
||||
: { context: innertubeContext(), browseId };
|
||||
const data = await innertubePost('browse', apiKey, accessToken, body, label);
|
||||
const batch = [];
|
||||
const root = continuation
|
||||
? (data?.onResponseReceivedActions || data?.continuationContents)
|
||||
@@ -511,6 +554,45 @@ export async function fetchInnerTubeHistory(accessToken, apiKey, max = 200) {
|
||||
return { items: out.slice(0, max), hasMore: Boolean(continuation) };
|
||||
}
|
||||
|
||||
export async function fetchInnerTubeHistory(accessToken, apiKey, max = 200) {
|
||||
try {
|
||||
return await innertubeBrowseAll(accessToken, apiKey, 'FEhistory', max, 'innertube_history');
|
||||
} catch (e) {
|
||||
if (e?.message?.startsWith('innertube_history_failed_')) throw e;
|
||||
const err = new Error(`innertube_history_failed_${e?.status || 'error'}`);
|
||||
err.status = e?.status === 401 ? 401 : 502;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Regarder plus tard" via InnerTube (browseId WL logique) : repli quand
|
||||
* l'API Data ne renvoie pas d'ID (comptes de marque...). Pas d'ID requis.
|
||||
*/
|
||||
export async function fetchInnerTubeWatchLater(accessToken, apiKey, max = 100) {
|
||||
try {
|
||||
const { items, hasMore } = await innertubeBrowseAll(accessToken, apiKey, 'WL', max, 'innertube_watchlater');
|
||||
return { items, hasMore, via: 'innertube' };
|
||||
} catch (e) {
|
||||
if (e?.message?.startsWith('innertube_watchlater_failed_')) throw e;
|
||||
const err = new Error(`innertube_watchlater_failed_${e?.status || 'error'}`);
|
||||
err.status = e?.status === 401 ? 401 : 502;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ajout à "Regarder plus tard" via InnerTube (playlistId logique WL). */
|
||||
export async function pushInnerTubeWatchLater(accessToken, apiKey, videoId) {
|
||||
const data = await innertubePost('browse/editPlaylist', apiKey, accessToken, {
|
||||
context: innertubeContext(),
|
||||
actions: [{ action: 'ACTION_ADD_VIDEO', addedVideoId: String(videoId), playlistId: 'WL' }],
|
||||
}, 'innertube_watchlater_push');
|
||||
if (data?.status && String(data.status).toUpperCase() === 'FAIL') {
|
||||
throw Object.assign(new Error('innertube_watchlater_push_rejected'), { status: 502 });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// -------------------- Twitch --------------------
|
||||
|
||||
function twitchClientId() {
|
||||
|
||||
@@ -144,6 +144,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="wlError()" class="mt-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ wlError() }}</div>
|
||||
<div *ngIf="wlNote()" class="mt-2 rounded-lg border border-blue-700 bg-blue-900/20 p-2 text-xs text-blue-200">{{ wlNote() }}</div>
|
||||
<div *ngIf="wlItems().length" class="mt-2 text-sm text-slate-300">
|
||||
{{ wlItems().length }} vidéo(s) dans « Regarder plus tard » YouTube.
|
||||
<span *ngIf="!wlWriteGranted()" class="text-amber-200">Écriture non autorisée : déconnectez puis reconnectez Google pour l’activer.</span>
|
||||
|
||||
@@ -226,6 +226,7 @@ export class ImportComponent implements OnInit {
|
||||
// -------------------- Watch Later YouTube --------------------
|
||||
|
||||
wlItems = signal<Array<{ videoId: string; title: string; thumbnail?: string }>>([]);
|
||||
wlNote = signal<string | null>(null);
|
||||
wlLoading = signal(false);
|
||||
wlWriteGranted = signal(false);
|
||||
wlError = signal<string | null>(null);
|
||||
@@ -243,6 +244,7 @@ export class ImportComponent implements OnInit {
|
||||
next: (res) => {
|
||||
this.wlItems.set(res?.items || []);
|
||||
this.wlWriteGranted.set(!!res?.writeGranted);
|
||||
this.wlNote.set(res?.note || (res?.via === 'innertube' ? 'Liste lue via InnerTube.' : null));
|
||||
this.wlLoading.set(false);
|
||||
},
|
||||
error: (e) => {
|
||||
|
||||
@@ -64,7 +64,7 @@ export class OAuthImportService {
|
||||
}
|
||||
|
||||
/** Contenu "Regarder plus tard" YouTube (lecture seule OK). */
|
||||
watchLater(): Observable<{ items: Array<{ videoId: string; title: string; thumbnail?: string }>; count: number; writeGranted: boolean }> {
|
||||
watchLater(): Observable<{ items: Array<{ videoId: string; title: string; thumbnail?: string }>; count: number; writeGranted: boolean; via?: string; note?: string }> {
|
||||
return this.http.get<any>(`/api/oauth/google/watchlater`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user