feat(sync): phase 1 - import Takeout + Watch Later YouTube bidirectionnel

- Takeout: POST /user/history/takeout (lots <=1000, validation videoId,
  upsert sans doublons), parsing local watch-history.json cote client
  (vie privee, dedup derniere date), progression par lots de 500
- Watch Later: GET/POST /oauth/google/watchlater (lecture readonly,
  push force-ssl), import vers playlist NewTube 'Watch Later (YouTube)',
  export playlist NewTube -> WL YouTube, writeGranted + invite reconnect
- Scopes Google elargis (youtube.force-ssl), aide /library/import a jour
  (limite historique API documentee)
This commit is contained in:
2026-09-26 21:58:28 -04:00
parent bb203f405c
commit 1c013452fa
5 changed files with 447 additions and 4 deletions
+78 -1
View File
@@ -104,7 +104,8 @@ import { fetchChannelContent } from './providers/channel-content.mjs';
import {
oauthStatus, buildAuthUrl, createOAuthState, consumeOAuthState, exchangeCode,
refreshAccessToken, redirectUriFor, fetchGoogleProfile, fetchGoogleSubscriptions,
fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows,
fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, hasGoogleWriteScope,
fetchGoogleWatchLater, pushGoogleWatchLater,
} from './oauth.mjs';
import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs';
import { ytScrapeCacheStats } from './providers/youtube.mjs';
@@ -641,6 +642,82 @@ r.post('/oauth/:provider/import', authMiddlewareCookieAware, oauthLimiter, async
}
});
// -------------------- Google : Watch Later (lecture + écriture) --------------------
// Lecture (youtube.readonly OK). `writeGranted` = le jeton stocké a le scope
// force-ssl ; sinon le push répond 403 et l'UI propose de reconnecter.
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),
});
} catch (error) {
return res.status(error?.status || 502).json({ error: error?.message || 'watchlater_fetch_failed' });
}
});
r.post('/oauth/google/watchlater', authMiddlewareCookieAware, oauthLimiter, async (req, res) => {
try {
const conn = await freshOAuthToken(req.user.id, 'google');
if (!hasGoogleWriteScope(conn.scopes)) {
return res.status(403).json({ error: 'youtube_write_scope_missing', hint: 'Reconnectez Google pour autoriser l’écriture (Watch Later).' });
}
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' });
let added = 0;
let skipped = 0;
for (const videoId of clean) {
try {
await pushGoogleWatchLater(conn.accessToken, videoId);
added++;
} catch { skipped++; }
}
return res.json({ added, skipped, total: clean.length });
} catch (error) {
const status = error?.status || 502;
if (status === 403) {
return res.status(403).json({ error: 'youtube_write_scope_missing', hint: 'Reconnectez Google pour autoriser l’écriture (Watch Later).' });
}
return res.status(status).json({ error: error?.message || 'watchlater_push_failed' });
}
});
// -------------------- Import historique Takeout (Google) --------------------
// Le client parse le fichier `watch-history.json` localement (confidentialité,
// pas de limite d'upload) et envoie des lots { videoId, title, watchedAt }.
// L'historique YouTube n'est pas lisible par API (limite Google), d'où Takeout.
r.post('/user/history/takeout', authMiddleware, oauthLimiter, (req, res) => {
try {
const items = Array.isArray(req.body?.items) ? req.body.items : [];
if (!items.length) return res.status(400).json({ error: 'items_required' });
if (items.length > 1000) return res.status(400).json({ error: 'batch_too_large' });
let imported = 0;
let skipped = 0;
for (const it of items) {
const videoId = String(it?.videoId || '').trim().slice(0, 64);
if (!/^[A-Za-z0-9_-]{6,64}$/.test(videoId)) { skipped++; continue; }
const title = String(it?.title || '').slice(0, 300) || videoId;
let watchedAt = new Date().toISOString();
if (it?.watchedAt) {
const d = new Date(String(it.watchedAt));
if (!Number.isNaN(d.getTime())) watchedAt = d.toISOString();
}
try {
upsertWatchHistory({ userId: req.user.id, provider: 'youtube', videoId, title, watchedAt });
imported++;
} catch { skipped++; }
}
return res.json({ imported, skipped, total: items.length });
} catch {
return res.status(500).json({ error: 'takeout_import_failed' });
}
});
// Public: view a playlist if allowed (owner or public). Authorization header is optional.
r.get('/playlists/:id/view', (req, res) => {
try {