feat(sync): import Takeout HTML/CSV (historique, WL, abos) sans API

This commit is contained in:
2026-09-27 09:31:45 -04:00
parent 1ae70b5c41
commit 03ad46e91b
4 changed files with 310 additions and 13 deletions
@@ -141,9 +141,13 @@
{{ 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
watch-history.json
<input type="file" accept=".json,application/json" class="hidden" (change)="onTakeoutFile($event)" />
</label>
<label class="cursor-pointer rounded-md bg-slate-700 px-3 py-1.5 text-sm text-white transition hover:bg-slate-600" title="Quand l'export ne contient pas de JSON (historique-vidéos.html)">
historique-vidéos.html
<input type="file" accept=".html,text/html" class="hidden" (change)="onHistHtmlFile($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' }}
@@ -165,6 +169,41 @@
{{ 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 class="mt-4 grid gap-4 md:grid-cols-2">
<div class="rounded-lg border border-slate-700 bg-slate-800/50 p-3">
<h4 class="font-semibold text-slate-100">📋 Regarder plus tard (CSV Takeout)</h4>
<p class="mt-1 text-xs text-slate-400">Fichier <code>Vidéos Watch later.csv</code> du dossier <code>listes de lecture</code> → playlist NewTube.</p>
<div class="mt-2 flex flex-wrap 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 le CSV
<input type="file" accept=".csv,text/csv" class="hidden" (change)="onWlCsvFile($event)" />
</label>
<button *ngIf="wlCsvItems().length" type="button" (click)="importWlCsv()" [disabled]="wlCsvImporting()"
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">
{{ wlCsvImporting() ? 'Import…' : 'Importer (' + wlCsvItems().length + ')' }}
</button>
</div>
<div *ngIf="wlCsvError()" class="mt-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ wlCsvError() }}</div>
<div *ngIf="wlCsvResult()" class="mt-2 rounded-lg border border-green-700 bg-green-900/20 p-2 text-xs text-green-200">{{ wlCsvResult() }}</div>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-800/50 p-3">
<h4 class="font-semibold text-slate-100">📺 Abonnements (CSV Takeout)</h4>
<p class="mt-1 text-xs text-slate-400">Fichier <code>abonnements.csv</code> → abonnements NewTube, sans appel réseau.</p>
<div class="mt-2 flex flex-wrap 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 le CSV
<input type="file" accept=".csv,text/csv" class="hidden" (change)="onSubsCsvFile($event)" />
</label>
<button *ngIf="subsCsvItems().length" type="button" (click)="importSubsCsv()" [disabled]="subsCsvImporting()"
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">
{{ subsCsvImporting() ? 'Import…' : 'Importer (' + subsCsvItems().length + ')' }}
</button>
</div>
<div *ngIf="subsCsvError()" class="mt-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ subsCsvError() }}</div>
<div *ngIf="subsCsvResult()" class="mt-2 rounded-lg border border-green-700 bg-green-900/20 p-2 text-xs text-green-200">{{ subsCsvResult() }} <a [routerLink]="['/library/subscriptions']" class="underline hover:text-green-100">Voir</a></div>
</div>
</div>
</div>
<!-- ==================== WATCH LATER YOUTUBE ==================== -->
+136 -12
View File
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { OAuthImportService, parseTakeoutHistory, type OAuthConnection, type OAuthPreview, type OAuthImportResult, type YtChannel } from '../../../services/oauth-import.service';
import { OAuthImportService, parseTakeoutHistory, parseTakeoutPlaylistCsv, parseTakeoutSubscriptionsCsv, parseTakeoutHistoryHtml, type OAuthConnection, type OAuthPreview, type OAuthImportResult, type YtChannel } from '../../../services/oauth-import.service';
import { PlaylistsService, type Playlist } from '../../../services/playlists.service';
interface ProviderCard {
@@ -331,6 +331,24 @@ export class ImportComponent implements OnInit {
});
}
/** Importe une liste de vidéos vers une playlist NewTube (créée si besoin). */
private async importVideoListToPlaylist(
items: Array<{ videoId: string; title?: string; thumbnail?: string }>,
playlistTitle: string,
): Promise<{ added: number; total: number; playlistTitle: string }> {
const existing = await firstValueFrom(this.playlists.list(100));
let pl = (existing || []).find((p) => p.title === playlistTitle);
if (!pl) pl = await firstValueFrom(this.playlists.create(playlistTitle, 'Importée depuis YouTube (Takeout/API)', undefined, true));
let added = 0;
for (const v of items) {
try {
await firstValueFrom(this.playlists.addVideo(pl.id, 'youtube', v.videoId, v.title || v.videoId, v.thumbnail));
added++;
} catch {}
}
return { added, total: items.length, playlistTitle: pl.title };
}
/** Importe la WL YouTube vers une playlist NewTube (créée si besoin). */
async importWatchLaterToPlaylist(): Promise<void> {
const items = this.wlItems();
@@ -338,17 +356,8 @@ export class ImportComponent implements OnInit {
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} ».`);
const r = await this.importVideoListToPlaylist(items, 'Watch Later (YouTube)');
this.wlImportResult.set(`${r.added}/${r.total} vidéo(s) dans « ${r.playlistTitle} ».`);
} catch (e: any) {
this.wlError.set(e?.error?.error || e?.message || 'watchlater_import_failed');
} finally {
@@ -356,6 +365,121 @@ export class ImportComponent implements OnInit {
}
}
// -------------------- Fichiers Takeout : WL CSV, abos CSV, historique HTML --------------------
wlCsvItems = signal<Array<{ videoId: string; title: string; watchedAt: string }>>([]);
wlCsvImporting = signal(false);
wlCsvResult = signal<string | null>(null);
wlCsvError = signal<string | null>(null);
subsCsvItems = signal<Array<{ provider: string; externalId: string; title: string; url: string }>>([]);
subsCsvImporting = signal(false);
subsCsvResult = signal<string | null>(null);
subsCsvError = signal<string | null>(null);
private readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('read_failed'));
reader.readAsText(file);
});
}
async onWlCsvFile(ev: Event): Promise<void> {
const input = ev.target as HTMLInputElement;
const file = input?.files?.[0];
if (!file) return;
this.wlCsvError.set(null);
this.wlCsvResult.set(null);
try {
const text = await this.readFileAsText(file);
const items = parseTakeoutPlaylistCsv(text);
this.wlCsvItems.set(items);
if (!items.length) this.wlCsvError.set('Aucune vidéo trouvée dans ce CSV.');
} catch {
this.wlCsvError.set('Lecture du fichier impossible.');
this.wlCsvItems.set([]);
}
try { input.value = ''; } catch {}
}
async importWlCsv(): Promise<void> {
const items = this.wlCsvItems();
if (!items.length || this.wlCsvImporting()) return;
this.wlCsvImporting.set(true);
this.wlCsvError.set(null);
try {
const r = await this.importVideoListToPlaylist(items, 'Watch Later (YouTube)');
this.wlCsvResult.set(`${r.added}/${r.total} vidéo(s) dans « ${r.playlistTitle} ».`);
} catch (e: any) {
this.wlCsvError.set(e?.error?.error || e?.message || 'watchlater_csv_import_failed');
} finally {
this.wlCsvImporting.set(false);
}
}
async onSubsCsvFile(ev: Event): Promise<void> {
const input = ev.target as HTMLInputElement;
const file = input?.files?.[0];
if (!file) return;
this.subsCsvError.set(null);
this.subsCsvResult.set(null);
try {
const text = await this.readFileAsText(file);
const items = parseTakeoutSubscriptionsCsv(text);
this.subsCsvItems.set(items);
if (!items.length) this.subsCsvError.set('Aucune chaîne trouvée dans ce CSV.');
} catch {
this.subsCsvError.set('Lecture du fichier impossible.');
this.subsCsvItems.set([]);
}
try { input.value = ''; } catch {}
}
async importSubsCsv(): Promise<void> {
const items = this.subsCsvItems();
if (!items.length || this.subsCsvImporting()) return;
this.subsCsvImporting.set(true);
this.subsCsvError.set(null);
try {
let imported = 0;
let skipped = 0;
for (let i = 0; i < items.length; i += 500) {
const res = await firstValueFrom(this.api.importSubscriptionsBatch(items.slice(i, i + 500)));
imported += res?.imported || 0;
skipped += res?.skipped || 0;
}
this.subsCsvResult.set(`${imported} abonnement(s) importé(s)${skipped ? `, ${skipped} ignoré(s)` : ''}.`);
} catch (e: any) {
this.subsCsvError.set(e?.error?.error || e?.message || 'subscriptions_csv_import_failed');
} finally {
this.subsCsvImporting.set(false);
}
}
async onHistHtmlFile(ev: Event): Promise<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);
try {
const text = await this.readFileAsText(file);
const items = parseTakeoutHistoryHtml(text);
this.takeoutItems.set(items);
if (!items.length) this.takeoutError.set('Aucune vidéo trouvée : vérifiez qu’il s’agit bien du historique-vidéos.html de Takeout.');
} catch {
this.takeoutError.set('Lecture du fichier impossible.');
this.takeoutItems.set([]);
}
this.takeoutParsing.set(false);
try { input.value = ''; } catch {}
}
loadUserPlaylists(): void {
this.playlists.list(100).subscribe({
next: (list) => {