From 03ad46e91b5dd95433186d942fedcb0481cdf185 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 27 Sep 2026 09:31:45 -0400 Subject: [PATCH] feat(sync): import Takeout HTML/CSV (historique, WL, abos) sans API --- server/index.mjs | 33 ++++ .../library/import/import.component.html | 41 ++++- .../library/import/import.component.ts | 148 ++++++++++++++++-- src/services/oauth-import.service.ts | 101 ++++++++++++ 4 files changed, 310 insertions(+), 13 deletions(-) diff --git a/server/index.mjs b/server/index.mjs index f6eb54f..c142af6 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -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 }); diff --git a/src/components/library/import/import.component.html b/src/components/library/import/import.component.html index 357410a..153cde4 100644 --- a/src/components/library/import/import.component.html +++ b/src/components/library/import/import.component.html @@ -141,9 +141,13 @@ {{ ytAutoLoading() ? 'Sync…' : '⚡ Sync auto (compte Google)' }} + + +
{{ wlCsvError() }}
+
{{ wlCsvResult() }}
+ +
+

📺 Abonnements (CSV Takeout)

+

Fichier abonnements.csv → abonnements NewTube, sans appel réseau.

+
+ + +
+
{{ subsCsvError() }}
+
{{ subsCsvResult() }} Voir
+
+ diff --git a/src/components/library/import/import.component.ts b/src/components/library/import/import.component.ts index d619dbb..289446b 100644 --- a/src/components/library/import/import.component.ts +++ b/src/components/library/import/import.component.ts @@ -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 { 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>([]); + wlCsvImporting = signal(false); + wlCsvResult = signal(null); + wlCsvError = signal(null); + + subsCsvItems = signal>([]); + subsCsvImporting = signal(false); + subsCsvResult = signal(null); + subsCsvError = signal(null); + + private readFileAsText(file: File): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) => { diff --git a/src/services/oauth-import.service.ts b/src/services/oauth-import.service.ts index f6a426d..435689c 100644 --- a/src/services/oauth-import.service.ts +++ b/src/services/oauth-import.service.ts @@ -87,6 +87,11 @@ export class OAuthImportService { return this.http.post(`/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(`/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(`/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(); + 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(); + 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
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(); + 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)); +}