feat(sync): import Takeout HTML/CSV (historique, WL, abos) sans API
This commit is contained in:
@@ -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 ==================== -->
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user