feat(sync): refonte import Takeout complet (playlists, musique, recherches, identité)

This commit is contained in:
2026-09-27 11:10:05 -04:00
parent 03ad46e91b
commit 6c218e8773
5 changed files with 338 additions and 152 deletions
+11
View File
@@ -409,6 +409,17 @@ export function insertSearchHistory({ userId, query, filters }) {
return { id, created_at };
}
/** Variante Takeout : date d'origine conservée (ISO valide exigée). */
export function insertSearchHistoryAt({ userId, query, createdAt }) {
const id = cryptoRandomId();
const d = new Date(String(createdAt || ''));
const created_at = !Number.isNaN(d.getTime()) ? d.toISOString() : nowIso();
db.prepare(`INSERT INTO search_history (id, user_id, query, filters_json, created_at)
VALUES (?, ?, ?, ?, ?)`)
.run(id, userId, query, null, created_at);
return { id, created_at };
}
export function listSearchHistory({ userId, limit = 50, before, q, provider }) {
const lim = Math.max(1, Math.min(200, Number(limit) || 50));
const conds = ['user_id = ?'];
+23
View File
@@ -40,6 +40,7 @@ import {
cryptoRandomId,
cryptoRandomUUID,
insertSearchHistory,
insertSearchHistoryAt,
listSearchHistory,
deleteSearchHistoryById,
deleteAllSearchHistory,
@@ -2550,6 +2551,28 @@ r.post('/user/history/search', authMiddleware, (req, res) => {
return res.status(201).json(row);
});
// Import en lot des recherches Takeout (historique-recherches.html).
r.post('/user/history/search/batch', authMiddleware, oauthLimiter, (req, res) => {
try {
const items = Array.isArray(req.body?.items) ? req.body.items : [];
if (!items.length) return res.status(400).json({ error: 'items_required' });
if (items.length > 1000) return res.status(400).json({ error: 'batch_too_large' });
let imported = 0;
let skipped = 0;
for (const it of items) {
const query = String(it?.query || '').trim().slice(0, 300);
if (!query) { skipped++; continue; }
try {
insertSearchHistoryAt({ userId: req.user.id, query, createdAt: it?.createdAt });
imported++;
} catch { skipped++; }
}
return res.json({ imported, skipped, total: items.length });
} catch {
return res.status(500).json({ error: 'search_batch_failed' });
}
});
r.get('/user/history/search', authMiddleware, (req, res) => {
const limit = Math.min(200, Number(req.query.limit || 50));
const before = req.query.before ? String(req.query.before) : undefined;
@@ -127,37 +127,51 @@
</div>
</div>
<!-- ==================== HISTORIQUE TAKEOUT ==================== -->
<!-- ==================== EXPORT TAKEOUT COMPLET ==================== -->
<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>
<h3 class="text-lg font-semibold text-slate-100"><span class="mr-2">📦</span>Export Takeout complet</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.
Déposez <strong class="text-slate-200">tous les fichiers</strong> de
<code>Takeout/YouTube et YouTube Music</code> d’un coup : historique (<code>watch-history.json</code> ou
<code>historique-vidéos.html</code>), <code>abonnements.csv</code>, playlists (<code>Vidéos *.csv</code>),
<code>music library songs.csv</code>, <code>historique-recherches.html</code>.
Parsé <strong class="text-slate-200">localement dans votre navigateur</strong>, sans doublons.
Non gérés : commentaires, uploads personnels, métadonnées de chaîne.
</p>
<div *ngIf="stagedIdentity() as ident" class="mt-2 inline-flex items-center gap-2 rounded-full border border-slate-700 bg-slate-800 px-3 py-1 text-xs text-slate-200">
👤 Export de : <strong>{{ ident.title }}</strong><span class="text-slate-500">{{ ident.channelId }}</span>
</div>
<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">
watch-history.json
<input type="file" accept=".json,application/json" class="hidden" (change)="onTakeoutFile($event)" />
{{ staging() ? 'Analyse…' : 'Choisir les fichiers Takeout' }}
<input type="file" multiple accept=".json,.html,.csv" class="hidden" (change)="stageTakeoutFiles($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()"
<button *ngIf="stagedSources().length" type="button" (click)="importAllStaged()" [disabled]="importAllBusy()"
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' }}
{{ importAllBusy() ? 'Import…' : 'Tout importer' }}
</button>
<button *ngIf="stagedSources().length" type="button" (click)="resetStaged()" [disabled]="importAllBusy()"
class="rounded-md border border-slate-700 bg-transparent px-3 py-1.5 text-sm text-slate-400 transition hover:text-white">
Réinitialiser
</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="staging()" class="mt-2 text-sm text-slate-400">Analyse des fichiers…</div>
<ul *ngIf="stagedSources().length" class="mt-3 space-y-1 text-sm">
<li *ngFor="let s of stagedSources()" class="flex items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-1.5">
<span>{{ s.icon }}</span>
<span class="flex-1 truncate text-slate-200">{{ s.label }}</span>
<span class="text-xs text-slate-500">{{ s.detail }}</span>
<span *ngIf="s.count" class="rounded-full bg-slate-700 px-2 py-0.5 text-xs text-slate-200">{{ s.count }}</span>
</li>
</ul>
<div *ngIf="importAllStage()" class="mt-2 text-sm text-slate-300">{{ importAllStage() }}</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">
@@ -165,44 +179,14 @@
</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 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 *ngIf="subsCsvError()" class="mt-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ subsCsvError() }}</div>
<ul *ngIf="importAllDone().length" class="mt-2 space-y-1 text-xs">
<li *ngFor="let line of importAllDone()" class="rounded-lg border border-green-700 bg-green-900/20 p-2 text-green-200">{{ line }}</li>
</ul>
<div *ngIf="takeoutResult() as r" class="mt-2 text-xs text-slate-400">
<a [routerLink]="['/account/history']" class="underline hover:text-slate-200">Voir l’historique</a>
<span> • </span>
<a [routerLink]="['/library/subscriptions']" class="underline hover:text-slate-200">Voir les abonnements</a>
</div>
</div>
+191 -97
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, parseTakeoutPlaylistCsv, parseTakeoutSubscriptionsCsv, parseTakeoutHistoryHtml, type OAuthConnection, type OAuthPreview, type OAuthImportResult, type YtChannel } from '../../../services/oauth-import.service';
import { OAuthImportService, parseTakeoutHistory, parseTakeoutPlaylistCsv, parseTakeoutSubscriptionsCsv, parseTakeoutHistoryHtml, parseTakeoutSearchHtml, parseTakeoutMusicCsv, parseTakeoutIdentity, takeoutPlaylistNameFromFile, type OAuthConnection, type OAuthPreview, type OAuthImportResult, type YtChannel } from '../../../services/oauth-import.service';
import { PlaylistsService, type Playlist } from '../../../services/playlists.service';
interface ProviderCard {
@@ -13,6 +13,15 @@ interface ProviderCard {
hint: string;
}
interface StagedSource {
key: string;
kind: 'history' | 'subs' | 'playlist' | 'searches' | 'identity' | 'ignored';
icon: string;
label: string;
detail: string;
count: number;
}
@Component({
selector: 'app-library-import',
standalone: true,
@@ -173,36 +182,6 @@ export class ImportComponent implements OnInit {
return { from: dates[0].slice(0, 10), to: dates[dates.length - 1].slice(0, 10) };
}
onTakeoutFile(ev: Event): 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);
const reader = new FileReader();
reader.onload = () => {
try {
const json = JSON.parse(String(reader.result || '[]'));
const items = parseTakeoutHistory(json);
this.takeoutItems.set(items);
if (!items.length) this.takeoutError.set('Aucune vidéo trouvée : vérifiez qu’il s’agit bien du watch-history.json de Takeout.');
} catch {
this.takeoutError.set('Fichier illisible : JSON invalide.');
this.takeoutItems.set([]);
}
this.takeoutParsing.set(false);
};
reader.onerror = () => {
this.takeoutError.set('Lecture du fichier impossible.');
this.takeoutParsing.set(false);
};
reader.readAsText(file);
try { input.value = ''; } catch {}
}
async importTakeout(): Promise<void> {
const items = this.takeoutItems();
if (!items.length || this.takeoutImporting()) return;
@@ -365,12 +344,18 @@ export class ImportComponent implements OnInit {
}
}
// -------------------- Fichiers Takeout : WL CSV, abos CSV, historique HTML --------------------
// -------------------- Export Takeout complet (multi-fichiers) --------------------
// Un seul dépôt de fichiers : historique (JSON/HTML), abonnements,
// playlists (une par CSV), musique, recherches. Parsé localement.
wlCsvItems = signal<Array<{ videoId: string; title: string; watchedAt: string }>>([]);
wlCsvImporting = signal(false);
wlCsvResult = signal<string | null>(null);
wlCsvError = signal<string | null>(null);
stagedSources = signal<StagedSource[]>([]);
staging = signal(false);
stagedIdentity = signal<{ channelId: string; title: string } | null>(null);
stagedPlaylists = signal<Array<{ title: string; items: Array<{ videoId: string; title: string }> }>>([]);
searchesItems = signal<Array<{ query: string; createdAt: string }>>([]);
importAllBusy = signal(false);
importAllStage = signal<string | null>(null);
importAllDone = signal<Array<string>>([]);
subsCsvItems = signal<Array<{ provider: string; externalId: string; title: string; url: string }>>([]);
subsCsvImporting = signal(false);
@@ -386,55 +371,186 @@ export class ImportComponent implements OnInit {
});
}
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([]);
private mergeHistory(items: Array<{ videoId: string; title: string; watchedAt: string }>): void {
const byId = new Map(this.takeoutItems().map((i) => [i.videoId, i]));
for (const it of items) {
const prev = byId.get(it.videoId);
if (!prev || it.watchedAt > prev.watchedAt) byId.set(it.videoId, it);
}
try { input.value = ''; } catch {}
this.takeoutItems.set([...byId.values()]);
}
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);
private mergeSubs(items: Array<{ provider: string; externalId: string; title: string; url: string }>): void {
const seen = new Set(this.subsCsvItems().map((i) => i.externalId));
const merged = [...this.subsCsvItems()];
for (const it of items) {
if (seen.has(it.externalId)) continue;
seen.add(it.externalId);
merged.push(it);
}
this.subsCsvItems.set(merged);
}
async onSubsCsvFile(ev: Event): Promise<void> {
private upsertStaged(entry: StagedSource): void {
const list = this.stagedSources().filter((s) => s.key !== entry.key);
list.push(entry);
this.stagedSources.set(list);
}
private classifyTakeoutFile(name: string): string {
const n = String(name || '').toLowerCase();
if (/watch-history\.json$/i.test(n)) return 'history-json';
if (/historique[-_ ]?vid[eé]os?\.html$/i.test(n)) return 'history-html';
if (/abonnements\.csv$/i.test(n) || /subscriptions\.csv$/i.test(n)) return 'subs';
if (/music library songs\.csv$/i.test(n)) return 'music';
if (/historique[-_ ]?recherches\.html$/i.test(n) || /search[-_ ]?history\.html$/i.test(n)) return 'searches';
if (/cha[iî]ne\.csv$/i.test(n) || /(^|\/)channel\.csv$/i.test(n)) return 'identity';
if (/listes de lecture\.csv$/i.test(n)) return 'index';
if (/\.csv$/i.test(n)) return 'playlist';
return 'unknown';
}
/** Dépôt multi-fichiers : classifie + parse chaque fichier, affiche le récap. */
async stageTakeoutFiles(ev: Event): Promise<void> {
const input = ev.target as HTMLInputElement;
const file = input?.files?.[0];
if (!file) return;
const files = Array.from(input?.files || []);
if (!files.length || this.staging()) return;
this.staging.set(true);
this.takeoutError.set(null);
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([]);
for (const file of files) {
const kind = this.classifyTakeoutFile(file.name);
try {
const text = await this.readFileAsText(file);
if (kind === 'history-json') {
const items = parseTakeoutHistory(JSON.parse(text));
this.mergeHistory(items);
this.upsertStaged({ key: file.name, kind: 'history', icon: '🕘', label: file.name, detail: '→ Historique', count: items.length });
} else if (kind === 'history-html') {
const items = parseTakeoutHistoryHtml(text);
this.mergeHistory(items);
this.upsertStaged({ key: file.name, kind: 'history', icon: '🕘', label: file.name, detail: '→ Historique', count: items.length });
} else if (kind === 'subs') {
const items = parseTakeoutSubscriptionsCsv(text);
this.mergeSubs(items);
this.upsertStaged({ key: file.name, kind: 'subs', icon: '📺', label: file.name, detail: '→ Abonnements', count: items.length });
} else if (kind === 'music') {
const items = parseTakeoutMusicCsv(text);
this.upsertPlaylistStaged('Musique (YouTube Music)', items);
this.upsertStaged({ key: file.name, kind: 'playlist', icon: '🎵', label: 'Musique (YouTube Music)', detail: '→ Playlist', count: items.length });
} else if (kind === 'searches') {
const items = parseTakeoutSearchHtml(text);
this.mergeSearches(items);
this.upsertStaged({ key: file.name, kind: 'searches', icon: '🔎', label: file.name, detail: '→ Recherches', count: items.length });
} else if (kind === 'identity') {
const id = parseTakeoutIdentity(text);
if (id) {
this.stagedIdentity.set(id);
this.upsertStaged({ key: file.name, kind: 'identity', icon: '👤', label: `${id.title} (${id.channelId})`, detail: 'Compte source', count: 1 });
} else {
this.upsertStaged({ key: file.name, kind: 'ignored', icon: '❓', label: file.name, detail: 'Identité introuvable', count: 0 });
}
} else if (kind === 'index') {
this.upsertStaged({ key: file.name, kind: 'ignored', icon: '📑', label: file.name, detail: 'Index : titres lus depuis les noms de fichiers', count: 0 });
} else if (kind === 'playlist') {
const title = takeoutPlaylistNameFromFile(file.name);
const items = parseTakeoutPlaylistCsv(text).map((v) => ({ videoId: v.videoId, title: v.title }));
this.upsertPlaylistStaged(title, items);
this.upsertStaged({ key: file.name, kind: 'playlist', icon: '📋', label: title, detail: '→ Playlist', count: items.length });
} else {
this.upsertStaged({ key: file.name, kind: 'ignored', icon: '🚫', label: file.name, detail: 'Type non géré (commentaires, uploads perso…)', count: 0 });
}
} catch {
this.upsertStaged({ key: file.name, kind: 'ignored', icon: '⚠️', label: file.name, detail: 'Fichier illisible', count: 0 });
}
}
this.takeoutFileName.set(`${files.length} fichier(s) Takeout`);
} finally {
this.staging.set(false);
try { input.value = ''; } catch {}
}
}
private upsertPlaylistStaged(title: string, items: Array<{ videoId: string; title?: string }>): void {
const list = this.stagedPlaylists().filter((p) => p.title !== title);
if (items.length) {
const seen = new Set<string>();
const dedup = items.filter((v) => (seen.has(v.videoId) ? false : (seen.add(v.videoId), true)));
list.push({ title, items: dedup.map((v) => ({ videoId: v.videoId, title: v.title || v.videoId })) });
}
this.stagedPlaylists.set(list);
}
private mergeSearches(items: Array<{ query: string; createdAt: string }>): void {
const byQ = new Map(this.searchesItems().map((i) => [i.query.toLowerCase(), i]));
for (const it of items) {
const prev = byQ.get(it.query.toLowerCase());
if (!prev || it.createdAt > prev.createdAt) byQ.set(it.query.toLowerCase(), it);
}
this.searchesItems.set([...byQ.values()]);
}
resetStaged(): void {
this.stagedSources.set([]);
this.stagedPlaylists.set([]);
this.searchesItems.set([]);
this.stagedIdentity.set(null);
this.takeoutItems.set([]);
this.subsCsvItems.set([]);
this.takeoutResult.set(null);
this.subsCsvResult.set(null);
this.importAllDone.set([]);
this.importAllStage.set(null);
this.takeoutProgress.set(null);
this.takeoutError.set(null);
this.subsCsvError.set(null);
}
/** Importe tout le lot préparé : historique, abos, playlists, recherches. */
async importAllStaged(): Promise<void> {
if (this.importAllBusy()) return;
this.importAllBusy.set(true);
this.importAllDone.set([]);
const done: string[] = [];
try {
if (this.takeoutItems().length) {
this.importAllStage.set(`Historique… (${this.takeoutItems().length})`);
await this.importTakeout();
const r = this.takeoutResult();
done.push(`Historique : ${r?.imported || 0} importée(s)${r?.skipped ? `, ${r.skipped} ignorée(s)` : ''}.`);
}
if (this.subsCsvItems().length) {
this.importAllStage.set(`Abonnements… (${this.subsCsvItems().length})`);
await this.importSubsCsv();
if (this.subsCsvResult()) done.push(`Abonnements : ${this.subsCsvResult()}`);
}
for (const pl of this.stagedPlaylists()) {
this.importAllStage.set(`Playlist « ${pl.title} »… (${pl.items.length})`);
try {
const r = await this.importVideoListToPlaylist(pl.items, pl.title);
done.push(`Playlist « ${r.playlistTitle} » : ${r.added}/${r.total}.`);
} catch {
done.push(`Playlist « ${pl.title} » : échec.`);
}
}
if (this.searchesItems().length) {
this.importAllStage.set(`Recherches… (${this.searchesItems().length})`);
let imported = 0;
let skipped = 0;
const items = this.searchesItems();
for (let i = 0; i < items.length; i += 500) {
const res = await firstValueFrom(this.api.importSearchBatch(items.slice(i, i + 500)));
imported += res?.imported || 0;
skipped += res?.skipped || 0;
}
done.push(`Recherches : ${imported} importée(s)${skipped ? `, ${skipped} ignorée(s)` : ''}.`);
}
this.importAllDone.set(done.length ? done : ['Rien à importer : déposez des fichiers ci-dessus.']);
} finally {
this.importAllStage.set(null);
this.importAllBusy.set(false);
}
try { input.value = ''; } catch {}
}
async importSubsCsv(): Promise<void> {
@@ -458,28 +574,6 @@ export class ImportComponent implements OnInit {
}
}
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) => {
+74
View File
@@ -92,6 +92,11 @@ export class OAuthImportService {
return this.http.post<any>(`/api/subscriptions/batch`, { items });
}
/** Lot de recherches (ex. historique-recherches.html de Takeout, max 1000/lot). */
importSearchBatch(items: Array<{ query: string; createdAt?: string }>): Observable<{ imported: number; skipped: number; total: number }> {
return this.http.post<any>(`/api/user/history/search/batch`, { items });
}
/** Historique YouTube via InnerTube authentifié (sync auto, sans fichier). */
ytHistory(max = 200): Observable<{ items: Array<{ videoId: string; title: string; thumbnail?: string; watchedAt?: string }>; count: number; hasMore: boolean }> {
return this.http.get<any>(`/api/oauth/google/yt-history?max=${max}`);
@@ -185,6 +190,75 @@ export function parseTakeoutPlaylistCsv(text: string): Array<{ videoId: string;
return out;
}
/** Nom de playlist NewTube dérivé d'un nom de fichier Takeout ("Vidéos X.csv" → "X"). */
export function takeoutPlaylistNameFromFile(fileName: string): string {
const base = String(fileName || '').split(/[\\/]/).pop() || '';
const noExt = base.replace(/\.csv$/i, '').trim();
const m = /^(?:vid[eé]os?|videos?)\s*[-–—:]?\s*(.+)$/i.exec(noExt);
const title = (m ? m[1] : noExt).trim();
return (title || noExt || 'Takeout').slice(0, 120);
}
/** Identité du compte source (`chaîne.csv` : id, titre). */
export function parseTakeoutIdentity(text: string): { channelId: string; title: string } | null {
for (const line of String(text || '').split(/\r?\n/)) {
const cells = splitCsvLine(line.trim());
if (!cells.length) continue;
const id = cells.find((c) => /^UC[A-Za-z0-9_-]{22}$/.test(c)) || '';
if (!id) continue;
const title = cells.find((c) => c && c !== id && !/^https?:\/\//i.test(c) && !/^(publique|public|privée|private)$/i.test(c)) || id;
return { channelId: id, title: title.slice(0, 120) };
}
return null;
}
/** Parse de `music library songs.csv` (id vidéo, titre, album, artistes). */
export function parseTakeoutMusicCsv(text: string): Array<{ videoId: string; title: string }> {
const lines = String(text || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const out: Array<{ videoId: string; title: string }> = [];
const seen = new Set<string>();
for (const line of lines) {
const cells = splitCsvLine(line);
const id = cells.find((c) => /^[A-Za-z0-9_-]{11}$/.test(c)) || '';
if (!id || seen.has(id)) continue;
seen.add(id);
// Colonnes typiques : id, morceau, album, artiste1, artiste2.
const rest = cells.filter((c) => c && c !== id);
const song = rest[0] || id;
const artists = rest.slice(2).filter((c) => c && c !== rest[1]).slice(0, 2).join(' & ');
out.push({ videoId: id, title: `${artists ? artists + ' – ' : ''}${song}`.slice(0, 300) });
}
return out;
}
/**
* Parse de `historique-recherches.html` : liens results?search_query= (requête
* décodée, fiable) + date de la cellule (retombe sur maintenant si localisée).
*/
export function parseTakeoutSearchHtml(html: string): Array<{ query: string; createdAt: string }> {
const byQuery = new Map<string, { query: string; createdAt: string }>();
try {
const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
const links = doc.querySelectorAll('a[href*="results?search_query="]');
links.forEach((a) => {
try {
const url = new URL(String((a as HTMLAnchorElement).href || ''), 'https://www.youtube.com');
const q = String(url.searchParams.get('search_query') || '').trim().slice(0, 300);
if (!q) return;
let createdAt = new Date().toISOString();
try {
const cell = (a as HTMLElement).closest('div');
const d = new Date(String(cell?.textContent || '').trim());
if (!Number.isNaN(d.getTime()) && d.getTime() > 946684800000) createdAt = d.toISOString();
} catch {}
const prev = byQuery.get(q.toLowerCase());
if (!prev || createdAt > prev.createdAt) byQuery.set(q.toLowerCase(), { query: q, createdAt });
} catch {}
});
} catch {}
return [...byQuery.values()].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
}
/** Parse de `abonnements.csv` de Takeout (id chaîne, url, titre). */
export function parseTakeoutSubscriptionsCsv(text: string): Array<{ provider: string; externalId: string; title: string; url: string }> {
const lines = String(text || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);