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
+33
View File
@@ -357,6 +357,39 @@ r.post('/subscriptions', authMiddlewareCookieAware, subscriptionsLimiter, async
}
});
// Import en lot (ex. abonnements.csv de Takeout) : upsert direct, sans
// résolution externe (les IDs Takeout sont déjà fiables).
r.post('/subscriptions/batch', authMiddlewareCookieAware, subscriptionsLimiter, (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' });
const allowed = new Set(['youtube', 'twitch', 'dailymotion', 'peertube', 'odysee', 'rumble', 'yt', 'dm', 'tw', 'pt', 'od', 'ru']);
let imported = 0;
let skipped = 0;
for (const it of items) {
const provider = String(it?.provider || 'youtube').trim().toLowerCase();
const externalId = String(it?.externalId || '').trim().slice(0, 128);
if (!allowed.has(provider) || !externalId) { skipped++; continue; }
try {
const row = upsertChannelRow({
provider, externalId,
title: String(it?.title || externalId).slice(0, 200) || null,
handle: String(it?.handle || '').slice(0, 128) || null,
avatarUrl: String(it?.avatarUrl || '').slice(0, 500) || null,
url: String(it?.url || '').slice(0, 500) || null,
lastRefreshedAt: Date.now(),
});
subscribeChannel({ userId: req.user.id, provider, externalId, channelId: row?.id });
imported++;
} catch { skipped++; }
}
return res.json({ imported, skipped, total: items.length });
} catch {
return res.status(500).json({ error: 'subscriptions_batch_failed' });
}
});
r.delete('/subscriptions/:subscriptionId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
try {
const info = unsubscribeChannel({ userId: req.user.id, subscriptionId: req.params.subscriptionId });
@@ -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) => {
+101
View File
@@ -87,6 +87,11 @@ export class OAuthImportService {
return this.http.post<any>(`/api/user/history/takeout`, { items });
}
/** Lot d'abonnements (ex. abonnements.csv de Takeout, max 1000/lot). */
importSubscriptionsBatch(items: Array<{ provider?: string; externalId: string; title?: string; url?: string }>): Observable<{ imported: number; skipped: number; total: number }> {
return this.http.post<any>(`/api/subscriptions/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}`);
@@ -130,3 +135,99 @@ export function parseTakeoutHistory(json: unknown): Array<{ videoId: string; tit
}
return [...byId.values()].sort((a, b) => (a.watchedAt < b.watchedAt ? 1 : -1));
}
const YT_VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
const YT_CHANNEL_ID = /^UC[A-Za-z0-9_-]{22}$/;
/** Découpe une ligne CSV (guillemets gérés). */
function splitCsvLine(line: string): string[] {
const out: string[] = [];
let cur = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (inQuotes) {
if (c === '"') {
if (line[i + 1] === '"') { cur += '"'; i++; }
else inQuotes = false;
} else cur += c;
} else if (c === '"') inQuotes = true;
else if (c === ',') { out.push(cur); cur = ''; }
else cur += c;
}
out.push(cur);
return out.map((s) => s.trim());
}
/**
* Parse d'un CSV de playlist Takeout ("Vidéos ....csv" : id vidéo + date).
* Titres absents du CSV : videoId utilisé, enrichi si l'API le renvoie.
*/
export function parseTakeoutPlaylistCsv(text: string): Array<{ videoId: string; title: string; watchedAt: string }> {
const lines = String(text || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
const out: Array<{ videoId: string; title: string; watchedAt: string }> = [];
const seen = new Set<string>();
for (const line of lines) {
const cells = splitCsvLine(line);
const id = cells.find((c) => YT_VIDEO_ID.test(c)) || '';
if (!id || seen.has(id)) continue;
seen.add(id);
let watchedAt = new Date().toISOString();
for (const c of cells) {
if (c === id) continue;
if (/\d{4}-\d{2}-\d{2}/.test(c)) {
const d = new Date(c);
if (!Number.isNaN(d.getTime())) { watchedAt = d.toISOString(); break; }
}
}
out.push({ videoId: id, title: id, watchedAt });
}
return out;
}
/** 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);
const out: Array<{ provider: string; externalId: string; title: string; url: string }> = [];
const seen = new Set<string>();
for (const line of lines) {
const cells = splitCsvLine(line);
const id = cells.find((c) => YT_CHANNEL_ID.test(c)) || '';
if (!id || seen.has(id)) continue;
seen.add(id);
const url = cells.find((c) => /^https?:\/\//i.test(c)) || `https://www.youtube.com/channel/${id}`;
const title = [...cells].reverse().find((c) => c && c !== id && !/^https?:\/\//i.test(c)) || id;
out.push({ provider: 'youtube', externalId: id, title: title.slice(0, 200), url: url.slice(0, 500) });
}
return out;
}
/**
* Parse de `historique-vidéos.html` de Takeout (quand le JSON est absent).
* Structure : div.content-cell > a[watch?v=ID]Titre</a><br>Date localisée.
* Les dates non reconnues (mois en français) retombent sur maintenant.
*/
export function parseTakeoutHistoryHtml(html: string): Array<{ videoId: string; title: string; watchedAt: string }> {
const byId = new Map<string, { videoId: string; title: string; watchedAt: string }>();
try {
const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
const links = doc.querySelectorAll('a[href*="youtube.com/watch"]');
links.forEach((a) => {
const href = String((a as HTMLAnchorElement).href || '');
const m = /[?&]v=([A-Za-z0-9_-]{11})/.exec(href);
if (!m) return;
const videoId = m[1];
const title = String(a.textContent || '').trim().slice(0, 300) || videoId;
let watchedAt = new Date().toISOString();
try {
const cell = (a as HTMLElement).closest('div');
const txt = String(cell?.textContent || '').replace(title, ' ');
const d = new Date(txt.trim());
if (!Number.isNaN(d.getTime()) && d.getTime() > 946684800000) watchedAt = d.toISOString();
} catch {}
const prev = byId.get(videoId);
if (!prev || watchedAt > prev.watchedAt) byId.set(videoId, { videoId, title, watchedAt });
});
} catch {}
return [...byId.values()].sort((a, b) => (a.watchedAt < b.watchedAt ? 1 : -1));
}