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:
+78
-1
@@ -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 {
|
||||
|
||||
@@ -21,8 +21,17 @@ const GOOGLE_SCOPES = [
|
||||
'openid',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/youtube.readonly',
|
||||
// Écriture Watch Later (playlistItems.insert/delete). Lecture seule
|
||||
// (abos, likes, WL) fonctionne sans lui ; le push exige un re-consentement.
|
||||
'https://www.googleapis.com/auth/youtube.force-ssl',
|
||||
].join(' ');
|
||||
|
||||
/** Le jeton stocké permet-il l'écriture (push Watch Later) ? */
|
||||
export function hasGoogleWriteScope(scopes) {
|
||||
const tokens = String(scopes || '').split(/\s+/).filter(Boolean);
|
||||
return tokens.some((t) => t === 'https://www.googleapis.com/auth/youtube.force-ssl' || t === 'https://www.googleapis.com/auth/youtube');
|
||||
}
|
||||
|
||||
const TWITCH_SCOPES = ['user:read:follows', 'user:read:subscriptions'].join(' ');
|
||||
|
||||
// state -> { userId, provider, createdAt } (mémoire, 10 min).
|
||||
@@ -298,6 +307,83 @@ export async function fetchGoogleLiked(accessToken, max = 25) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// -------------------- Google : Watch Later --------------------
|
||||
|
||||
async function googleApiGet(accessToken, path, params = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
|
||||
}
|
||||
return getJson(`https://www.googleapis.com/youtube/v3/${path}?${qs.toString()}`, accessToken);
|
||||
}
|
||||
|
||||
async function googleApiPost(accessToken, path, body) {
|
||||
const resp = await fetch(`https://www.googleapis.com/youtube/v3/${path}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
const err = new Error(`oauth_api_failed_${resp.status}`);
|
||||
err.status = resp.status === 401 ? 401 : resp.status === 403 ? 403 : 502;
|
||||
err.details = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** ID de la playlist "Regarder plus tard" du compte (lecture seule OK). */
|
||||
export async function fetchGoogleWatchLaterId(accessToken) {
|
||||
const data = await googleApiGet(accessToken, 'channels', { part: 'contentDetails', mine: 'true' });
|
||||
const item = Array.isArray(data?.items) ? data.items[0] : null;
|
||||
const id = item?.contentDetails?.relatedPlaylists?.watchLater || '';
|
||||
if (!id) throw Object.assign(new Error('watchlater_not_found'), { status: 502 });
|
||||
return String(id);
|
||||
}
|
||||
|
||||
/** Contenu de "Regarder plus tard" (lecture seule OK). */
|
||||
export async function fetchGoogleWatchLater(accessToken, max = 50) {
|
||||
const playlistId = await fetchGoogleWatchLaterId(accessToken);
|
||||
const out = [];
|
||||
let pageToken = '';
|
||||
while (out.length < max) {
|
||||
const data = await googleApiGet(accessToken, 'playlistItems', {
|
||||
part: 'snippet,contentDetails',
|
||||
playlistId,
|
||||
maxResults: Math.min(50, max - out.length),
|
||||
...(pageToken ? { pageToken } : {}),
|
||||
});
|
||||
for (const item of Array.isArray(data?.items) ? data.items : []) {
|
||||
const vid = item?.contentDetails?.videoId || item?.snippet?.resourceId?.videoId || '';
|
||||
if (!vid) continue;
|
||||
const sn = item?.snippet || {};
|
||||
out.push({
|
||||
provider: 'youtube',
|
||||
videoId: String(vid),
|
||||
title: sn?.title || '',
|
||||
thumbnail: sn?.thumbnails?.medium?.url || sn?.thumbnails?.default?.url || '',
|
||||
playlistItemId: item?.id || null,
|
||||
});
|
||||
}
|
||||
pageToken = data?.nextPageToken || '';
|
||||
if (!pageToken) break;
|
||||
}
|
||||
return { playlistId, items: out };
|
||||
}
|
||||
|
||||
/** Ajoute une vidéo à "Regarder plus tard" (exige le scope force-ssl). */
|
||||
export async function pushGoogleWatchLater(accessToken, videoId) {
|
||||
const playlistId = await fetchGoogleWatchLaterId(accessToken);
|
||||
const data = await googleApiPost(accessToken, 'playlistItems?part=snippet', {
|
||||
snippet: {
|
||||
playlistId,
|
||||
resourceId: { kind: 'youtube#video', videoId: String(videoId) },
|
||||
},
|
||||
});
|
||||
return { playlistItemId: data?.id || null };
|
||||
}
|
||||
|
||||
// -------------------- Twitch --------------------
|
||||
|
||||
function twitchClientId() {
|
||||
|
||||
Reference in New Issue
Block a user