diff --git a/server/db.mjs b/server/db.mjs index ab40bc0..191d0f5 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -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 = ?']; diff --git a/server/index.mjs b/server/index.mjs index c142af6..9435dca 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -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; diff --git a/src/components/library/import/import.component.html b/src/components/library/import/import.component.html index 153cde4..bb9124b 100644 --- a/src/components/library/import/import.component.html +++ b/src/components/library/import/import.component.html @@ -127,37 +127,51 @@ - +
-

🕘Historique YouTube (Takeout)

+

📦Export Takeout complet

- Google n’expose pas l’historique par API Data : sync auto via votre compte - connecté, ou export manuel via takeout.google.com → YouTube → watch-history.json. - Fichier parsé localement dans votre navigateur, sans doublons. + Déposez tous les fichiers de + Takeout/YouTube et YouTube Music d’un coup : historique (watch-history.json ou + historique-vidéos.html), abonnements.csv, playlists (Vidéos *.csv), + music library songs.csv, historique-recherches.html. + Parsé localement dans votre navigateur, sans doublons. + Non gérés : commentaires, uploads personnels, métadonnées de chaîne.

+
+ 👤 Export de : {{ ident.title }}{{ ident.channelId }} +
- - +
{{ ytAutoError() }}
-
Analyse du fichier…
-
- {{ takeoutItems().length }} vidéo(s) — du {{ rg.from }} au {{ rg.to }} ({{ takeoutFileName() }}) -
+
Analyse des fichiers…
+ + + +
{{ importAllStage() }}
Progression : {{ pg.done }}/{{ pg.total }}
@@ -165,44 +179,14 @@
{{ takeoutError() }}
-
- {{ r.imported }} entrée(s) importée(s){{ r.skipped ? `, ${r.skipped} ignorée(s)` : '' }}. - Voir l’historique -
- -
-
-

📋 Regarder plus tard (CSV Takeout)

-

Fichier Vidéos Watch later.csv du dossier listes de lecture → playlist NewTube.

-
- - -
-
{{ wlCsvError() }}
-
{{ wlCsvResult() }}
-
-
-

📺 Abonnements (CSV Takeout)

-

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

-
- - -
-
{{ subsCsvError() }}
-
{{ subsCsvResult() }} Voir
-
+
{{ subsCsvError() }}
+ +
diff --git a/src/components/library/import/import.component.ts b/src/components/library/import/import.component.ts index 289446b..df3cc61 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, 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 { 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>([]); - wlCsvImporting = signal(false); - wlCsvResult = signal(null); - wlCsvError = signal(null); + stagedSources = signal([]); + staging = signal(false); + stagedIdentity = signal<{ channelId: string; title: string } | null>(null); + stagedPlaylists = signal }>>([]); + searchesItems = signal>([]); + importAllBusy = signal(false); + importAllStage = signal(null); + importAllDone = signal>([]); subsCsvItems = signal>([]); subsCsvImporting = signal(false); @@ -386,55 +371,186 @@ export class ImportComponent implements OnInit { }); } - 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([]); + 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 { - 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 { + 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 { 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(); + 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 { + 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 { @@ -458,28 +574,6 @@ export class ImportComponent implements OnInit { } } - 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 435689c..e86e148 100644 --- a/src/services/oauth-import.service.ts +++ b/src/services/oauth-import.service.ts @@ -92,6 +92,11 @@ export class OAuthImportService { return this.http.post(`/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(`/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(`/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(); + 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(); + 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);