Files
NewTube/src/components/library/import/import.component.html
T

207 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<div class="container mx-auto p-4 sm:p-6">
<div class="mb-6 flex flex-col gap-2">
<div class="flex items-center gap-3">
<a [routerLink]="['/library/subscriptions']" class="rounded-lg border border-slate-700 bg-slate-800 px-3 py-1.5 text-slate-200 transition hover:bg-slate-700">‹</a>
<h2 class="text-3xl font-bold text-slate-100">Importer depuis Google / Twitch</h2>
</div>
<p class="text-sm text-slate-400">Connectez votre compte, prévisualisez, puis importez vos abonnements et favoris dans NewTube.</p>
<div class="flex gap-2 text-sm">
<a [routerLink]="['/library/subscriptions']" class="text-red-300 hover:text-red-200">Voir mes abonnements</a>
<span class="text-slate-600">•</span>
<a [routerLink]="['/library/liked']" class="text-red-300 hover:text-red-200">Voir mes favoris</a>
</div>
</div>
<div *ngIf="notice()" class="mb-4 rounded-lg border border-blue-700 bg-blue-900/30 p-3 text-sm text-blue-200">{{ notice() }}</div>
<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 <code>openid userinfo.profile youtube.readonly youtube.force-ssl youtube</code> (le scope complet est requis pour la sync auto et Regarder plus tard via InnerTube — reconnectez-vous après chaque élargissement). 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>
<div *ngIf="loading()" class="text-sm text-slate-400">Chargement…</div>
<div *ngIf="!loading()" class="grid gap-4 md:grid-cols-2">
<div *ngFor="let p of providers" class="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
<div class="mb-2 flex items-center justify-between gap-2">
<h3 class="text-lg font-semibold text-slate-100"><span class="mr-2">{{ p.emoji }}</span>{{ p.label }}</h3>
<span *ngIf="isConnected(p.id)" class="rounded-full bg-green-900/40 border border-green-700 px-2 py-0.5 text-xs text-green-200">connecté</span>
<span *ngIf="!isConnected(p.id)" class="rounded-full bg-slate-800 border border-slate-700 px-2 py-0.5 text-xs text-slate-300">non connecté</span>
</div>
<p class="mb-3 text-sm text-slate-400">{{ p.hint }}</p>
<div *ngIf="!configured()[p.id]" class="mb-3 rounded-lg border border-amber-700 bg-amber-900/20 p-2 text-xs text-amber-200">
Non configuré côté serveur. Ajoutez {{ (missing()[p.id] || []).join(', ') || 'les variables manquantes' }} puis redémarrez l’API.
<span *ngIf="p.id === 'google'">Voir <code>GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_REDIRECT_URI</code> dans <code>.env.example</code>.</span>
</div>
<div *ngIf="isConnected(p.id) && connectionOf(p.id) as c" class="mb-3 flex items-center gap-2 text-sm text-slate-300">
<img *ngIf="c.avatarUrl" [src]="c.avatarUrl" alt="" class="h-8 w-8 rounded-full object-cover" />
<span class="font-medium">{{ c.displayName || c.externalUserId }}</span>
</div>
<div class="mb-3 flex flex-wrap gap-2">
<button *ngIf="!isConnected(p.id)" type="button" [disabled]="!configured()[p.id]"
(click)="connect(p.id)"
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">
Connecter {{ p.label }}
</button>
<button *ngIf="isConnected(p.id)" type="button" (click)="preview(p.id)" [disabled]="loadingPreview()[p.id]"
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">
{{ loadingPreview()[p.id] ? 'Aperçu…' : 'Prévisualiser' }}
</button>
<button *ngIf="isConnected(p.id)" type="button" (click)="importAll(p.id)" [disabled]="importing()[p.id]"
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">
{{ importing()[p.id] ? 'Import…' : 'Importer' }}
</button>
<button *ngIf="isConnected(p.id)" type="button" (click)="disconnect(p.id)"
class="rounded-md border border-slate-700 bg-transparent px-3 py-1.5 text-sm text-slate-400 transition hover:text-white">
Déconnecter
</button>
</div>
<div *ngIf="errors()[p.id]" class="mb-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ errors()[p.id] }}</div>
<div *ngIf="previews()[p.id] as pv" class="mb-2 text-sm">
<p class="text-slate-300">Abonnements : <strong>{{ pv.subscriptionCount }}</strong><span *ngIf="pv.subscriptionTotal && pv.subscriptionTotal > pv.subscriptionCount"> / {{ pv.subscriptionTotal }}</span><span *ngIf="p.id === 'google'"> • Favoris : <strong>{{ pv.likeCount }}</strong><span *ngIf="pv.likeTotal && pv.likeTotal > pv.likeCount"> / {{ pv.likeTotal }}</span></span></p>
<p *ngIf="p.id === 'google' && ((pv.subscriptionTotal && pv.subscriptionTotal > pv.subscriptionCount) || (pv.likeTotal && pv.likeTotal > pv.likeCount))" class="text-xs text-slate-500">Aperçu limité — le bouton Importer prend tout le compte.</p>
<ul class="mt-2 max-h-48 space-y-1 overflow-auto text-xs text-slate-400">
<li *ngFor="let s of pv.subscriptions.slice(0, 20)" class="flex items-center gap-2">
<img *ngIf="s.avatarUrl" [src]="s.avatarUrl" alt="" class="h-5 w-5 rounded-full object-cover" />
<span class="truncate">{{ s.title }}</span>
</li>
</ul>
<ul *ngIf="pv.likes?.length" class="mt-2 max-h-32 space-y-1 overflow-auto text-xs text-slate-400">
<li *ngFor="let l of pv.likes.slice(0, 10)" class="truncate">♥ {{ l.title }}</li>
</ul>
</div>
<div *ngIf="results()[p.id] as r" class="rounded-lg border border-green-700 bg-green-900/20 p-2 text-xs text-green-200">
Importé : {{ r.importedSubscriptions }} abonnement(s)<span *ngIf="p.id === 'google'">, {{ r.importedLikes }} favori(s)</span>.
<span *ngIf="r.skippedSubscriptions"> (ignorés : {{ r.skippedSubscriptions }})</span>
</div>
</div>
</div>
<!-- ==================== CHAÎNE 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>Chaîne YouTube utilisée</h3>
<p class="mt-1 text-sm text-slate-400">
Comptes multi-chaînes / marque : la chaîne par défaut est parfois vide (pas d’historique ni de Regarder plus tard).
Choisissez la chaîne où se trouve votre activité réelle.
</p>
<div class="mt-3 flex flex-wrap gap-2">
<button type="button" (click)="loadYtChannels()" [disabled]="ytChannelsLoading()"
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">
{{ ytChannelsLoading() ? 'Chargement…' : (ytChannels().length ? 'Recharger' : 'Lister mes chaînes') }}
</button>
</div>
<div *ngIf="ytChannelsError()" class="mt-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ ytChannelsError() }}</div>
<div *ngIf="ytChannels().length" class="mt-3 space-y-2">
<label *ngFor="let c of ytChannels()" class="flex cursor-pointer items-center gap-3 rounded-lg border border-slate-700 bg-slate-800/60 px-3 py-2 text-sm transition hover:border-slate-500">
<input type="radio" name="yt-channel" [value]="c.channelId || ''" [ngModel]="ytSelectedChannelId()" (ngModelChange)="ytSelectedChannelId.set($event)" class="accent-red-600" />
<span class="flex-1">
<span class="block font-medium text-slate-100">{{ c.title }}</span>
<span *ngIf="c.handle" class="block text-xs text-slate-400">{{ c.handle }}</span>
</span>
<span *ngIf="c.selected" class="rounded-full bg-slate-700 px-2 py-0.5 text-xs text-slate-300">actuelle</span>
</label>
<div class="flex flex-wrap items-center gap-2">
<button type="button" (click)="saveYtChannel()" [disabled]="ytSavingChannel()"
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">
{{ ytSavingChannel() ? 'Enregistrement…' : 'Utiliser cette chaîne' }}
</button>
<span *ngIf="ytChannelSaved()" class="text-xs text-green-200">{{ ytChannelSaved() }}</span>
</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 Data : <strong class="text-slate-200">sync auto</strong> via votre compte
connecté, ou export manuel via <code>takeout.google.com → YouTube → watch-history.json</code>.
Fichier 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">
<button *ngIf="isConnected('google')" type="button" (click)="syncAutoHistory()" [disabled]="ytAutoLoading() || takeoutImporting()"
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">
{{ ytAutoLoading() ? 'Sync…' : '⚡ Sync auto (compte Google)' }}
</button>
<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="ytAutoError()" class="mt-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ ytAutoError() }}</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="wlNote()" class="mt-2 rounded-lg border border-blue-700 bg-blue-900/20 p-2 text-xs text-blue-200">{{ wlNote() }}</div>
<div *ngIf="wlLoaded() && !wlLoading() && !wlItems().length && !wlError()" class="mt-2 text-sm text-slate-400">Liste vide : aucune vidéo dans « Regarder plus tard » YouTube.</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>