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:
2026-09-26 22:50:53 -04:00
parent 050cea3a92
commit 5a19f8f11f
5 changed files with 132 additions and 25 deletions
+29 -7
View File
@@ -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) {