fix(sync): WL diagnostic, import-all paginé + totaux, sync auto historique InnerTube

- Watch Later: youtube_no_channel / watchlater_not_found avec hint
  actionnable + log cles relatedPlaylists (sans PII)
- Preview Google: totaux compte (subscriptionTotal/likeTotal), UI
  'X / N, l'import prend tout'; import-all paginé (subs<=1000,
  likes<=500) au lieu de 50/25
- Sync auto historique: GET /oauth/google/yt-history (browse FEhistory
  authentifie, continuations, <=500), bouton Sync auto dans carte
  Takeout, import via lots takeout existants
This commit is contained in:
2026-09-26 22:25:50 -04:00
parent 1c013452fa
commit 050cea3a92
5 changed files with 260 additions and 33 deletions
+59 -8
View File
@@ -105,7 +105,7 @@ import {
oauthStatus, buildAuthUrl, createOAuthState, consumeOAuthState, exchangeCode,
refreshAccessToken, redirectUriFor, fetchGoogleProfile, fetchGoogleSubscriptions,
fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, hasGoogleWriteScope,
fetchGoogleWatchLater, pushGoogleWatchLater,
fetchGoogleWatchLater, pushGoogleWatchLater, fetchInnerTubeHistory,
} from './oauth.mjs';
import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs';
import { ytScrapeCacheStats } from './providers/youtube.mjs';
@@ -564,11 +564,20 @@ r.get('/oauth/:provider/preview', authMiddlewareCookieAware, oauthLimiter, async
const provider = requireOAuthProvider(req.params.provider);
const conn = await freshOAuthToken(req.user.id, provider);
if (provider === 'google') {
const [subscriptions, likes] = await Promise.all([
fetchGoogleSubscriptions(conn.accessToken, 50).catch(() => []),
fetchGoogleLiked(conn.accessToken, 25).catch(() => []),
const [subsRes, likesRes] = await Promise.all([
fetchGoogleSubscriptions(conn.accessToken, 50).catch(() => ({ items: [], total: 0 })),
fetchGoogleLiked(conn.accessToken, 25).catch(() => ({ items: [], total: 0 })),
]);
return res.json({ provider, subscriptions, likes, subscriptionCount: subscriptions.length, likeCount: likes.length });
return res.json({
provider,
subscriptions: subsRes.items,
likes: likesRes.items,
subscriptionCount: subsRes.items.length,
likeCount: likesRes.items.length,
// Totaux du compte (l'aperçu n'affiche que les 50/25 premiers, l'import prend tout).
subscriptionTotal: subsRes.total,
likeTotal: likesRes.total,
});
}
const profile = await fetchTwitchProfile(conn.accessToken).catch(() => ({ id: conn.externalUserId || '' }));
const follows = await fetchTwitchFollows(conn.accessToken, profile?.id || conn.externalUserId || '', 100);
@@ -594,7 +603,8 @@ r.post('/oauth/:provider/import', authMiddlewareCookieAware, oauthLimiter, async
if (provider === 'google') {
if (wantSubs) {
const subs = await fetchGoogleSubscriptions(conn.accessToken, 50);
// Import complet paginé (l'aperçu n'en montre que 50).
const { items: subs } = await fetchGoogleSubscriptions(conn.accessToken, 1000);
for (const s of subs) {
try {
const row = upsertChannelRow({
@@ -608,7 +618,8 @@ r.post('/oauth/:provider/import', authMiddlewareCookieAware, oauthLimiter, async
}
}
if (wantLikes) {
const likes = await fetchGoogleLiked(conn.accessToken, 25);
// Import complet paginé (l'aperçu n'en montre que 25).
const { items: likes } = await fetchGoogleLiked(conn.accessToken, 500);
for (const v of likes) {
try {
likeVideo({ userId: req.user.id, provider: 'youtube', videoId: v.videoId, title: v.title, thumbnail: v.thumbnail });
@@ -655,7 +666,47 @@ r.get('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, async
writeGranted: hasGoogleWriteScope(conn.scopes),
});
} catch (error) {
return res.status(error?.status || 502).json({ error: error?.message || 'watchlater_fetch_failed' });
const out = { error: error?.message || 'watchlater_fetch_failed' };
if (error?.hint) out.hint = error.hint;
return res.status(error?.status || 502).json(out);
}
});
// -------------------- Google : historique auto via InnerTube authentifié --------------------
// L'API Data v3 n'expose pas l'historique : on lit youtubei/v1/browse
// (FEhistory) avec le Bearer OAuth de l'utilisateur (mécanisme SmartTube).
// Le client réutilise ensuite POST /user/history/takeout pour l'importer.
function innertubeApiKey() {
const single = String(process.env.YOUTUBE_API_KEY || '').trim();
if (single && !single.includes(',')) return single;
const csv = String(process.env.YOUTUBE_API_KEYS || '').trim();
try {
if (csv.startsWith('[')) {
const arr = JSON.parse(csv);
if (Array.isArray(arr) && arr[0]) return String(arr[0]);
}
} catch {}
if (csv) {
const first = csv.split(',').map((s) => s.trim()).filter(Boolean)[0];
if (first) return first;
}
if (single) return single.split(',').map((s) => s.trim()).filter(Boolean)[0] || '';
return '';
}
r.get('/oauth/google/yt-history', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
try {
const conn = await freshOAuthToken(req.user.id, 'google');
const max = Math.min(500, Math.max(1, Number(req.query.max || 200)));
const { items, hasMore } = await fetchInnerTubeHistory(conn.accessToken, innertubeApiKey(), max);
return res.json({ items, count: items.length, hasMore });
} catch (error) {
const out = { error: error?.message || 'ythistory_fetch_failed' };
if (error?.status === 401) {
out.hint = 'Session Google expirée : déconnectez puis reconnectez Google.';
}
return res.status(error?.status || 502).json(out);
}
});