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
+86
View File
@@ -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() {