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)
@@ -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() }}) +
+
+ Progression : {{ pg.done }}/{{ pg.total }} +
+
+
+
+
{{ takeoutError() }}
+
+ {{ r.imported }} entrée(s) importée(s){{ r.skipped ? `, ${r.skipped} ignorée(s)` : '' }}. + Voir l’historique +
+
+ + +
+

⏱️Regarder plus tard (YouTube)

+

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. + +
+
{{ wlImportResult() }}
+
+ + + +
+
{{ wlPushResult() }}
+
diff --git a/src/components/library/import/import.component.ts b/src/components/library/import/import.component.ts index 12a8cbd..2fcb0ca 100644 --- a/src/components/library/import/import.component.ts +++ b/src/components/library/import/import.component.ts @@ -1,7 +1,10 @@ import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { ActivatedRoute, RouterLink } from '@angular/router'; -import { OAuthImportService, type OAuthConnection, type OAuthPreview, type OAuthImportResult } from '../../../services/oauth-import.service'; +import { firstValueFrom } from 'rxjs'; +import { OAuthImportService, parseTakeoutHistory, type OAuthConnection, type OAuthPreview, type OAuthImportResult } from '../../../services/oauth-import.service'; +import { PlaylistsService, type Playlist } from '../../../services/playlists.service'; interface ProviderCard { id: 'google' | 'twitch'; @@ -13,12 +16,13 @@ interface ProviderCard { @Component({ selector: 'app-library-import', standalone: true, - imports: [CommonModule, RouterLink], + imports: [CommonModule, FormsModule, RouterLink], templateUrl: './import.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ImportComponent implements OnInit { private api = inject(OAuthImportService); + private playlists = inject(PlaylistsService); private route = inject(ActivatedRoute); readonly providers: ProviderCard[] = [ @@ -113,4 +117,166 @@ export class ImportComponent implements OnInit { }, }); } + + // -------------------- Takeout (historique YouTube) -------------------- + // L'historique n'est pas lisible par API (limite Google) : on importe le + // `watch-history.json` de Takeout, parsé localement puis envoyé par lots. + + takeoutItems = signal>([]); + takeoutFileName = signal(''); + takeoutParsing = signal(false); + takeoutImporting = signal(false); + takeoutProgress = signal<{ done: number; total: number } | null>(null); + takeoutResult = signal<{ imported: number; skipped: number } | null>(null); + takeoutError = signal(null); + + takeoutRange(): { from: string; to: string } | null { + const items = this.takeoutItems(); + if (!items.length) return null; + const dates = items.map((i) => i.watchedAt).sort(); + return { from: dates[0].slice(0, 10), to: dates[dates.length - 1].slice(0, 10) }; + } + + onTakeoutFile(ev: Event): void { + const input = ev.target as HTMLInputElement; + const file = input?.files?.[0]; + if (!file) return; + this.takeoutError.set(null); + this.takeoutResult.set(null); + this.takeoutProgress.set(null); + this.takeoutParsing.set(true); + this.takeoutFileName.set(file.name); + const reader = new FileReader(); + reader.onload = () => { + try { + const json = JSON.parse(String(reader.result || '[]')); + const items = parseTakeoutHistory(json); + this.takeoutItems.set(items); + if (!items.length) this.takeoutError.set('Aucune vidéo trouvée : vérifiez qu’il s’agit bien du watch-history.json de Takeout.'); + } catch { + this.takeoutError.set('Fichier illisible : JSON invalide.'); + this.takeoutItems.set([]); + } + this.takeoutParsing.set(false); + }; + reader.onerror = () => { + this.takeoutError.set('Lecture du fichier impossible.'); + this.takeoutParsing.set(false); + }; + reader.readAsText(file); + try { input.value = ''; } catch {} + } + + async importTakeout(): Promise { + const items = this.takeoutItems(); + if (!items.length || this.takeoutImporting()) return; + this.takeoutImporting.set(true); + this.takeoutError.set(null); + let imported = 0; + let skipped = 0; + try { + for (let i = 0; i < items.length; i += 500) { + const batch = items.slice(i, i + 500); + const res = await firstValueFrom(this.api.importTakeoutBatch(batch)); + imported += res?.imported || 0; + skipped += res?.skipped || 0; + this.takeoutProgress.set({ done: Math.min(i + batch.length, items.length), total: items.length }); + } + this.takeoutResult.set({ imported, skipped }); + } catch (e: any) { + this.takeoutError.set(e?.error?.error || e?.message || 'takeout_import_failed'); + } finally { + this.takeoutImporting.set(false); + } + } + + // -------------------- Watch Later YouTube -------------------- + + wlItems = signal>([]); + wlLoading = signal(false); + wlWriteGranted = signal(false); + wlError = signal(null); + wlImporting = signal(false); + wlImportResult = signal(null); + wlPushing = signal(false); + wlPushResult = signal(null); + userPlaylists = signal([]); + wlExportPlaylistId = signal(''); + + loadWatchLater(): void { + this.wlLoading.set(true); + this.wlError.set(null); + this.api.watchLater().subscribe({ + next: (res) => { + this.wlItems.set(res?.items || []); + this.wlWriteGranted.set(!!res?.writeGranted); + this.wlLoading.set(false); + }, + error: (e) => { + this.wlError.set(e?.error?.error || e?.message || 'watchlater_fetch_failed'); + this.wlLoading.set(false); + }, + }); + } + + /** Importe la WL YouTube vers une playlist NewTube (créée si besoin). */ + async importWatchLaterToPlaylist(): Promise { + const items = this.wlItems(); + if (!items.length || this.wlImporting()) return; + this.wlImporting.set(true); + this.wlError.set(null); + try { + const existing = await firstValueFrom(this.playlists.list(100)); + let pl = (existing || []).find((p) => p.title === 'Watch Later (YouTube)'); + if (!pl) pl = await firstValueFrom(this.playlists.create('Watch Later (YouTube)', 'Importée depuis YouTube', undefined, true)); + let added = 0; + for (const v of items) { + try { + await firstValueFrom(this.playlists.addVideo(pl.id, 'youtube', v.videoId, v.title, v.thumbnail)); + added++; + } catch {} + } + this.wlImportResult.set(`${added}/${items.length} vidéo(s) dans « ${pl.title} ».`); + } catch (e: any) { + this.wlError.set(e?.error?.error || e?.message || 'watchlater_import_failed'); + } finally { + this.wlImporting.set(false); + } + } + + loadUserPlaylists(): void { + this.playlists.list(100).subscribe({ + next: (list) => { + this.userPlaylists.set(list || []); + if (!this.wlExportPlaylistId() && list?.length) this.wlExportPlaylistId.set(list[0].id); + }, + error: () => {}, + }); + } + + /** Exporte une playlist NewTube (vidéos YouTube) vers "Regarder plus tard" YouTube. */ + async exportPlaylistToWatchLater(): Promise { + const pid = this.wlExportPlaylistId(); + if (!pid || this.wlPushing()) return; + this.wlPushing.set(true); + this.wlError.set(null); + this.wlPushResult.set(null); + try { + const detail = await firstValueFrom(this.playlists.get(pid, 200)); + const ids = [...new Set((detail?.items || []).filter((i) => i.provider === 'youtube').map((i) => i.videoId))]; + if (!ids.length) { + this.wlError.set('Cette playlist ne contient aucune vidéo YouTube.'); + return; + } + const res = await firstValueFrom(this.api.pushWatchLater(ids.slice(0, 50))); + this.wlPushResult.set(`${res?.added || 0} ajoutée(s)${res?.skipped ? `, ${res.skipped} ignorée(s)` : ''} dans « Regarder plus tard » YouTube.`); + } catch (e: any) { + const code = e?.error?.error || e?.message || 'watchlater_push_failed'; + this.wlError.set(code === 'youtube_write_scope_missing' + ? 'Écriture non autorisée : déconnectez puis reconnectez Google pour autoriser l’accès en écriture.' + : code); + } finally { + this.wlPushing.set(false); + } + } } diff --git a/src/services/oauth-import.service.ts b/src/services/oauth-import.service.ts index 379dbd6..fe1b172 100644 --- a/src/services/oauth-import.service.ts +++ b/src/services/oauth-import.service.ts @@ -59,4 +59,42 @@ export class OAuthImportService { importAll(provider: string, types: string[]): Observable { return this.http.post(`/api/oauth/${provider}/import`, { types }); } + + /** Contenu "Regarder plus tard" YouTube (lecture seule OK). */ + watchLater(): Observable<{ items: Array<{ videoId: string; title: string; thumbnail?: string }>; count: number; writeGranted: boolean }> { + return this.http.get(`/api/oauth/google/watchlater`); + } + + /** Pousse des vidéos vers "Regarder plus tard" YouTube (scope écriture requis). */ + pushWatchLater(videoIds: string[]): Observable<{ added: number; skipped: number; total: number }> { + return this.http.post(`/api/oauth/google/watchlater`, { videoIds }); + } + + /** Lot d'entrées d'historique Takeout (max 1000/lot côté serveur). */ + importTakeoutBatch(items: Array<{ videoId: string; title?: string; watchedAt?: string }>): Observable<{ imported: number; skipped: number; total: number }> { + return this.http.post(`/api/user/history/takeout`, { items }); + } +} + +/** Parse local du `watch-history.json` de Google Takeout (confidentiel : rien n'est uploadé tel quel). */ +export function parseTakeoutHistory(json: unknown): Array<{ videoId: string; title: string; watchedAt: string }> { + const arr = Array.isArray(json) ? json : []; + const byId = new Map(); + for (const e of arr) { + const url = String((e as any)?.titleUrl || ''); + const m = /(?:[?&]v=|youtu\.be\/|\/(?:shorts|live|embed)\/)([A-Za-z0-9_-]{11})/.exec(url); + if (!m) continue; + const videoId = m[1]; + const rawTitle = String((e as any)?.title || '').replace(/^Watched\s+/i, '').trim(); + let watchedAt = new Date().toISOString(); + try { + const d = new Date(String((e as any)?.time || '')); + if (!Number.isNaN(d.getTime())) watchedAt = d.toISOString(); + } catch {} + const prev = byId.get(videoId); + if (!prev || watchedAt > prev.watchedAt) { + byId.set(videoId, { videoId, title: (rawTitle || videoId).slice(0, 300), watchedAt }); + } + } + return [...byId.values()].sort((a, b) => (a.watchedAt < b.watchedAt ? 1 : -1)); }