diff --git a/src/lib/transfer.ts b/src/lib/transfer.ts new file mode 100644 index 0000000..0cb5091 --- /dev/null +++ b/src/lib/transfer.ts @@ -0,0 +1,516 @@ +import "server-only"; +import { prisma } from "./db"; +import { env } from "./env"; +import { ensureTitle } from "./catalog"; +import { addToLibrary } from "./library"; +import { episodeCode } from "./labels"; +import type { EntryStatus, TitleType } from "@/generated/prisma/client"; + +/** + * Daten hinein und hinaus. + * + * Hinein: Trakt.tv-Export (JSON) und CSV, wie es Serienjunkies, TV Time und + * Tabellenkalkulationen ausspucken. Hinaus: dieselben Daten als CSV oder JSON. + * + * Grundhaltung beim Import: **nichts überschreiben**. Was schon in der + * Bibliothek liegt, bleibt wie es ist; es kommt nur dazu, was fehlt. Ein + * Import soll niemand versehentlich seinen Fortschritt kosten. + */ + +export type ImportSummary = { + processed: number; + added: number; + skipped: number; + watchesAdded: number; + errors: string[]; +}; + +const STATUS_ALIASES: Record = { + watching: "WATCHING", + "schaue gerade": "WATCHING", + laufend: "WATCHING", + paused: "PAUSED", + pausiert: "PAUSED", + hold: "PAUSED", + completed: "COMPLETED", + abgeschlossen: "COMPLETED", + watched: "COMPLETED", + gesehen: "COMPLETED", + dropped: "DROPPED", + abgebrochen: "DROPPED", + watchlist: "WATCHLIST", + plantowatch: "WATCHLIST", + merkliste: "WATCHLIST", +}; + +function parseStatus(value: string | undefined): EntryStatus { + if (!value) return "WATCHLIST"; + return STATUS_ALIASES[value.trim().toLocaleLowerCase("de-DE")] ?? "WATCHLIST"; +} + +function parseType(value: string | undefined): TitleType | null { + const normalized = value?.trim().toLocaleLowerCase("de-DE"); + if (!normalized) return null; + if (["serie", "series", "show", "tv", "tvshow"].includes(normalized)) return "SERIES"; + if (["film", "movie", "kinofilm"].includes(normalized)) return "MOVIE"; + return null; +} + +// --------------------------------------------------------------------------- +// CSV +// --------------------------------------------------------------------------- + +/** + * Minimaler CSV-Leser mit Unterstützung für Anführungszeichen und + * eingebettete Zeilenumbrüche. Trennzeichen wird erraten (Komma oder + * Semikolon) — deutsche Tabellenprogramme nehmen gern das Semikolon. + */ +export function parseCsv(text: string): string[][] { + const clean = text.replace(/^/, ""); + const firstLine = clean.split(/\r?\n/, 1)[0] ?? ""; + const delimiter = (firstLine.match(/;/g)?.length ?? 0) > (firstLine.match(/,/g)?.length ?? 0) ? ";" : ","; + + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let quoted = false; + + for (let index = 0; index < clean.length; index += 1) { + const char = clean[index]; + + if (quoted) { + if (char === '"') { + if (clean[index + 1] === '"') { + field += '"'; + index += 1; + } else { + quoted = false; + } + } else { + field += char; + } + continue; + } + + if (char === '"') { + quoted = true; + } else if (char === delimiter) { + row.push(field); + field = ""; + } else if (char === "\n") { + row.push(field); + rows.push(row); + row = []; + field = ""; + } else if (char !== "\r") { + field += char; + } + } + + if (field.length > 0 || row.length > 0) { + row.push(field); + rows.push(row); + } + + return rows.filter((entry) => entry.some((cell) => cell.trim().length > 0)); +} + +const CSV_HEADERS: Record = { + titel: "title", + title: "title", + name: "title", + show: "title", + typ: "type", + type: "type", + art: "type", + status: "status", + bewertung: "rating", + rating: "rating", + score: "rating", + tmdb: "tmdbId", + tmdb_id: "tmdbId", + tmdbid: "tmdbId", + jahr: "year", + year: "year", + notiz: "notes", + notes: "notes", + kommentar: "notes", +}; + +/** + * CSV-Import. + * + * Erwartet eine Kopfzeile. Gebraucht wird nur eine Spalte mit dem Titel; + * Typ, Status, Bewertung, TMDB-ID und Notiz werden übernommen, wenn vorhanden. + */ +export async function importCsv(userId: string, text: string): Promise { + const summary: ImportSummary = { processed: 0, added: 0, skipped: 0, watchesAdded: 0, errors: [] }; + const rows = parseCsv(text); + const header = rows.shift(); + + if (!header) { + summary.errors.push("Die Datei ist leer."); + return summary; + } + + const columns = header.map( + (cell) => CSV_HEADERS[cell.trim().toLocaleLowerCase("de-DE").replace(/\s+/g, "_")] ?? "", + ); + if (!columns.includes("title")) { + summary.errors.push( + 'Es fehlt eine Titelspalte. Die Kopfzeile braucht eine Spalte „Titel" (oder „title", „name", „show").', + ); + return summary; + } + + for (const row of rows) { + summary.processed += 1; + const record: Record = {}; + columns.forEach((key, index) => { + if (key) record[key] = (row[index] ?? "").trim(); + }); + + const title = record.title; + if (!title) { + summary.skipped += 1; + continue; + } + + try { + const result = await importOne(userId, { + title, + type: parseType(record.type), + tmdbId: record.tmdbId ? Number.parseInt(record.tmdbId, 10) : null, + status: parseStatus(record.status), + rating: record.rating ? Number.parseInt(record.rating, 10) : null, + year: record.year ? Number.parseInt(record.year, 10) : null, + notes: record.notes || null, + }); + if (result === "added") summary.added += 1; + else summary.skipped += 1; + } catch (error) { + summary.errors.push(`${title}: ${error instanceof Error ? error.message : "Fehler"}`); + } + } + + return summary; +} + +// --------------------------------------------------------------------------- +// Trakt +// --------------------------------------------------------------------------- + +type TraktEntry = { + type?: string; + show?: { title?: string; year?: number; ids?: { tmdb?: number } }; + movie?: { title?: string; year?: number; ids?: { tmdb?: number } }; + title?: string; + year?: number; + ids?: { tmdb?: number }; + rating?: number; + listed_at?: string; + last_watched_at?: string; + watched_at?: string; + plays?: number; +}; + +/** + * Trakt.tv-Export. + * + * Trakt gibt je nach Ansicht unterschiedliche Formen aus (Watchlist, + * History, Ratings, Collection). Gemeinsam ist ihnen ein Array mit Einträgen, + * die entweder `show` oder `movie` tragen — genau darauf stützt sich der + * Import, statt ein bestimmtes Exportformat zu verlangen. + */ +export async function importTrakt(userId: string, json: string): Promise { + const summary: ImportSummary = { processed: 0, added: 0, skipped: 0, watchesAdded: 0, errors: [] }; + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + summary.errors.push("Die Datei ist kein gültiges JSON."); + return summary; + } + + // Ein Array direkt, oder ein Objekt mit Listen darin. + const entries: TraktEntry[] = Array.isArray(parsed) + ? (parsed as TraktEntry[]) + : typeof parsed === "object" && parsed !== null + ? Object.values(parsed as Record) + .filter(Array.isArray) + .flat() as TraktEntry[] + : []; + + if (entries.length === 0) { + summary.errors.push("In der Datei stehen keine Einträge, die Flimmer versteht."); + return summary; + } + + for (const entry of entries) { + summary.processed += 1; + + const source = entry.show ?? entry.movie ?? entry; + const title = source.title; + if (!title) { + summary.skipped += 1; + continue; + } + + const type: TitleType | null = entry.show + ? "SERIES" + : entry.movie + ? "MOVIE" + : parseType(entry.type); + + // Was schon gesehen wurde, gilt als abgeschlossen; alles andere landet + // auf der Watchlist. Genauer geht es aus einem Export nicht hervor. + const watched = Boolean(entry.watched_at ?? entry.last_watched_at ?? entry.plays); + + try { + const result = await importOne(userId, { + title, + type, + tmdbId: source.ids?.tmdb ?? null, + status: watched ? "COMPLETED" : "WATCHLIST", + rating: typeof entry.rating === "number" ? Math.round(entry.rating) : null, + year: source.year ?? null, + notes: null, + }); + if (result === "added") summary.added += 1; + else summary.skipped += 1; + } catch (error) { + summary.errors.push(`${title}: ${error instanceof Error ? error.message : "Fehler"}`); + } + } + + return summary; +} + +// --------------------------------------------------------------------------- +// Gemeinsamer Kern +// --------------------------------------------------------------------------- + +type ImportRecord = { + title: string; + type: TitleType | null; + tmdbId: number | null; + status: EntryStatus; + rating: number | null; + year: number | null; + notes: string | null; +}; + +/** + * Nimmt einen einzelnen Eintrag auf. + * + * Mit TMDB-ID wird der Titel bei TMDB geholt; ohne wird im lokalen Katalog + * nach dem Namen gesucht und andernfalls ein manueller Eintrag angelegt. + * Das ist bewusst so: ein Import soll auch ohne TMDB-Schlüssel durchlaufen. + */ +async function importOne(userId: string, record: ImportRecord): Promise<"added" | "skipped"> { + let titleId: string | null = null; + + if (record.tmdbId && record.type && env.tmdb.isConfigured) { + try { + const title = await ensureTitle(record.type, record.tmdbId, { region: env.tmdb.region }); + titleId = title.id; + } catch { + // TMDB nicht erreichbar oder ID unbekannt — unten weiterversuchen. + } + } + + if (!titleId) { + const existing = await prisma.title.findFirst({ + where: { + title: { equals: record.title, mode: "insensitive" }, + ...(record.type ? { type: record.type } : {}), + }, + select: { id: true }, + }); + titleId = existing?.id ?? null; + } + + if (!titleId) { + const created = await prisma.title.create({ + data: { + type: record.type ?? "MOVIE", + title: record.title, + releaseDate: record.year ? new Date(Date.UTC(record.year, 0, 1)) : null, + status: "Importiert", + syncedAt: new Date(), + }, + select: { id: true }, + }); + titleId = created.id; + } + + const existingEntry = await prisma.libraryEntry.findUnique({ + where: { userId_titleId: { userId, titleId } }, + select: { id: true }, + }); + if (existingEntry) return "skipped"; + + const entry = await addToLibrary(userId, titleId, record.status); + + if (record.rating || record.notes) { + await prisma.libraryEntry.update({ + where: { id: entry.id }, + data: { + rating: record.rating && record.rating >= 1 && record.rating <= 10 ? record.rating : null, + notes: record.notes, + }, + }); + } + + return "added"; +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +function csvCell(value: unknown): string { + const text = value === null || value === undefined ? "" : String(value); + return /[",;\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +/** Bibliothek als CSV — ein Titel je Zeile. */ +export async function exportCsv(userId: string): Promise { + const entries = await prisma.libraryEntry.findMany({ + where: { userId, deletedAt: null }, + orderBy: { title: { title: "asc" } }, + include: { + title: true, + tags: { include: { tag: { select: { name: true } } } }, + }, + }); + + const header = [ + "Titel", + "Typ", + "Jahr", + "TMDB", + "Status", + "Bewertung", + "Durchlaeufe", + "WoGeschaut", + "Tags", + "Notiz", + "Hinzugefuegt", + "Abgeschlossen", + ]; + + const rows = entries.map((entry) => + [ + entry.title.title, + entry.title.type === "SERIES" ? "Serie" : "Film", + entry.title.releaseDate?.getFullYear() ?? "", + entry.title.tmdbId ?? "", + entry.status, + entry.rating ?? "", + entry.currentRun + 1, + entry.whereToWatch ?? "", + entry.tags.map((link) => link.tag.name).join(" "), + entry.notes?.replace(/\r?\n/g, " ") ?? "", + entry.createdAt.toISOString().slice(0, 10), + entry.finishedAt?.toISOString().slice(0, 10) ?? "", + ] + .map(csvCell) + .join(","), + ); + + return [header.join(","), ...rows].join("\n"); +} + +/** Vollständiger Export als JSON, inklusive Sichtungen und Universen. */ +export async function exportJson(userId: string): Promise { + const [user, entries, watches, universes] = await Promise.all([ + prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { email: true, name: true, region: true, createdAt: true }, + }), + prisma.libraryEntry.findMany({ + where: { userId, deletedAt: null }, + include: { + title: { + select: { + title: true, + type: true, + tmdbId: true, + imdbId: true, + releaseDate: true, + genres: true, + }, + }, + tags: { include: { tag: { select: { name: true } } } }, + }, + }), + prisma.watch.findMany({ + where: { userId }, + orderBy: { watchedAt: "asc" }, + include: { + title: { select: { title: true, tmdbId: true, type: true } }, + episode: { select: { seasonNumber: true, episodeNumber: true, name: true } }, + }, + }), + prisma.universe.findMany({ + where: { ownerId: userId, deletedAt: null }, + include: { + items: { include: { title: { select: { title: true, tmdbId: true, type: true } } } }, + orders: { include: { items: { orderBy: { position: "asc" } } } }, + }, + }), + ]); + + return { + format: "flimmer-export", + version: 1, + exportedAt: new Date().toISOString(), + user, + library: entries.map((entry) => ({ + title: entry.title.title, + type: entry.title.type, + tmdbId: entry.title.tmdbId, + imdbId: entry.title.imdbId, + year: entry.title.releaseDate?.getFullYear() ?? null, + genres: entry.title.genres, + status: entry.status, + rating: entry.rating, + notes: entry.notes, + whereToWatch: entry.whereToWatch, + favorite: entry.favorite, + currentRun: entry.currentRun, + tags: entry.tags.map((link) => link.tag.name), + startedAt: entry.startedAt, + finishedAt: entry.finishedAt, + createdAt: entry.createdAt, + })), + watches: watches.map((watch) => ({ + title: watch.title.title, + tmdbId: watch.title.tmdbId, + type: watch.title.type, + episode: watch.episode + ? episodeCode(watch.episode.seasonNumber, watch.episode.episodeNumber) + : null, + episodeName: watch.episode?.name ?? null, + run: watch.run, + watchedAt: watch.watchedAt, + minutes: watch.minutes, + })), + universes: universes.map((universe) => ({ + name: universe.name, + description: universe.description, + titles: universe.items.map((item) => ({ + title: item.title.title, + tmdbId: item.title.tmdbId, + type: item.title.type, + })), + orders: universe.orders.map((order) => ({ + name: order.name, + kind: order.kind, + positions: order.items.map((entry) => entry.position), + })), + })), + }; +}