diff --git a/server/index.mjs b/server/index.mjs
index 9012c70..4a25ee3 100644
--- a/server/index.mjs
+++ b/server/index.mjs
@@ -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 {
diff --git a/server/oauth.mjs b/server/oauth.mjs
index 3a273ac..a86ab74 100644
--- a/server/oauth.mjs
+++ b/server/oauth.mjs
@@ -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() {
diff --git a/src/components/library/import/import.component.html b/src/components/library/import/import.component.html
index 6d061ad..4bc8692 100644
--- a/src/components/library/import/import.component.html
+++ b/src/components/library/import/import.component.html
@@ -17,10 +17,11 @@
Comment ça marche ? (scopes, URLs, ré-import)
-
Google : abonnements + favoris YouTube, scopes lecture seule openid userinfo.profile youtube.readonly. Redirect URI à enregistrer dans Google Cloud : /api/oauth/google/callback.
+
Google : abonnements + favoris YouTube, scopes openid userinfo.profile youtube.readonly (+ youtube.force-ssl pour pousser vers Regarder plus tard). Redirect URI à enregistrer dans Google Cloud : /api/oauth/google/callback.
Twitch : chaînes suivies uniquement (pas de likes côté Twitch), scopes user:read:follows user:read:subscriptions. Redirect URI à enregistrer sur dev.twitch.tv : /api/oauth/twitch/callback.
Prévisualiser ne change rien ; Importer ajoute abonnements / favoris dans NewTube.
Ré-importer est sans doublons : relancez sans crainte après de nouveaux abos/likes.
+
Historique YouTube : non lisible par API (limite Google) — utilisez l’export Takeout ci-dessous (takeout.google.com → YouTube → watch-history.json).
@@ -86,4 +87,79 @@
+
+
+
+
🕘Historique YouTube (Takeout)
+
+ Google n’expose pas l’historique par API : exportez-le via
+ takeout.google.com → YouTube → watch-history.json, puis importez le fichier ici.
+ Parsé localement dans votre navigateur, sans doublons.
+
+
+
+
+
+
Analyse du fichier…
+
+ {{ takeoutItems().length }} vidéo(s) — du {{ rg.from }} au {{ rg.to }} ({{ takeoutFileName() }})
+
Tirez votre liste YouTube vers une playlist NewTube, ou poussez une playlist NewTube vers YouTube (écriture).
+
+
+
+
+
{{ wlError() }}
+
+ {{ wlItems().length }} vidéo(s) dans « Regarder plus tard » YouTube.
+ Écriture non autorisée : déconnectez puis reconnectez Google pour l’activer.
+