207 lines
16 KiB
HTML
207 lines
16 KiB
HTML
<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>
|