feat(sync): refonte import Takeout complet (playlists, musique, recherches, identité)
This commit is contained in:
@@ -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 = ?'];
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user