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
@@ -17,10 +17,11 @@
<details class="mb-4 rounded-xl border border-slate-800 bg-slate-900/60 p-4 text-sm text-slate-300">
<summary class="cursor-pointer font-semibold text-slate-100">Comment ça marche ? (scopes, URLs, ré-import)</summary>
<ul class="mt-2 list-disc space-y-1 pl-5 text-slate-400">
<li><strong class="text-slate-200">Google</strong> : abonnements + favoris YouTube, scopes lecture seule <code>openid userinfo.profile youtube.readonly</code>. Redirect URI à enregistrer dans Google Cloud : <code>/api/oauth/google/callback</code>.</li>
<li><strong class="text-slate-200">Google</strong> : abonnements + favoris YouTube, scopes <code>openid userinfo.profile youtube.readonly</code> (+ <code>youtube.force-ssl</code> pour pousser vers Regarder plus tard). Redirect URI à enregistrer dans Google Cloud : <code>/api/oauth/google/callback</code>.</li>
<li><strong class="text-slate-200">Twitch</strong> : chaînes suivies uniquement (<strong>pas de likes côté Twitch</strong>), scopes <code>user:read:follows user:read:subscriptions</code>. Redirect URI à enregistrer sur dev.twitch.tv : <code>/api/oauth/twitch/callback</code>.</li>
<li><strong class="text-slate-200">Prévisualiser</strong> ne change rien ; <strong class="text-slate-200">Importer</strong> ajoute abonnements / favoris dans NewTube.</li>
<li>Ré-importer est <strong class="text-slate-200">sans doublons</strong> : relancez sans crainte après de nouveaux abos/likes.</li>
<li><strong class="text-slate-200">Historique YouTube</strong> : non lisible par API (limite Google) — utilisez l’export <strong class="text-slate-200">Takeout</strong> ci-dessous (<code>takeout.google.com → YouTube → watch-history.json</code>).</li>
</ul>
</details>
@@ -86,4 +87,79 @@
</div>
</div>
</div>
<!-- ==================== HISTORIQUE TAKEOUT ==================== -->
<div *ngIf="!loading()" class="mt-4 rounded-xl border border-slate-800 bg-slate-900/60 p-4">
<h3 class="text-lg font-semibold text-slate-100"><span class="mr-2">🕘</span>Historique YouTube (Takeout)</h3>
<p class="mt-1 text-sm text-slate-400">
Google n’expose pas l’historique par API : exportez-le via
<code>takeout.google.com → YouTube → watch-history.json</code>, puis importez le fichier ici.
Parsé <strong class="text-slate-200">localement dans votre navigateur</strong>, sans doublons.
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<label class="cursor-pointer rounded-md bg-slate-700 px-3 py-1.5 text-sm text-white transition hover:bg-slate-600">
Choisir watch-history.json
<input type="file" accept=".json,application/json" class="hidden" (change)="onTakeoutFile($event)" />
</label>
<button *ngIf="takeoutItems().length" type="button" (click)="importTakeout()" [disabled]="takeoutImporting()"
class="rounded-md bg-green-700 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-green-600 disabled:opacity-40">
{{ takeoutImporting() ? 'Import…' : 'Importer dans l’historique' }}
</button>
</div>
<div *ngIf="takeoutParsing()" class="mt-2 text-sm text-slate-400">Analyse du fichier…</div>
<div *ngIf="takeoutItems().length && takeoutRange() as rg" class="mt-2 text-sm text-slate-300">
{{ takeoutItems().length }} vidéo(s) — du {{ rg.from }} au {{ rg.to }}<span *ngIf="takeoutFileName()"> ({{ takeoutFileName() }})</span>
</div>
<div *ngIf="takeoutProgress() as pg" class="mt-2 text-sm text-slate-300">
Progression : {{ pg.done }}/{{ pg.total }}
<div class="mt-1 h-2 w-full max-w-md overflow-hidden rounded bg-slate-700">
<div class="h-full bg-green-600 transition-all" [style.width.%]="pg.total ? (pg.done / pg.total * 100) : 0"></div>
</div>
</div>
<div *ngIf="takeoutError()" class="mt-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ takeoutError() }}</div>
<div *ngIf="takeoutResult() as r" class="mt-2 rounded-lg border border-green-700 bg-green-900/20 p-2 text-xs text-green-200">
{{ r.imported }} entrée(s) importée(s){{ r.skipped ? `, ${r.skipped} ignorée(s)` : '' }}.
<a [routerLink]="['/account/history']" class="underline hover:text-green-100">Voir l’historique</a>
</div>
</div>
<!-- ==================== WATCH LATER YOUTUBE ==================== -->
<div *ngIf="!loading() && isConnected('google')" class="mt-4 rounded-xl border border-slate-800 bg-slate-900/60 p-4">
<h3 class="text-lg font-semibold text-slate-100"><span class="mr-2">⏱️</span>Regarder plus tard (YouTube)</h3>
<p class="mt-1 text-sm text-slate-400">Tirez votre liste YouTube vers une playlist NewTube, ou poussez une playlist NewTube vers YouTube (écriture).</p>
<div class="mt-3 flex flex-wrap gap-2">
<button type="button" (click)="loadWatchLater(); loadUserPlaylists()" [disabled]="wlLoading()"
class="rounded-md border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm text-slate-200 transition hover:border-slate-500">
{{ wlLoading() ? 'Chargement…' : (wlItems().length ? 'Recharger' : 'Voir ma liste') }}
</button>
<button *ngIf="wlItems().length" type="button" (click)="importWatchLaterToPlaylist()" [disabled]="wlImporting()"
class="rounded-md bg-green-700 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-green-600 disabled:opacity-40">
{{ wlImporting() ? 'Import…' : '→ Playlist NewTube' }}
</button>
</div>
<div *ngIf="wlError()" class="mt-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ wlError() }}</div>
<div *ngIf="wlItems().length" class="mt-2 text-sm text-slate-300">
{{ wlItems().length }} vidéo(s) dans « Regarder plus tard » YouTube.
<span *ngIf="!wlWriteGranted()" class="text-amber-200">Écriture non autorisée : déconnectez puis reconnectez Google pour l’activer.</span>
<ul class="mt-2 max-h-40 space-y-1 overflow-auto text-xs text-slate-400">
<li *ngFor="let v of wlItems().slice(0, 20)" class="flex items-center gap-2">
<img *ngIf="v.thumbnail" [src]="v.thumbnail" alt="" class="h-8 w-14 rounded object-cover" />
<span class="truncate">{{ v.title }}</span>
</li>
</ul>
</div>
<div *ngIf="wlImportResult()" class="mt-2 rounded-lg border border-green-700 bg-green-900/20 p-2 text-xs text-green-200">{{ wlImportResult() }}</div>
<div *ngIf="userPlaylists().length" class="mt-3 flex flex-wrap items-center gap-2 border-t border-slate-800 pt-3">
<label class="text-sm text-slate-300">Exporter vers YouTube :</label>
<select [ngModel]="wlExportPlaylistId()" (ngModelChange)="wlExportPlaylistId.set($event)" class="rounded-md border border-slate-600 bg-slate-700 px-2 py-1.5 text-sm text-white">
<option *ngFor="let pl of userPlaylists()" [value]="pl.id">{{ pl.title }}</option>
</select>
<button type="button" (click)="exportPlaylistToWatchLater()" [disabled]="wlPushing() || !wlWriteGranted()"
title="{{ wlWriteGranted() ? '' : 'Reconnectez Google pour autoriser l’écriture' }}"
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-red-500 disabled:opacity-40">
{{ wlPushing() ? 'Envoi…' : '→ YouTube' }}
</button>
</div>
<div *ngIf="wlPushResult()" class="mt-2 rounded-lg border border-green-700 bg-green-900/20 p-2 text-xs text-green-200">{{ wlPushResult() }}</div>
</div>
</div>