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
+38
View File
@@ -59,4 +59,42 @@ export class OAuthImportService {
importAll(provider: string, types: string[]): Observable<OAuthImportResult> {
return this.http.post<OAuthImportResult>(`/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<any>(`/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<any>(`/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<any>(`/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<string, { videoId: string; title: string; watchedAt: string }>();
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));
}