A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: trakt import

+2179 -143
+2
apps/web/package.json
··· 30 30 "date-fns": "^4.1.0", 31 31 "lucide-react": "^0.561.0", 32 32 "nitro": "npm:nitro-nightly@latest", 33 + "papaparse": "^5.5.3", 33 34 "posthog-js": "^1.356.1", 34 35 "radix-ui": "^1.4.3", 35 36 "react": "^19.2.0", ··· 46 47 "@biomejs/biome": "2.2.4", 47 48 "@tanstack/devtools-vite": "^0.3.11", 48 49 "@types/node": "^22.10.2", 50 + "@types/papaparse": "^5.5.2", 49 51 "@types/react": "^19.2.0", 50 52 "@types/react-dom": "^19.2.0", 51 53 "@vitejs/plugin-react": "^5.0.4",
+21
apps/web/src/routeTree.gen.ts
··· 12 12 import { Route as SearchRouteImport } from './routes/search' 13 13 import { Route as ProfileRouteImport } from './routes/profile' 14 14 import { Route as PrivacyRouteImport } from './routes/privacy' 15 + import { Route as OnboardingRouteImport } from './routes/onboarding' 15 16 import { Route as LoginRouteImport } from './routes/login' 16 17 import { Route as ListsRouteImport } from './routes/lists' 17 18 import { Route as IndexRouteImport } from './routes/index' ··· 38 39 const PrivacyRoute = PrivacyRouteImport.update({ 39 40 id: '/privacy', 40 41 path: '/privacy', 42 + getParentRoute: () => rootRouteImport, 43 + } as any) 44 + const OnboardingRoute = OnboardingRouteImport.update({ 45 + id: '/onboarding', 46 + path: '/onboarding', 41 47 getParentRoute: () => rootRouteImport, 42 48 } as any) 43 49 const LoginRoute = LoginRouteImport.update({ ··· 107 113 '/': typeof IndexRoute 108 114 '/lists': typeof ListsRouteWithChildren 109 115 '/login': typeof LoginRoute 116 + '/onboarding': typeof OnboardingRoute 110 117 '/privacy': typeof PrivacyRoute 111 118 '/profile': typeof ProfileRouteWithChildren 112 119 '/search': typeof SearchRoute ··· 124 131 '/': typeof IndexRoute 125 132 '/lists': typeof ListsRouteWithChildren 126 133 '/login': typeof LoginRoute 134 + '/onboarding': typeof OnboardingRoute 127 135 '/privacy': typeof PrivacyRoute 128 136 '/profile': typeof ProfileRouteWithChildren 129 137 '/search': typeof SearchRoute ··· 142 150 '/': typeof IndexRoute 143 151 '/lists': typeof ListsRouteWithChildren 144 152 '/login': typeof LoginRoute 153 + '/onboarding': typeof OnboardingRoute 145 154 '/privacy': typeof PrivacyRoute 146 155 '/profile': typeof ProfileRouteWithChildren 147 156 '/search': typeof SearchRoute ··· 161 170 | '/' 162 171 | '/lists' 163 172 | '/login' 173 + | '/onboarding' 164 174 | '/privacy' 165 175 | '/profile' 166 176 | '/search' ··· 178 188 | '/' 179 189 | '/lists' 180 190 | '/login' 191 + | '/onboarding' 181 192 | '/privacy' 182 193 | '/profile' 183 194 | '/search' ··· 195 206 | '/' 196 207 | '/lists' 197 208 | '/login' 209 + | '/onboarding' 198 210 | '/privacy' 199 211 | '/profile' 200 212 | '/search' ··· 213 225 IndexRoute: typeof IndexRoute 214 226 ListsRoute: typeof ListsRouteWithChildren 215 227 LoginRoute: typeof LoginRoute 228 + OnboardingRoute: typeof OnboardingRoute 216 229 PrivacyRoute: typeof PrivacyRoute 217 230 ProfileRoute: typeof ProfileRouteWithChildren 218 231 SearchRoute: typeof SearchRoute ··· 242 255 path: '/privacy' 243 256 fullPath: '/privacy' 244 257 preLoaderRoute: typeof PrivacyRouteImport 258 + parentRoute: typeof rootRouteImport 259 + } 260 + '/onboarding': { 261 + id: '/onboarding' 262 + path: '/onboarding' 263 + fullPath: '/onboarding' 264 + preLoaderRoute: typeof OnboardingRouteImport 245 265 parentRoute: typeof rootRouteImport 246 266 } 247 267 '/login': { ··· 387 407 IndexRoute: IndexRoute, 388 408 ListsRoute: ListsRouteWithChildren, 389 409 LoginRoute: LoginRoute, 410 + OnboardingRoute: OnboardingRoute, 390 411 PrivacyRoute: PrivacyRoute, 391 412 ProfileRoute: ProfileRouteWithChildren, 392 413 SearchRoute: SearchRoute,
+38 -1
apps/web/src/routes/__root.tsx
··· 1 1 import { configureApiClient } from "@opnshelf/api"; 2 2 import { PostHogProvider, usePostHog } from "@posthog/react"; 3 3 import { TanStackDevtools } from "@tanstack/react-devtools"; 4 - import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 + import { authControllerMeOptions } from "@opnshelf/api"; 5 + import { 6 + type QueryClient, 7 + QueryClientProvider, 8 + useQuery, 9 + } from "@tanstack/react-query"; 5 10 import { 6 11 createRootRouteWithContext, 7 12 HeadContent, 8 13 Outlet, 9 14 Scripts, 10 15 useLocation, 16 + useNavigate, 11 17 } from "@tanstack/react-router"; 12 18 import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; 13 19 import React from "react"; ··· 77 83 return ( 78 84 <QueryClientProvider client={queryClient}> 79 85 <ThemeProvider> 86 + <OnboardingGate /> 80 87 <ScreenTracker /> 81 88 <div className="min-h-screen flex flex-col"> 82 89 <Header /> ··· 101 108 </ThemeProvider> 102 109 </QueryClientProvider> 103 110 ); 111 + } 112 + 113 + function OnboardingGate() { 114 + const location = useLocation(); 115 + const navigate = useNavigate(); 116 + const { data: user } = useQuery({ 117 + ...authControllerMeOptions(), 118 + retry: false, 119 + staleTime: 60_000, 120 + }); 121 + 122 + React.useEffect(() => { 123 + if (!user) { 124 + return; 125 + } 126 + 127 + const pathname = location.pathname; 128 + const isAuthRoute = pathname === "/login" || pathname.startsWith("/auth/"); 129 + 130 + if (user.needsOnboarding && pathname !== "/onboarding" && !isAuthRoute) { 131 + navigate({ to: "/onboarding", replace: true }); 132 + return; 133 + } 134 + 135 + if (!user.needsOnboarding && pathname === "/onboarding") { 136 + navigate({ to: "/profile/shelf", replace: true }); 137 + } 138 + }, [location.pathname, navigate, user]); 139 + 140 + return null; 104 141 } 105 142 106 143 function RootDocument({ children }: { children: React.ReactNode }) {
+8 -2
apps/web/src/routes/auth/complete.tsx
··· 55 55 sessionStorage.removeItem("auth_redirect"); 56 56 57 57 if (storedRedirect && isValidRedirectPath(storedRedirect)) { 58 - navigate({ to: storedRedirect }); 58 + if (user?.needsOnboarding) { 59 + navigate({ to: "/onboarding" }); 60 + } else { 61 + navigate({ to: storedRedirect }); 62 + } 59 63 } else { 60 - navigate({ to: "/profile/shelf" }); 64 + navigate({ 65 + to: user?.needsOnboarding ? "/onboarding" : "/profile/shelf", 66 + }); 61 67 } 62 68 } catch (error) { 63 69 console.error("Auth complete failed:", error);
+717
apps/web/src/routes/onboarding.tsx
··· 1 + import { 2 + authControllerMeOptions, 3 + type NormalizedImportItemDto, 4 + usersControllerCompleteOnboardingMutation, 5 + usersControllerFetchMyTraktPublicHistoryMutation, 6 + usersControllerImportMyHistoryMutation, 7 + } from "@opnshelf/api"; 8 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 9 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 10 + import Papa from "papaparse"; 11 + import { useEffect, useMemo, useState } from "react"; 12 + import { toast } from "sonner"; 13 + import { M3Button } from "@/components/ui/m3-button"; 14 + 15 + type TabValue = "trakt" | "csv"; 16 + type CsvParseError = { row: number; message: string }; 17 + type ImportPhase = 18 + | "idle" 19 + | "fetching_trakt" 20 + | "parsing_csv" 21 + | "importing" 22 + | "done" 23 + | "error"; 24 + 25 + type ImportProgressState = { 26 + phase: ImportPhase; 27 + totalItems: number; 28 + processedItems: number; 29 + currentBatch: number; 30 + totalBatches: number; 31 + imported: number; 32 + skipped: number; 33 + failed: number; 34 + startedAt: number | null; 35 + message: string; 36 + }; 37 + 38 + type ImportProgressUpdate = { 39 + totalItems: number; 40 + processedItems: number; 41 + currentBatch: number; 42 + totalBatches: number; 43 + imported: number; 44 + skipped: number; 45 + failed: number; 46 + }; 47 + 48 + const MAX_BATCH_SIZE = 25; 49 + const CSV_HEADERS = [ 50 + "watched_at", 51 + "action", 52 + "type", 53 + "tmdb_id", 54 + "season_number", 55 + "episode_number", 56 + ] as const; 57 + 58 + export const Route = createFileRoute("/onboarding")({ 59 + head: () => ({ 60 + meta: [{ title: "Welcome | OpnShelf" }], 61 + }), 62 + component: OnboardingPage, 63 + }); 64 + 65 + function OnboardingPage() { 66 + const queryClient = useQueryClient(); 67 + const navigate = useNavigate(); 68 + const [step, setStep] = useState(1); 69 + const [activeTab, setActiveTab] = useState<TabValue>("trakt"); 70 + const [traktUsername, setTraktUsername] = useState(""); 71 + const [importResult, setImportResult] = useState({ 72 + imported: 0, 73 + skipped: 0, 74 + failed: 0, 75 + errors: [] as string[], 76 + }); 77 + const [importProgress, setImportProgress] = useState<ImportProgressState>({ 78 + phase: "idle", 79 + totalItems: 0, 80 + processedItems: 0, 81 + currentBatch: 0, 82 + totalBatches: 0, 83 + imported: 0, 84 + skipped: 0, 85 + failed: 0, 86 + startedAt: null, 87 + message: "", 88 + }); 89 + 90 + const { data: user, isLoading: isAuthLoading } = useQuery({ 91 + ...authControllerMeOptions(), 92 + retry: false, 93 + staleTime: 0, 94 + }); 95 + 96 + const completeOnboardingMutation = useMutation({ 97 + ...usersControllerCompleteOnboardingMutation(), 98 + onError: () => { 99 + toast.error("Could not complete onboarding"); 100 + }, 101 + }); 102 + 103 + const fetchTraktMutation = useMutation({ 104 + ...usersControllerFetchMyTraktPublicHistoryMutation(), 105 + }); 106 + 107 + const importHistoryMutation = useMutation({ 108 + ...usersControllerImportMyHistoryMutation(), 109 + }); 110 + 111 + const progress = useMemo(() => (step / 3) * 100, [step]); 112 + const isImporting = 113 + fetchTraktMutation.isPending || importHistoryMutation.isPending; 114 + const isImportBusy = isImporting || importProgress.phase === "parsing_csv"; 115 + const importPercent = 116 + importProgress.totalItems > 0 117 + ? Math.round((importProgress.processedItems / importProgress.totalItems) * 100) 118 + : 0; 119 + const isCompleting = completeOnboardingMutation.isPending; 120 + const needsAuthRedirect = !isAuthLoading && !user; 121 + const needsShelfRedirect = !isAuthLoading && !!user && !user.needsOnboarding; 122 + 123 + useEffect(() => { 124 + if (needsAuthRedirect) { 125 + navigate({ to: "/login", search: { redirect: "/onboarding" } }); 126 + return; 127 + } 128 + 129 + if (needsShelfRedirect) { 130 + navigate({ to: "/profile/shelf" }); 131 + } 132 + }, [navigate, needsAuthRedirect, needsShelfRedirect]); 133 + 134 + if (isAuthLoading) { 135 + return ( 136 + <div className="flex-1 flex items-center justify-center"> 137 + <div className="w-8 h-8 border-4 border-t-transparent rounded-full animate-spin border-[var(--md-sys-color-primary)]" /> 138 + </div> 139 + ); 140 + } 141 + 142 + if (needsAuthRedirect || needsShelfRedirect || !user) { 143 + return null; 144 + } 145 + 146 + const handleSkip = async () => { 147 + await completeOnboardingAndRedirect(); 148 + }; 149 + 150 + const completeOnboardingAndRedirect = async () => { 151 + await completeOnboardingMutation.mutateAsync({}); 152 + queryClient.setQueryData(authControllerMeOptions().queryKey, (previousUser) => { 153 + if (!previousUser) { 154 + return previousUser; 155 + } 156 + 157 + return { 158 + ...previousUser, 159 + needsOnboarding: false, 160 + }; 161 + }); 162 + 163 + navigate({ to: "/profile/shelf", replace: true }); 164 + void queryClient.invalidateQueries({ queryKey: authControllerMeOptions().queryKey }); 165 + }; 166 + 167 + const handleTraktImport = async () => { 168 + const username = traktUsername.trim(); 169 + if (!username) { 170 + toast.error("Enter your Trakt username"); 171 + return; 172 + } 173 + 174 + try { 175 + setImportProgress({ 176 + phase: "fetching_trakt", 177 + totalItems: 0, 178 + processedItems: 0, 179 + currentBatch: 0, 180 + totalBatches: 0, 181 + imported: 0, 182 + skipped: 0, 183 + failed: 0, 184 + startedAt: Date.now(), 185 + message: "Fetching public history from Trakt...", 186 + }); 187 + 188 + const fetched = await fetchTraktMutation.mutateAsync({ 189 + body: { 190 + username, 191 + }, 192 + }); 193 + 194 + if (!fetched.items.length) { 195 + setImportResult({ 196 + imported: 0, 197 + skipped: fetched.skipped.length, 198 + failed: 0, 199 + errors: [], 200 + }); 201 + setImportProgress((prev) => ({ 202 + ...prev, 203 + phase: "done", 204 + message: "No importable items found.", 205 + })); 206 + toast.message("No supported watch history items found"); 207 + return; 208 + } 209 + 210 + const result = await runImportInChunks( 211 + fetched.items, 212 + importHistoryMutation.mutateAsync, 213 + (update) => { 214 + setImportProgress((prev) => ({ 215 + ...prev, 216 + phase: "importing", 217 + message: "Importing history...", 218 + ...update, 219 + })); 220 + }, 221 + ); 222 + setImportResult(result); 223 + setImportProgress((prev) => ({ 224 + ...prev, 225 + phase: "done", 226 + message: "Import complete.", 227 + })); 228 + setStep(3); 229 + } catch (error) { 230 + const message = 231 + error instanceof Error 232 + ? error.message 233 + : "Unable to fetch Trakt history right now"; 234 + setImportProgress((prev) => ({ 235 + ...prev, 236 + phase: "error", 237 + message, 238 + })); 239 + toast.error(message); 240 + } 241 + }; 242 + 243 + const handleCsvUpload = async (file: File) => { 244 + try { 245 + setImportProgress({ 246 + phase: "parsing_csv", 247 + totalItems: 0, 248 + processedItems: 0, 249 + currentBatch: 0, 250 + totalBatches: 0, 251 + imported: 0, 252 + skipped: 0, 253 + failed: 0, 254 + startedAt: Date.now(), 255 + message: "Parsing CSV file...", 256 + }); 257 + 258 + const { items, errors } = await parseCsvFile(file); 259 + if (!items.length) { 260 + setImportResult({ 261 + imported: 0, 262 + skipped: 0, 263 + failed: errors.length, 264 + errors: errors.map((entry) => entry.message), 265 + }); 266 + setImportProgress((prev) => ({ 267 + ...prev, 268 + phase: "error", 269 + failed: errors.length, 270 + message: "No valid rows found in CSV.", 271 + })); 272 + toast.error("No valid rows found in CSV"); 273 + return; 274 + } 275 + 276 + const imported = await runImportInChunks( 277 + items, 278 + importHistoryMutation.mutateAsync, 279 + (update) => { 280 + setImportProgress((prev) => ({ 281 + ...prev, 282 + phase: "importing", 283 + message: "Importing history...", 284 + ...update, 285 + })); 286 + }, 287 + ); 288 + 289 + setImportResult({ 290 + imported: imported.imported, 291 + skipped: imported.skipped, 292 + failed: imported.failed + errors.length, 293 + errors: [...errors.map((entry) => entry.message), ...imported.errors], 294 + }); 295 + setImportProgress((prev) => ({ 296 + ...prev, 297 + phase: "done", 298 + failed: imported.failed + errors.length, 299 + message: "Import complete.", 300 + })); 301 + setStep(3); 302 + } catch (error) { 303 + const message = 304 + error instanceof Error 305 + ? error.message 306 + : "Unable to parse CSV file"; 307 + setImportProgress((prev) => ({ 308 + ...prev, 309 + phase: "error", 310 + message, 311 + })); 312 + toast.error(message); 313 + } 314 + }; 315 + 316 + return ( 317 + <div className="flex-1 flex items-center justify-center p-4"> 318 + <div className="w-full max-w-3xl rounded-(--md-sys-shape-corner-large) border p-6 md:p-8 bg-(--md-sys-color-surface)"> 319 + <h1 className="md-headline-large mb-2">Welcome to OpnShelf</h1> 320 + <p className="md-body-large text-(--md-sys-color-on-surface-variant)"> 321 + Bring your watch history over, or skip and start tracking now. 322 + </p> 323 + 324 + <div className="h-2 rounded-full mt-6 mb-8 bg-(--md-sys-color-surface-container)"> 325 + <div 326 + className="h-2 rounded-full transition-all" 327 + style={{ 328 + width: `${progress}%`, 329 + backgroundColor: "var(--md-sys-color-primary)", 330 + }} 331 + /> 332 + </div> 333 + 334 + {step === 1 && ( 335 + <div className="space-y-5"> 336 + <p className="md-body-large text-(--md-sys-color-on-surface-variant)"> 337 + Step 1 of 3: We can import your existing watches from Trakt or from 338 + a CSV export. 339 + </p> 340 + <div className="flex gap-3"> 341 + <M3Button variant="filled" onClick={() => setStep(2)}> 342 + Start import 343 + </M3Button> 344 + <M3Button 345 + variant="text" 346 + onClick={handleSkip} 347 + disabled={isCompleting} 348 + > 349 + {isCompleting ? "Finishing..." : "Skip for now"} 350 + </M3Button> 351 + </div> 352 + </div> 353 + )} 354 + 355 + {step === 2 && ( 356 + <div className="space-y-4"> 357 + {importProgress.phase !== "idle" && ( 358 + <div className="rounded-(--md-sys-shape-corner-medium) border p-3 space-y-2 bg-(--md-sys-color-surface-container-low)"> 359 + <p className="md-label-large">{importProgress.message}</p> 360 + {importProgress.phase === "importing" ? ( 361 + <> 362 + <div className="h-2 rounded-full bg-(--md-sys-color-surface-container)"> 363 + <div 364 + className="h-2 rounded-full transition-all" 365 + style={{ 366 + width: `${importPercent}%`, 367 + backgroundColor: "var(--md-sys-color-primary)", 368 + }} 369 + /> 370 + </div> 371 + <p className="md-body-small text-(--md-sys-color-on-surface-variant)"> 372 + {importProgress.processedItems} / {importProgress.totalItems} items 373 + ({importPercent}%) 374 + </p> 375 + <p className="md-body-small text-(--md-sys-color-on-surface-variant)"> 376 + Batch {importProgress.currentBatch} / {importProgress.totalBatches} 377 + · Imported {importProgress.imported} · Skipped{" "} 378 + {importProgress.skipped} · Failed {importProgress.failed} 379 + </p> 380 + </> 381 + ) : ( 382 + <p className="md-body-small text-(--md-sys-color-on-surface-variant)"> 383 + Preparing import... 384 + </p> 385 + )} 386 + </div> 387 + )} 388 + 389 + <div className="flex gap-2"> 390 + <button 391 + type="button" 392 + onClick={() => setActiveTab("trakt")} 393 + className="px-3 py-2 rounded-(--md-sys-shape-corner-medium)" 394 + style={{ 395 + backgroundColor: 396 + activeTab === "trakt" 397 + ? "var(--md-sys-color-secondary-container)" 398 + : "var(--md-sys-color-surface-container)", 399 + }} 400 + > 401 + Trakt username 402 + </button> 403 + <button 404 + type="button" 405 + onClick={() => setActiveTab("csv")} 406 + className="px-3 py-2 rounded-(--md-sys-shape-corner-medium)" 407 + style={{ 408 + backgroundColor: 409 + activeTab === "csv" 410 + ? "var(--md-sys-color-secondary-container)" 411 + : "var(--md-sys-color-surface-container)", 412 + }} 413 + > 414 + CSV upload 415 + </button> 416 + </div> 417 + 418 + {activeTab === "trakt" ? ( 419 + <div className="space-y-3"> 420 + <input 421 + type="text" 422 + value={traktUsername} 423 + onChange={(event) => setTraktUsername(event.target.value)} 424 + placeholder="Trakt username" 425 + className="w-full rounded-(--md-sys-shape-corner-medium) border px-3 py-2" 426 + /> 427 + <M3Button 428 + variant="filled" 429 + onClick={handleTraktImport} 430 + disabled={isImportBusy} 431 + > 432 + Fetch and import 433 + </M3Button> 434 + </div> 435 + ) : ( 436 + <div className="space-y-3"> 437 + <input 438 + type="file" 439 + accept=".csv,text/csv" 440 + onChange={(event) => { 441 + const file = event.target.files?.[0]; 442 + if (file) { 443 + void handleCsvUpload(file); 444 + } 445 + }} 446 + disabled={isImportBusy} 447 + /> 448 + <p className="md-body-small text-(--md-sys-color-on-surface-variant)"> 449 + Upload a Trakt history CSV export using the standard Trakt columns. 450 + </p> 451 + </div> 452 + )} 453 + 454 + <div className="flex gap-3"> 455 + <M3Button variant="text" onClick={() => setStep(1)} disabled={isImportBusy}> 456 + Back 457 + </M3Button> 458 + <M3Button 459 + variant="text" 460 + onClick={handleSkip} 461 + disabled={isCompleting || isImportBusy} 462 + > 463 + {isCompleting ? "Finishing..." : "Skip for now"} 464 + </M3Button> 465 + </div> 466 + </div> 467 + )} 468 + 469 + {step === 3 && ( 470 + <div className="space-y-4"> 471 + <h2 className="md-title-large">You&apos;re all set</h2> 472 + <p className="md-body-medium text-(--md-sys-color-on-surface-variant)"> 473 + Imported: {importResult.imported} | Skipped: {importResult.skipped} | 474 + Failed: {importResult.failed} 475 + </p> 476 + {importResult.errors.length > 0 && ( 477 + <div className="rounded-(--md-sys-shape-corner-medium) border p-3 max-h-56 overflow-auto"> 478 + <p className="md-label-large mb-2">Errors</p> 479 + <ul className="space-y-1"> 480 + {importResult.errors.map((error) => ( 481 + <li key={error} className="md-body-small"> 482 + {error} 483 + </li> 484 + ))} 485 + </ul> 486 + </div> 487 + )} 488 + <M3Button 489 + variant="filled" 490 + onClick={() => { 491 + void completeOnboardingAndRedirect(); 492 + }} 493 + disabled={isCompleting} 494 + > 495 + {isCompleting ? "Finishing..." : "Finish"} 496 + </M3Button> 497 + </div> 498 + )} 499 + </div> 500 + </div> 501 + ); 502 + } 503 + 504 + async function runImportInChunks( 505 + items: NormalizedImportItemDto[], 506 + importMutate: (payload: { 507 + body: { items: NormalizedImportItemDto[] }; 508 + }) => Promise<{ 509 + imported: number; 510 + skipped: number; 511 + failed: number; 512 + errors: Array<{ message: string }>; 513 + }>, 514 + onProgress?: (update: ImportProgressUpdate) => void, 515 + ) { 516 + let imported = 0; 517 + let skipped = 0; 518 + let failed = 0; 519 + const errors: string[] = []; 520 + const totalItems = items.length; 521 + const totalBatches = Math.ceil(totalItems / MAX_BATCH_SIZE); 522 + 523 + onProgress?.({ 524 + totalItems, 525 + processedItems: 0, 526 + currentBatch: 0, 527 + totalBatches, 528 + imported, 529 + skipped, 530 + failed, 531 + }); 532 + 533 + for (let start = 0; start < totalItems; start += MAX_BATCH_SIZE) { 534 + const currentBatch = Math.floor(start / MAX_BATCH_SIZE) + 1; 535 + const chunk = items.slice(start, start + MAX_BATCH_SIZE); 536 + 537 + onProgress?.({ 538 + totalItems, 539 + processedItems: start, 540 + currentBatch, 541 + totalBatches, 542 + imported, 543 + skipped, 544 + failed, 545 + }); 546 + 547 + const result = await importMutate({ body: { items: chunk } }); 548 + imported += result.imported; 549 + skipped += result.skipped; 550 + failed += result.failed; 551 + errors.push(...result.errors.map((error) => error.message)); 552 + 553 + onProgress?.({ 554 + totalItems, 555 + processedItems: Math.min(start + chunk.length, totalItems), 556 + currentBatch, 557 + totalBatches, 558 + imported, 559 + skipped, 560 + failed, 561 + }); 562 + } 563 + 564 + return { 565 + imported, 566 + skipped, 567 + failed, 568 + errors, 569 + }; 570 + } 571 + 572 + async function parseCsvFile(file: File): Promise<{ 573 + items: NormalizedImportItemDto[]; 574 + errors: CsvParseError[]; 575 + }> { 576 + return new Promise((resolve, reject) => { 577 + Papa.parse<Record<string, string>>(file, { 578 + header: true, 579 + skipEmptyLines: true, 580 + complete: (results) => { 581 + const items: NormalizedImportItemDto[] = []; 582 + const errors: CsvParseError[] = []; 583 + const headers = (results.meta.fields ?? []).map((header) => 584 + header.trim(), 585 + ); 586 + 587 + for (const expectedHeader of CSV_HEADERS) { 588 + if (!headers.includes(expectedHeader)) { 589 + errors.push({ 590 + row: 1, 591 + message: `Missing required header: ${expectedHeader}`, 592 + }); 593 + } 594 + } 595 + 596 + if (errors.length > 0) { 597 + resolve({ items, errors }); 598 + return; 599 + } 600 + 601 + for (let rowIndex = 0; rowIndex < results.data.length; rowIndex++) { 602 + const row = results.data[rowIndex] ?? {}; 603 + const normalized = normalizeCsvRow(row, rowIndex + 2); 604 + if (normalized.item) { 605 + items.push(normalized.item); 606 + } else if (normalized.error) { 607 + errors.push(normalized.error); 608 + } 609 + } 610 + 611 + resolve({ items, errors }); 612 + }, 613 + error: (error) => { 614 + reject(error); 615 + }, 616 + }); 617 + }); 618 + } 619 + 620 + function normalizeCsvRow( 621 + row: Record<string, string>, 622 + rowNumber: number, 623 + ): { item?: NormalizedImportItemDto; error?: CsvParseError } { 624 + const type = getCsvValue(row, "type").toLowerCase(); 625 + const watchedAtRaw = getCsvValue(row, "watched_at"); 626 + const watchedAt = Number.isNaN(Date.parse(watchedAtRaw)) 627 + ? "" 628 + : new Date(watchedAtRaw).toISOString(); 629 + const actionRaw = getCsvValue(row, "action").toLowerCase(); 630 + const action = actionRaw || "watch"; 631 + 632 + if (!["watch", "scrobble", "checkin"].includes(action)) { 633 + return { 634 + error: { 635 + row: rowNumber, 636 + message: `Row ${rowNumber}: unsupported action \"${actionRaw || "unknown"}\"`, 637 + }, 638 + }; 639 + } 640 + 641 + if (!watchedAt) { 642 + return { error: { row: rowNumber, message: `Row ${rowNumber}: invalid watched_at` } }; 643 + } 644 + 645 + if (type === "movie") { 646 + const movieTmdbId = Number.parseInt(getCsvValue(row, "tmdb_id"), 10); 647 + if (!Number.isInteger(movieTmdbId) || movieTmdbId < 1) { 648 + return { 649 + error: { row: rowNumber, message: `Row ${rowNumber}: missing movie TMDB id` }, 650 + }; 651 + } 652 + 653 + return { 654 + item: { 655 + type: "movie", 656 + movieTmdbId, 657 + action: action as "watch" | "scrobble" | "checkin", 658 + watchedAt, 659 + }, 660 + }; 661 + } 662 + 663 + if (type === "episode") { 664 + const showTmdbId = Number.parseInt(getCsvValue(row, "tmdb_id"), 10); 665 + const seasonNumber = Number.parseInt(getCsvValue(row, "season_number"), 10); 666 + const episodeNumber = Number.parseInt( 667 + getCsvValue(row, "episode_number"), 668 + 10, 669 + ); 670 + 671 + if (!Number.isInteger(showTmdbId) || showTmdbId < 1) { 672 + return { 673 + error: { row: rowNumber, message: `Row ${rowNumber}: missing show TMDB id` }, 674 + }; 675 + } 676 + 677 + if ( 678 + !Number.isInteger(seasonNumber) || 679 + seasonNumber < 0 || 680 + !Number.isInteger(episodeNumber) || 681 + episodeNumber < 1 682 + ) { 683 + return { 684 + error: { 685 + row: rowNumber, 686 + message: `Row ${rowNumber}: invalid season/episode values`, 687 + }, 688 + }; 689 + } 690 + 691 + return { 692 + item: { 693 + type: "episode", 694 + showTmdbId, 695 + seasonNumber, 696 + episodeNumber, 697 + action: action as "watch" | "scrobble" | "checkin", 698 + watchedAt, 699 + }, 700 + }; 701 + } 702 + 703 + return { 704 + error: { 705 + row: rowNumber, 706 + message: `Row ${rowNumber}: unsupported type \"${type || "unknown"}\"`, 707 + }, 708 + }; 709 + } 710 + 711 + function getCsvValue(row: Record<string, string>, key: string): string { 712 + const value = row[key]; 713 + if (typeof value === "string" && value.trim()) { 714 + return value.trim(); 715 + } 716 + return ""; 717 + }
+8
backend/prisma/migrations/20260303120000_add_onboarding_completed_at/migration.sql
··· 1 + -- Add onboarding completion timestamp for web onboarding flow. 2 + ALTER TABLE "User" 3 + ADD COLUMN "onboardingCompletedAt" TIMESTAMP(3); 4 + 5 + -- Existing users should bypass onboarding. 6 + UPDATE "User" 7 + SET "onboardingCompletedAt" = NOW() 8 + WHERE "onboardingCompletedAt" IS NULL;
+1
backend/prisma/schema.prisma
··· 15 15 avatar String? 16 16 timezone String @default("UTC") 17 17 timeFormat String @default("24h") 18 + onboardingCompletedAt DateTime? 18 19 createdAt DateTime @default(now()) 19 20 updatedAt DateTime @updatedAt 20 21
+9 -1
backend/src/auth/auth.controller.spec.ts
··· 548 548 handle: "user.bsky.social", 549 549 displayName: "Test User", 550 550 avatar: "https://example.com/avatar.jpg", 551 + onboardingCompletedAt: new Date("2026-01-01T00:00:00.000Z"), 551 552 }; 552 553 mockAuthService.getUser.mockResolvedValue(mockUser); 553 554 ··· 559 560 req as unknown as import("../auth/types").AuthenticatedRequest, 560 561 ); 561 562 562 - expect(result).toEqual(mockUser); 563 + expect(result).toEqual({ 564 + did: "did:plc:abc123", 565 + handle: "user.bsky.social", 566 + displayName: "Test User", 567 + avatar: "https://example.com/avatar.jpg", 568 + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", 569 + needsOnboarding: false, 570 + }); 563 571 expect(mockAuthService.getUser).toHaveBeenCalledWith("did:plc:abc123"); 564 572 }); 565 573
+4
backend/src/auth/auth.controller.ts
··· 392 392 handle: user.handle, 393 393 displayName: user.displayName, 394 394 avatar: user.avatar, 395 + onboardingCompletedAt: user.onboardingCompletedAt 396 + ? user.onboardingCompletedAt.toISOString() 397 + : null, 398 + needsOnboarding: user.onboardingCompletedAt === null, 395 399 }; 396 400 } 397 401
+9
backend/src/auth/dto/user.dto.ts
··· 12 12 13 13 @ApiProperty({ description: "Avatar URL", nullable: true }) 14 14 avatar: string | null; 15 + 16 + @ApiProperty({ 17 + description: "When onboarding was completed", 18 + nullable: true, 19 + }) 20 + onboardingCompletedAt: string | null; 21 + 22 + @ApiProperty({ description: "Whether this user should complete onboarding" }) 23 + needsOnboarding: boolean; 15 24 }
+50 -50
backend/src/generated/commonInputTypes.ts
··· 44 44 not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null 45 45 } 46 46 47 + export type DateTimeNullableFilter<$PrismaModel = never> = { 48 + equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null 49 + in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 50 + notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 51 + lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 52 + lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 53 + gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 54 + gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 55 + not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null 56 + } 57 + 47 58 export type DateTimeFilter<$PrismaModel = never> = { 48 59 equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 49 60 in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> ··· 94 105 _count?: Prisma.NestedIntNullableFilter<$PrismaModel> 95 106 _min?: Prisma.NestedStringNullableFilter<$PrismaModel> 96 107 _max?: Prisma.NestedStringNullableFilter<$PrismaModel> 108 + } 109 + 110 + export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = { 111 + equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null 112 + in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 113 + notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 114 + lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 115 + lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 116 + gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 117 + gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 118 + not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null 119 + _count?: Prisma.NestedIntNullableFilter<$PrismaModel> 120 + _min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> 121 + _max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> 97 122 } 98 123 99 124 export type DateTimeWithAggregatesFilter<$PrismaModel = never> = { ··· 121 146 not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null 122 147 } 123 148 124 - export type DateTimeNullableFilter<$PrismaModel = never> = { 125 - equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null 126 - in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 127 - notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 128 - lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 129 - lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 130 - gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 131 - gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 132 - not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null 133 - } 134 - 135 149 export type JsonNullableFilter<$PrismaModel = never> = 136 150 | Prisma.PatchUndefined< 137 151 Prisma.Either<Required<JsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>, ··· 170 184 _sum?: Prisma.NestedIntNullableFilter<$PrismaModel> 171 185 _min?: Prisma.NestedIntNullableFilter<$PrismaModel> 172 186 _max?: Prisma.NestedIntNullableFilter<$PrismaModel> 173 - } 174 - 175 - export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = { 176 - equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null 177 - in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 178 - notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 179 - lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 180 - lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 181 - gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 182 - gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 183 - not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null 184 - _count?: Prisma.NestedIntNullableFilter<$PrismaModel> 185 - _min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> 186 - _max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> 187 187 } 188 188 189 189 export type JsonNullableWithAggregatesFilter<$PrismaModel = never> = ··· 298 298 not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null 299 299 } 300 300 301 + export type NestedDateTimeNullableFilter<$PrismaModel = never> = { 302 + equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null 303 + in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 304 + notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 305 + lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 306 + lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 307 + gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 308 + gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 309 + not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null 310 + } 311 + 301 312 export type NestedDateTimeFilter<$PrismaModel = never> = { 302 313 equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 303 314 in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> ··· 365 376 not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null 366 377 } 367 378 379 + export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = { 380 + equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null 381 + in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 382 + notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 383 + lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 384 + lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 385 + gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 386 + gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 387 + not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null 388 + _count?: Prisma.NestedIntNullableFilter<$PrismaModel> 389 + _min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> 390 + _max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> 391 + } 392 + 368 393 export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = { 369 394 equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 370 395 in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> ··· 379 404 _max?: Prisma.NestedDateTimeFilter<$PrismaModel> 380 405 } 381 406 382 - export type NestedDateTimeNullableFilter<$PrismaModel = never> = { 383 - equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null 384 - in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 385 - notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 386 - lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 387 - lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 388 - gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 389 - gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 390 - not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null 391 - } 392 - 393 407 export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = { 394 408 equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null 395 409 in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null ··· 415 429 gt?: number | Prisma.FloatFieldRefInput<$PrismaModel> 416 430 gte?: number | Prisma.FloatFieldRefInput<$PrismaModel> 417 431 not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null 418 - } 419 - 420 - export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = { 421 - equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null 422 - in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 423 - notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null 424 - lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 425 - lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 426 - gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 427 - gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> 428 - not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null 429 - _count?: Prisma.NestedIntNullableFilter<$PrismaModel> 430 - _min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> 431 - _max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> 432 432 } 433 433 434 434 export type NestedJsonNullableFilter<$PrismaModel = never> =
+2 -2
backend/src/generated/internal/class.ts
··· 20 20 "clientVersion": "7.3.0", 21 21 "engineVersion": "9d6ad21cbbceab97458517b147a6a09ff43aa735", 22 22 "activeProvider": "postgresql", 23 - "inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"../src/generated\"\n moduleFormat = \"cjs\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n did String @id\n handle String @unique\n displayName String?\n avatar String?\n timezone String @default(\"UTC\")\n timeFormat String @default(\"24h\")\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n trackedMovies TrackedMovie[]\n trackedEpisodes TrackedEpisode[]\n lists MovieList[]\n\n @@index([handle])\n}\n\nenum MediaType {\n movie\n show\n}\n\n// OAuth session storage for @atproto/oauth-client-node sessionStore\n// No FK to User because the OAuth library stores session before we create the User\n// Cookie stores opaque id (not DID) for session lookup\nmodel AuthSession {\n id String @id @default(cuid())\n userDid String @unique\n sessionData String // JSON-serialized session from the library\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([updatedAt])\n}\n\nmodel AuthState {\n key String @id\n stateData String\n expiresAt DateTime\n createdAt DateTime @default(now())\n\n @@index([expiresAt])\n}\n\nmodel Movie {\n movieId String @id\n title String\n posterPath String?\n backdropPath String?\n releaseYear Int?\n releaseDate DateTime?\n overview String?\n colors Json? // { primary, secondary, accent, muted }\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n trackedBy TrackedMovie[]\n listItems ListItem[]\n\n @@index([title])\n}\n\nmodel Show {\n showId String @id\n title String\n posterPath String?\n backdropPath String?\n firstAirYear Int?\n firstAirDate DateTime?\n overview String?\n colors Json?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n trackedBy TrackedEpisode[]\n listItems ListItem[]\n\n @@index([title])\n}\n\nmodel TrackedMovie {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String\n\n userDid String\n user User @relation(fields: [userDid], references: [did], onDelete: Cascade)\n\n movieId String\n movie Movie @relation(fields: [movieId], references: [movieId], onDelete: Cascade)\n\n status String @default(\"watched\")\n watchedDate DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userDid])\n @@index([movieId])\n @@index([status])\n @@index([createdAt])\n @@index([watchedDate])\n @@index([uri])\n @@index([cid])\n}\n\nmodel TrackedEpisode {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String\n\n userDid String\n user User @relation(fields: [userDid], references: [did], onDelete: Cascade)\n\n showId String\n show Show @relation(fields: [showId], references: [showId], onDelete: Cascade)\n\n seasonNumber Int\n episodeNumber Int\n status String @default(\"watched\")\n watchedDate DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userDid])\n @@index([showId])\n @@index([seasonNumber])\n @@index([episodeNumber])\n @@index([status])\n @@index([createdAt])\n @@index([watchedDate])\n @@index([uri])\n @@index([cid])\n}\n\nmodel MovieList {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String?\n\n userDid String\n user User @relation(fields: [userDid], references: [did], onDelete: Cascade)\n\n name String\n description String?\n slug String\n isDefault Boolean @default(false)\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n items ListItem[]\n\n @@unique([userDid, slug])\n @@index([userDid])\n @@index([isDefault])\n}\n\nmodel ListItem {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String?\n\n listId String\n list MovieList @relation(fields: [listId], references: [id], onDelete: Cascade)\n\n mediaType MediaType\n mediaId String\n movieId String?\n movie Movie? @relation(fields: [movieId], references: [movieId], onDelete: Cascade)\n showId String?\n show Show? @relation(fields: [showId], references: [showId], onDelete: Cascade)\n\n notes String?\n position Int @default(0)\n\n createdAt DateTime @default(now())\n\n @@unique([listId, mediaType, mediaId])\n @@index([listId])\n @@index([mediaType, mediaId])\n}\n", 23 + "inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"../src/generated\"\n moduleFormat = \"cjs\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel User {\n did String @id\n handle String @unique\n displayName String?\n avatar String?\n timezone String @default(\"UTC\")\n timeFormat String @default(\"24h\")\n onboardingCompletedAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n trackedMovies TrackedMovie[]\n trackedEpisodes TrackedEpisode[]\n lists MovieList[]\n\n @@index([handle])\n}\n\nenum MediaType {\n movie\n show\n}\n\n// OAuth session storage for @atproto/oauth-client-node sessionStore\n// No FK to User because the OAuth library stores session before we create the User\n// Cookie stores opaque id (not DID) for session lookup\nmodel AuthSession {\n id String @id @default(cuid())\n userDid String @unique\n sessionData String // JSON-serialized session from the library\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([updatedAt])\n}\n\nmodel AuthState {\n key String @id\n stateData String\n expiresAt DateTime\n createdAt DateTime @default(now())\n\n @@index([expiresAt])\n}\n\nmodel Movie {\n movieId String @id\n title String\n posterPath String?\n backdropPath String?\n releaseYear Int?\n releaseDate DateTime?\n overview String?\n colors Json? // { primary, secondary, accent, muted }\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n trackedBy TrackedMovie[]\n listItems ListItem[]\n\n @@index([title])\n}\n\nmodel Show {\n showId String @id\n title String\n posterPath String?\n backdropPath String?\n firstAirYear Int?\n firstAirDate DateTime?\n overview String?\n colors Json?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n trackedBy TrackedEpisode[]\n listItems ListItem[]\n\n @@index([title])\n}\n\nmodel TrackedMovie {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String\n\n userDid String\n user User @relation(fields: [userDid], references: [did], onDelete: Cascade)\n\n movieId String\n movie Movie @relation(fields: [movieId], references: [movieId], onDelete: Cascade)\n\n status String @default(\"watched\")\n watchedDate DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userDid])\n @@index([movieId])\n @@index([status])\n @@index([createdAt])\n @@index([watchedDate])\n @@index([uri])\n @@index([cid])\n}\n\nmodel TrackedEpisode {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String\n\n userDid String\n user User @relation(fields: [userDid], references: [did], onDelete: Cascade)\n\n showId String\n show Show @relation(fields: [showId], references: [showId], onDelete: Cascade)\n\n seasonNumber Int\n episodeNumber Int\n status String @default(\"watched\")\n watchedDate DateTime?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userDid])\n @@index([showId])\n @@index([seasonNumber])\n @@index([episodeNumber])\n @@index([status])\n @@index([createdAt])\n @@index([watchedDate])\n @@index([uri])\n @@index([cid])\n}\n\nmodel MovieList {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String?\n\n userDid String\n user User @relation(fields: [userDid], references: [did], onDelete: Cascade)\n\n name String\n description String?\n slug String\n isDefault Boolean @default(false)\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n items ListItem[]\n\n @@unique([userDid, slug])\n @@index([userDid])\n @@index([isDefault])\n}\n\nmodel ListItem {\n id String @id @default(cuid())\n rkey String @unique\n uri String\n cid String?\n\n listId String\n list MovieList @relation(fields: [listId], references: [id], onDelete: Cascade)\n\n mediaType MediaType\n mediaId String\n movieId String?\n movie Movie? @relation(fields: [movieId], references: [movieId], onDelete: Cascade)\n showId String?\n show Show? @relation(fields: [showId], references: [showId], onDelete: Cascade)\n\n notes String?\n position Int @default(0)\n\n createdAt DateTime @default(now())\n\n @@unique([listId, mediaType, mediaId])\n @@index([listId])\n @@index([mediaType, mediaId])\n}\n", 24 24 "runtimeDataModel": { 25 25 "models": {}, 26 26 "enums": {}, ··· 28 28 } 29 29 } 30 30 31 - config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"did\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"handle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"displayName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"avatar\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"timezone\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"timeFormat\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"trackedMovies\",\"kind\":\"object\",\"type\":\"TrackedMovie\",\"relationName\":\"TrackedMovieToUser\"},{\"name\":\"trackedEpisodes\",\"kind\":\"object\",\"type\":\"TrackedEpisode\",\"relationName\":\"TrackedEpisodeToUser\"},{\"name\":\"lists\",\"kind\":\"object\",\"type\":\"MovieList\",\"relationName\":\"MovieListToUser\"}],\"dbName\":null},\"AuthSession\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessionData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"AuthState\":{\"fields\":[{\"name\":\"key\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"stateData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Movie\":{\"fields\":[{\"name\":\"movieId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"posterPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backdropPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"releaseYear\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"releaseDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"overview\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"colors\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"trackedBy\",\"kind\":\"object\",\"type\":\"TrackedMovie\",\"relationName\":\"MovieToTrackedMovie\"},{\"name\":\"listItems\",\"kind\":\"object\",\"type\":\"ListItem\",\"relationName\":\"ListItemToMovie\"}],\"dbName\":null},\"Show\":{\"fields\":[{\"name\":\"showId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"posterPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backdropPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"firstAirYear\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"firstAirDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"overview\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"colors\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"trackedBy\",\"kind\":\"object\",\"type\":\"TrackedEpisode\",\"relationName\":\"ShowToTrackedEpisode\"},{\"name\":\"listItems\",\"kind\":\"object\",\"type\":\"ListItem\",\"relationName\":\"ListItemToShow\"}],\"dbName\":null},\"TrackedMovie\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"TrackedMovieToUser\"},{\"name\":\"movieId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"movie\",\"kind\":\"object\",\"type\":\"Movie\",\"relationName\":\"MovieToTrackedMovie\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"watchedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"TrackedEpisode\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"TrackedEpisodeToUser\"},{\"name\":\"showId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"show\",\"kind\":\"object\",\"type\":\"Show\",\"relationName\":\"ShowToTrackedEpisode\"},{\"name\":\"seasonNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"episodeNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"watchedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"MovieList\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"MovieListToUser\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"slug\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isDefault\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"items\",\"kind\":\"object\",\"type\":\"ListItem\",\"relationName\":\"ListItemToMovieList\"}],\"dbName\":null},\"ListItem\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"listId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"list\",\"kind\":\"object\",\"type\":\"MovieList\",\"relationName\":\"ListItemToMovieList\"},{\"name\":\"mediaType\",\"kind\":\"enum\",\"type\":\"MediaType\"},{\"name\":\"mediaId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"movieId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"movie\",\"kind\":\"object\",\"type\":\"Movie\",\"relationName\":\"ListItemToMovie\"},{\"name\":\"showId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"show\",\"kind\":\"object\",\"type\":\"Show\",\"relationName\":\"ListItemToShow\"},{\"name\":\"notes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"position\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") 31 + config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"did\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"handle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"displayName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"avatar\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"timezone\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"timeFormat\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"onboardingCompletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"trackedMovies\",\"kind\":\"object\",\"type\":\"TrackedMovie\",\"relationName\":\"TrackedMovieToUser\"},{\"name\":\"trackedEpisodes\",\"kind\":\"object\",\"type\":\"TrackedEpisode\",\"relationName\":\"TrackedEpisodeToUser\"},{\"name\":\"lists\",\"kind\":\"object\",\"type\":\"MovieList\",\"relationName\":\"MovieListToUser\"}],\"dbName\":null},\"AuthSession\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessionData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"AuthState\":{\"fields\":[{\"name\":\"key\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"stateData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Movie\":{\"fields\":[{\"name\":\"movieId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"posterPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backdropPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"releaseYear\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"releaseDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"overview\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"colors\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"trackedBy\",\"kind\":\"object\",\"type\":\"TrackedMovie\",\"relationName\":\"MovieToTrackedMovie\"},{\"name\":\"listItems\",\"kind\":\"object\",\"type\":\"ListItem\",\"relationName\":\"ListItemToMovie\"}],\"dbName\":null},\"Show\":{\"fields\":[{\"name\":\"showId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"posterPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backdropPath\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"firstAirYear\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"firstAirDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"overview\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"colors\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"trackedBy\",\"kind\":\"object\",\"type\":\"TrackedEpisode\",\"relationName\":\"ShowToTrackedEpisode\"},{\"name\":\"listItems\",\"kind\":\"object\",\"type\":\"ListItem\",\"relationName\":\"ListItemToShow\"}],\"dbName\":null},\"TrackedMovie\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"TrackedMovieToUser\"},{\"name\":\"movieId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"movie\",\"kind\":\"object\",\"type\":\"Movie\",\"relationName\":\"MovieToTrackedMovie\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"watchedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"TrackedEpisode\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"TrackedEpisodeToUser\"},{\"name\":\"showId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"show\",\"kind\":\"object\",\"type\":\"Show\",\"relationName\":\"ShowToTrackedEpisode\"},{\"name\":\"seasonNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"episodeNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"watchedDate\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"MovieList\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userDid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"MovieListToUser\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"slug\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isDefault\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"items\",\"kind\":\"object\",\"type\":\"ListItem\",\"relationName\":\"ListItemToMovieList\"}],\"dbName\":null},\"ListItem\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rkey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"uri\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"cid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"listId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"list\",\"kind\":\"object\",\"type\":\"MovieList\",\"relationName\":\"ListItemToMovieList\"},{\"name\":\"mediaType\",\"kind\":\"enum\",\"type\":\"MediaType\"},{\"name\":\"mediaId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"movieId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"movie\",\"kind\":\"object\",\"type\":\"Movie\",\"relationName\":\"ListItemToMovie\"},{\"name\":\"showId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"show\",\"kind\":\"object\",\"type\":\"Show\",\"relationName\":\"ListItemToShow\"},{\"name\":\"notes\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"position\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") 32 32 33 33 async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Module> { 34 34 const { Buffer } = await import('node:buffer')
+1
backend/src/generated/internal/prismaNamespace.ts
··· 1124 1124 avatar: 'avatar', 1125 1125 timezone: 'timezone', 1126 1126 timeFormat: 'timeFormat', 1127 + onboardingCompletedAt: 'onboardingCompletedAt', 1127 1128 createdAt: 'createdAt', 1128 1129 updatedAt: 'updatedAt' 1129 1130 } as const
+1
backend/src/generated/internal/prismaNamespaceBrowser.ts
··· 85 85 avatar: 'avatar', 86 86 timezone: 'timezone', 87 87 timeFormat: 'timeFormat', 88 + onboardingCompletedAt: 'onboardingCompletedAt', 88 89 createdAt: 'createdAt', 89 90 updatedAt: 'updatedAt' 90 91 } as const
-4
backend/src/generated/models/Movie.ts
··· 485 485 divide?: number 486 486 } 487 487 488 - export type NullableDateTimeFieldUpdateOperationsInput = { 489 - set?: Date | string | null 490 - } 491 - 492 488 export type MovieCreateNestedOneWithoutTrackedByInput = { 493 489 create?: Prisma.XOR<Prisma.MovieCreateWithoutTrackedByInput, Prisma.MovieUncheckedCreateWithoutTrackedByInput> 494 490 connectOrCreate?: Prisma.MovieCreateOrConnectWithoutTrackedByInput
+45 -1
backend/src/generated/models/User.ts
··· 31 31 avatar: string | null 32 32 timezone: string | null 33 33 timeFormat: string | null 34 + onboardingCompletedAt: Date | null 34 35 createdAt: Date | null 35 36 updatedAt: Date | null 36 37 } ··· 42 43 avatar: string | null 43 44 timezone: string | null 44 45 timeFormat: string | null 46 + onboardingCompletedAt: Date | null 45 47 createdAt: Date | null 46 48 updatedAt: Date | null 47 49 } ··· 53 55 avatar: number 54 56 timezone: number 55 57 timeFormat: number 58 + onboardingCompletedAt: number 56 59 createdAt: number 57 60 updatedAt: number 58 61 _all: number ··· 66 69 avatar?: true 67 70 timezone?: true 68 71 timeFormat?: true 72 + onboardingCompletedAt?: true 69 73 createdAt?: true 70 74 updatedAt?: true 71 75 } ··· 77 81 avatar?: true 78 82 timezone?: true 79 83 timeFormat?: true 84 + onboardingCompletedAt?: true 80 85 createdAt?: true 81 86 updatedAt?: true 82 87 } ··· 88 93 avatar?: true 89 94 timezone?: true 90 95 timeFormat?: true 96 + onboardingCompletedAt?: true 91 97 createdAt?: true 92 98 updatedAt?: true 93 99 _all?: true ··· 172 178 avatar: string | null 173 179 timezone: string 174 180 timeFormat: string 181 + onboardingCompletedAt: Date | null 175 182 createdAt: Date 176 183 updatedAt: Date 177 184 _count: UserCountAggregateOutputType | null ··· 204 211 avatar?: Prisma.StringNullableFilter<"User"> | string | null 205 212 timezone?: Prisma.StringFilter<"User"> | string 206 213 timeFormat?: Prisma.StringFilter<"User"> | string 214 + onboardingCompletedAt?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null 207 215 createdAt?: Prisma.DateTimeFilter<"User"> | Date | string 208 216 updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string 209 217 trackedMovies?: Prisma.TrackedMovieListRelationFilter ··· 218 226 avatar?: Prisma.SortOrderInput | Prisma.SortOrder 219 227 timezone?: Prisma.SortOrder 220 228 timeFormat?: Prisma.SortOrder 229 + onboardingCompletedAt?: Prisma.SortOrderInput | Prisma.SortOrder 221 230 createdAt?: Prisma.SortOrder 222 231 updatedAt?: Prisma.SortOrder 223 232 trackedMovies?: Prisma.TrackedMovieOrderByRelationAggregateInput ··· 235 244 avatar?: Prisma.StringNullableFilter<"User"> | string | null 236 245 timezone?: Prisma.StringFilter<"User"> | string 237 246 timeFormat?: Prisma.StringFilter<"User"> | string 247 + onboardingCompletedAt?: Prisma.DateTimeNullableFilter<"User"> | Date | string | null 238 248 createdAt?: Prisma.DateTimeFilter<"User"> | Date | string 239 249 updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string 240 250 trackedMovies?: Prisma.TrackedMovieListRelationFilter ··· 249 259 avatar?: Prisma.SortOrderInput | Prisma.SortOrder 250 260 timezone?: Prisma.SortOrder 251 261 timeFormat?: Prisma.SortOrder 262 + onboardingCompletedAt?: Prisma.SortOrderInput | Prisma.SortOrder 252 263 createdAt?: Prisma.SortOrder 253 264 updatedAt?: Prisma.SortOrder 254 265 _count?: Prisma.UserCountOrderByAggregateInput ··· 266 277 avatar?: Prisma.StringNullableWithAggregatesFilter<"User"> | string | null 267 278 timezone?: Prisma.StringWithAggregatesFilter<"User"> | string 268 279 timeFormat?: Prisma.StringWithAggregatesFilter<"User"> | string 280 + onboardingCompletedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"User"> | Date | string | null 269 281 createdAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string 270 282 updatedAt?: Prisma.DateTimeWithAggregatesFilter<"User"> | Date | string 271 283 } ··· 277 289 avatar?: string | null 278 290 timezone?: string 279 291 timeFormat?: string 292 + onboardingCompletedAt?: Date | string | null 280 293 createdAt?: Date | string 281 294 updatedAt?: Date | string 282 295 trackedMovies?: Prisma.TrackedMovieCreateNestedManyWithoutUserInput ··· 291 304 avatar?: string | null 292 305 timezone?: string 293 306 timeFormat?: string 307 + onboardingCompletedAt?: Date | string | null 294 308 createdAt?: Date | string 295 309 updatedAt?: Date | string 296 310 trackedMovies?: Prisma.TrackedMovieUncheckedCreateNestedManyWithoutUserInput ··· 305 319 avatar?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 306 320 timezone?: Prisma.StringFieldUpdateOperationsInput | string 307 321 timeFormat?: Prisma.StringFieldUpdateOperationsInput | string 322 + onboardingCompletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null 308 323 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 309 324 updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 310 325 trackedMovies?: Prisma.TrackedMovieUpdateManyWithoutUserNestedInput ··· 319 334 avatar?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 320 335 timezone?: Prisma.StringFieldUpdateOperationsInput | string 321 336 timeFormat?: Prisma.StringFieldUpdateOperationsInput | string 337 + onboardingCompletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null 322 338 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 323 339 updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 324 340 trackedMovies?: Prisma.TrackedMovieUncheckedUpdateManyWithoutUserNestedInput ··· 333 349 avatar?: string | null 334 350 timezone?: string 335 351 timeFormat?: string 352 + onboardingCompletedAt?: Date | string | null 336 353 createdAt?: Date | string 337 354 updatedAt?: Date | string 338 355 } ··· 344 361 avatar?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 345 362 timezone?: Prisma.StringFieldUpdateOperationsInput | string 346 363 timeFormat?: Prisma.StringFieldUpdateOperationsInput | string 364 + onboardingCompletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null 347 365 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 348 366 updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 349 367 } ··· 355 373 avatar?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 356 374 timezone?: Prisma.StringFieldUpdateOperationsInput | string 357 375 timeFormat?: Prisma.StringFieldUpdateOperationsInput | string 376 + onboardingCompletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null 358 377 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 359 378 updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 360 379 } ··· 366 385 avatar?: Prisma.SortOrder 367 386 timezone?: Prisma.SortOrder 368 387 timeFormat?: Prisma.SortOrder 388 + onboardingCompletedAt?: Prisma.SortOrder 369 389 createdAt?: Prisma.SortOrder 370 390 updatedAt?: Prisma.SortOrder 371 391 } ··· 377 397 avatar?: Prisma.SortOrder 378 398 timezone?: Prisma.SortOrder 379 399 timeFormat?: Prisma.SortOrder 400 + onboardingCompletedAt?: Prisma.SortOrder 380 401 createdAt?: Prisma.SortOrder 381 402 updatedAt?: Prisma.SortOrder 382 403 } ··· 388 409 avatar?: Prisma.SortOrder 389 410 timezone?: Prisma.SortOrder 390 411 timeFormat?: Prisma.SortOrder 412 + onboardingCompletedAt?: Prisma.SortOrder 391 413 createdAt?: Prisma.SortOrder 392 414 updatedAt?: Prisma.SortOrder 393 415 } ··· 405 427 set?: string | null 406 428 } 407 429 430 + export type NullableDateTimeFieldUpdateOperationsInput = { 431 + set?: Date | string | null 432 + } 433 + 408 434 export type DateTimeFieldUpdateOperationsInput = { 409 435 set?: Date | string 410 436 } ··· 458 484 avatar?: string | null 459 485 timezone?: string 460 486 timeFormat?: string 487 + onboardingCompletedAt?: Date | string | null 461 488 createdAt?: Date | string 462 489 updatedAt?: Date | string 463 490 trackedEpisodes?: Prisma.TrackedEpisodeCreateNestedManyWithoutUserInput ··· 471 498 avatar?: string | null 472 499 timezone?: string 473 500 timeFormat?: string 501 + onboardingCompletedAt?: Date | string | null 474 502 createdAt?: Date | string 475 503 updatedAt?: Date | string 476 504 trackedEpisodes?: Prisma.TrackedEpisodeUncheckedCreateNestedManyWithoutUserInput ··· 500 528 avatar?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 501 529 timezone?: Prisma.StringFieldUpdateOperationsInput | string 502 530 timeFormat?: Prisma.StringFieldUpdateOperationsInput | string 531 + onboardingCompletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null 503 532 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 504 533 updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 505 534 trackedEpisodes?: Prisma.TrackedEpisodeUpdateManyWithoutUserNestedInput ··· 513 542 avatar?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 514 543 timezone?: Prisma.StringFieldUpdateOperationsInput | string 515 544 timeFormat?: Prisma.StringFieldUpdateOperationsInput | string 545 + onboardingCompletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null 516 546 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 517 547 updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 518 548 trackedEpisodes?: Prisma.TrackedEpisodeUncheckedUpdateManyWithoutUserNestedInput ··· 526 556 avatar?: string | null 527 557 timezone?: string 528 558 timeFormat?: string 559 + onboardingCompletedAt?: Date | string | null 529 560 createdAt?: Date | string 530 561 updatedAt?: Date | string 531 562 trackedMovies?: Prisma.TrackedMovieCreateNestedManyWithoutUserInput ··· 539 570 avatar?: string | null 540 571 timezone?: string 541 572 timeFormat?: string 573 + onboardingCompletedAt?: Date | string | null 542 574 createdAt?: Date | string 543 575 updatedAt?: Date | string 544 576 trackedMovies?: Prisma.TrackedMovieUncheckedCreateNestedManyWithoutUserInput ··· 568 600 avatar?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 569 601 timezone?: Prisma.StringFieldUpdateOperationsInput | string 570 602 timeFormat?: Prisma.StringFieldUpdateOperationsInput | string 603 + onboardingCompletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null 571 604 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 572 605 updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 573 606 trackedMovies?: Prisma.TrackedMovieUpdateManyWithoutUserNestedInput ··· 581 614 avatar?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 582 615 timezone?: Prisma.StringFieldUpdateOperationsInput | string 583 616 timeFormat?: Prisma.StringFieldUpdateOperationsInput | string 617 + onboardingCompletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null 584 618 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 585 619 updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 586 620 trackedMovies?: Prisma.TrackedMovieUncheckedUpdateManyWithoutUserNestedInput ··· 594 628 avatar?: string | null 595 629 timezone?: string 596 630 timeFormat?: string 631 + onboardingCompletedAt?: Date | string | null 597 632 createdAt?: Date | string 598 633 updatedAt?: Date | string 599 634 trackedMovies?: Prisma.TrackedMovieCreateNestedManyWithoutUserInput ··· 607 642 avatar?: string | null 608 643 timezone?: string 609 644 timeFormat?: string 645 + onboardingCompletedAt?: Date | string | null 610 646 createdAt?: Date | string 611 647 updatedAt?: Date | string 612 648 trackedMovies?: Prisma.TrackedMovieUncheckedCreateNestedManyWithoutUserInput ··· 636 672 avatar?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 637 673 timezone?: Prisma.StringFieldUpdateOperationsInput | string 638 674 timeFormat?: Prisma.StringFieldUpdateOperationsInput | string 675 + onboardingCompletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null 639 676 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 640 677 updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 641 678 trackedMovies?: Prisma.TrackedMovieUpdateManyWithoutUserNestedInput ··· 649 686 avatar?: Prisma.NullableStringFieldUpdateOperationsInput | string | null 650 687 timezone?: Prisma.StringFieldUpdateOperationsInput | string 651 688 timeFormat?: Prisma.StringFieldUpdateOperationsInput | string 689 + onboardingCompletedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null 652 690 createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 653 691 updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string 654 692 trackedMovies?: Prisma.TrackedMovieUncheckedUpdateManyWithoutUserNestedInput ··· 711 749 avatar?: boolean 712 750 timezone?: boolean 713 751 timeFormat?: boolean 752 + onboardingCompletedAt?: boolean 714 753 createdAt?: boolean 715 754 updatedAt?: boolean 716 755 trackedMovies?: boolean | Prisma.User$trackedMoviesArgs<ExtArgs> ··· 726 765 avatar?: boolean 727 766 timezone?: boolean 728 767 timeFormat?: boolean 768 + onboardingCompletedAt?: boolean 729 769 createdAt?: boolean 730 770 updatedAt?: boolean 731 771 }, ExtArgs["result"]["user"]> ··· 737 777 avatar?: boolean 738 778 timezone?: boolean 739 779 timeFormat?: boolean 780 + onboardingCompletedAt?: boolean 740 781 createdAt?: boolean 741 782 updatedAt?: boolean 742 783 }, ExtArgs["result"]["user"]> ··· 748 789 avatar?: boolean 749 790 timezone?: boolean 750 791 timeFormat?: boolean 792 + onboardingCompletedAt?: boolean 751 793 createdAt?: boolean 752 794 updatedAt?: boolean 753 795 } 754 796 755 - export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"did" | "handle" | "displayName" | "avatar" | "timezone" | "timeFormat" | "createdAt" | "updatedAt", ExtArgs["result"]["user"]> 797 + export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"did" | "handle" | "displayName" | "avatar" | "timezone" | "timeFormat" | "onboardingCompletedAt" | "createdAt" | "updatedAt", ExtArgs["result"]["user"]> 756 798 export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { 757 799 trackedMovies?: boolean | Prisma.User$trackedMoviesArgs<ExtArgs> 758 800 trackedEpisodes?: boolean | Prisma.User$trackedEpisodesArgs<ExtArgs> ··· 776 818 avatar: string | null 777 819 timezone: string 778 820 timeFormat: string 821 + onboardingCompletedAt: Date | null 779 822 createdAt: Date 780 823 updatedAt: Date 781 824 }, ExtArgs["result"]["user"]> ··· 1210 1253 readonly avatar: Prisma.FieldRef<"User", 'String'> 1211 1254 readonly timezone: Prisma.FieldRef<"User", 'String'> 1212 1255 readonly timeFormat: Prisma.FieldRef<"User", 'String'> 1256 + readonly onboardingCompletedAt: Prisma.FieldRef<"User", 'DateTime'> 1213 1257 readonly createdAt: Prisma.FieldRef<"User", 'DateTime'> 1214 1258 readonly updatedAt: Prisma.FieldRef<"User", 'DateTime'> 1215 1259 }
-42
backend/src/ingester/ingester.service.ts
··· 137 137 throw new Error("TAP client not initialized"); 138 138 } 139 139 140 - this.logger.log(`Registering repo with TAP: ${did}`); 141 140 try { 142 141 await this.tap.addRepos([did]); 143 - this.logger.log(`Successfully registered repo: ${did}`); 144 142 145 143 // Check repo info to verify it's being tracked 146 144 try { ··· 165 163 throw new Error("TAP client not initialized"); 166 164 } 167 165 168 - this.logger.log(`Unregistering repo from TAP: ${did}`); 169 166 await this.tap.removeRepos([did]); 170 - this.logger.debug(`Successfully unregistered repo: ${did}`); 171 167 } 172 168 173 169 /** ··· 181 177 }); 182 178 183 179 if (users.length === 0) { 184 - this.logger.log("No existing users to register with TAP"); 185 180 return; 186 181 } 187 182 188 183 const dids = users.map((u) => u.did); 189 - this.logger.log(`Registering ${dids.length} existing users with TAP`); 190 184 191 185 // Register each user individually to handle partial failures 192 186 let successCount = 0; ··· 199 193 // Continue with next user even if one fails 200 194 } 201 195 } 202 - 203 - this.logger.log( 204 - `Successfully registered ${successCount}/${dids.length} repos with TAP`, 205 - ); 206 196 } catch (err) { 207 197 this.logger.error("Failed to register existing users with TAP", err); 208 198 } 209 199 } 210 200 211 201 private async handleRecordEvent(evt: RecordEvent) { 212 - this.logger.debug( 213 - `Received TAP event: ${evt.action} ${evt.collection} for ${evt.did} (live: ${evt.live})`, 214 - ); 215 - 216 202 const uri = `at://${evt.did}/${evt.collection}/${evt.rkey}`; 217 203 218 204 if (evt.collection === MOVIE_COLLECTION) { ··· 252 238 return; 253 239 } 254 240 255 - this.logger.log( 256 - `Indexing movie record (${evt.live ? "live" : "backfill"}): ${uri}`, 257 - ); 258 - 259 241 const existingMovie = await this.moviesService.getMovieByTMDBId( 260 242 movieRecord.movieId, 261 243 ); ··· 265 247 movieRecord.movieId, 266 248 ); 267 249 await this.moviesService.upsertMovie(movieData); 268 - this.logger.debug(`Created movie ${movieRecord.movieId} from TMDB`); 269 250 } catch (err) { 270 251 this.logger.error( 271 252 `Failed to fetch movie ${movieRecord.movieId} from TMDB, skipping record`, ··· 292 273 status: "watched", 293 274 }, 294 275 }); 295 - 296 - this.logger.debug( 297 - `Indexed movie ${movieRecord.movieId} for user ${evt.did}`, 298 - ); 299 276 } 300 277 301 278 if (evt.action === "delete") { 302 - this.logger.log(`Removing movie record: ${uri} (rkey: ${evt.rkey})`); 303 - 304 279 await this.prisma.trackedMovie.deleteMany({ 305 280 where: { rkey: evt.rkey }, 306 281 }); 307 - 308 - this.logger.debug(`Removed record with rkey ${evt.rkey}`); 309 282 } 310 283 } 311 284 ··· 333 306 return; 334 307 } 335 308 336 - this.logger.log( 337 - `Indexing list record (${evt.live ? "live" : "backfill"}): ${uri}`, 338 - ); 339 - 340 309 await this.listsService.indexListRecord( 341 310 uri, 342 311 evt.cid ?? "", ··· 347 316 } 348 317 349 318 if (evt.action === "delete") { 350 - this.logger.log(`Removing list record: ${uri} (rkey: ${evt.rkey})`); 351 319 await this.listsService.deleteListRecord(evt.rkey); 352 320 } 353 321 } ··· 376 344 return; 377 345 } 378 346 379 - this.logger.log( 380 - `Indexing episode record (${evt.live ? "live" : "backfill"}): ${uri}`, 381 - ); 382 - 383 347 const existingShow = await this.showsService.getShowByTMDBId( 384 348 episodeRecord.showId, 385 349 ); ··· 422 386 } 423 387 424 388 if (evt.action === "delete") { 425 - this.logger.log(`Removing episode record: ${uri} (rkey: ${evt.rkey})`); 426 389 await this.prisma.trackedEpisode.deleteMany({ 427 390 where: { rkey: evt.rkey }, 428 391 }); ··· 453 416 return; 454 417 } 455 418 456 - this.logger.log( 457 - `Indexing list item record (${evt.live ? "live" : "backfill"}): ${uri}`, 458 - ); 459 - 460 419 await this.listsService.indexListItemRecord( 461 420 uri, 462 421 evt.cid ?? "", ··· 467 426 } 468 427 469 428 if (evt.action === "delete") { 470 - this.logger.log(`Removing list item record: ${uri} (rkey: ${evt.rkey})`); 471 429 await this.listsService.deleteListItemRecord(evt.rkey); 472 430 } 473 431 }
-4
backend/src/movies/movies.service.ts
··· 349 349 validate: false, // PDS may not have xyz.opnshelf.movie lexicon 350 350 }); 351 351 352 - this.logger.log( 353 - `Created AT record for movie ${movieId}: ${response.data.uri}`, 354 - ); 355 - 356 352 // Return the record info for optimistic updates 357 353 return { 358 354 uri: response.data.uri,
+160
backend/src/users/dto/import-history.dto.ts
··· 1 + import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; 2 + import { Type } from "class-transformer"; 3 + import { 4 + ArrayMaxSize, 5 + IsArray, 6 + IsDateString, 7 + IsIn, 8 + IsInt, 9 + IsOptional, 10 + IsString, 11 + Min, 12 + ValidateIf, 13 + ValidateNested, 14 + } from "class-validator"; 15 + 16 + export class NormalizedImportItemDto { 17 + @ApiProperty({ enum: ["movie", "episode"] }) 18 + @IsString() 19 + @IsIn(["movie", "episode"]) 20 + type: "movie" | "episode"; 21 + 22 + @ApiProperty({ description: "UTC datetime in ISO-8601 format" }) 23 + @IsDateString() 24 + watchedAt: string; 25 + 26 + @ApiPropertyOptional({ description: "TMDB movie id", type: Number }) 27 + @ValidateIf((item: NormalizedImportItemDto) => item.type === "movie") 28 + @Type(() => Number) 29 + @IsInt() 30 + @Min(1) 31 + movieTmdbId?: number; 32 + 33 + @ApiPropertyOptional({ description: "TMDB show id", type: Number }) 34 + @ValidateIf((item: NormalizedImportItemDto) => item.type === "episode") 35 + @Type(() => Number) 36 + @IsInt() 37 + @Min(1) 38 + showTmdbId?: number; 39 + 40 + @ApiPropertyOptional({ type: Number }) 41 + @ValidateIf((item: NormalizedImportItemDto) => item.type === "episode") 42 + @Type(() => Number) 43 + @IsInt() 44 + @Min(0) 45 + seasonNumber?: number; 46 + 47 + @ApiPropertyOptional({ type: Number }) 48 + @ValidateIf((item: NormalizedImportItemDto) => item.type === "episode") 49 + @Type(() => Number) 50 + @IsInt() 51 + @Min(1) 52 + episodeNumber?: number; 53 + 54 + @ApiPropertyOptional({ enum: ["watch", "scrobble", "checkin"] }) 55 + @IsOptional() 56 + @IsString() 57 + @IsIn(["watch", "scrobble", "checkin"]) 58 + action?: "watch" | "scrobble" | "checkin"; 59 + } 60 + 61 + export class ImportSkipDto { 62 + @ApiProperty({ description: "1-based item index from source payload" }) 63 + index: number; 64 + 65 + @ApiProperty({ 66 + enum: [ 67 + "unsupported_type", 68 + "unsupported_action", 69 + "missing_tmdb_id", 70 + "missing_episode_ref", 71 + "invalid_watched_at", 72 + ], 73 + }) 74 + reason: 75 + | "unsupported_type" 76 + | "unsupported_action" 77 + | "missing_tmdb_id" 78 + | "missing_episode_ref" 79 + | "invalid_watched_at"; 80 + 81 + @ApiPropertyOptional() 82 + message?: string; 83 + } 84 + 85 + export class ImportErrorDto { 86 + @ApiProperty({ description: "1-based item index from request payload" }) 87 + index: number; 88 + 89 + @ApiProperty({ 90 + enum: [ 91 + "invalid_item", 92 + "already_exists", 93 + "write_failed", 94 + "duplicate_in_request", 95 + ], 96 + }) 97 + code: "invalid_item" | "already_exists" | "write_failed" | "duplicate_in_request"; 98 + 99 + @ApiProperty() 100 + message: string; 101 + } 102 + 103 + export class FetchTraktPublicHistoryDto { 104 + @ApiProperty({ description: "Trakt username or slug" }) 105 + @IsString() 106 + username: string; 107 + 108 + @ApiPropertyOptional({ 109 + description: 110 + "Maximum items to fetch. If omitted, fetches full available history via pagination.", 111 + minimum: 1, 112 + }) 113 + @IsOptional() 114 + @Type(() => Number) 115 + @IsInt() 116 + @Min(1) 117 + maxItems?: number; 118 + } 119 + 120 + export class FetchTraktPublicHistoryResponseDto { 121 + @ApiProperty({ type: [NormalizedImportItemDto] }) 122 + items: NormalizedImportItemDto[]; 123 + 124 + @ApiProperty({ type: [ImportSkipDto] }) 125 + skipped: ImportSkipDto[]; 126 + 127 + @ApiProperty({ description: "Count of rows returned by Trakt before filtering" }) 128 + sourceCount: number; 129 + } 130 + 131 + export class ImportHistoryDto { 132 + @ApiProperty({ type: [NormalizedImportItemDto], maxItems: 100 }) 133 + @IsArray() 134 + @ArrayMaxSize(100) 135 + @ValidateNested({ each: true }) 136 + @Type(() => NormalizedImportItemDto) 137 + items: NormalizedImportItemDto[]; 138 + } 139 + 140 + export class ImportHistoryResponseDto { 141 + @ApiProperty() 142 + imported: number; 143 + 144 + @ApiProperty() 145 + skipped: number; 146 + 147 + @ApiProperty() 148 + failed: number; 149 + 150 + @ApiProperty({ type: [ImportErrorDto] }) 151 + errors: ImportErrorDto[]; 152 + } 153 + 154 + export class CompleteOnboardingResponseDto { 155 + @ApiProperty({ description: "Timestamp when onboarding was completed" }) 156 + onboardingCompletedAt: string; 157 + 158 + @ApiProperty({ default: false }) 159 + needsOnboarding: boolean; 160 + }
+115
backend/src/users/users.controller.spec.ts
··· 1 + import { BadRequestException } from "@nestjs/common"; 2 + import { Test, type TestingModule } from "@nestjs/testing"; 3 + import type { AuthenticatedRequest } from "../auth/types"; 4 + import { UsersController } from "./users.controller"; 5 + import { UsersService } from "./users.service"; 6 + 7 + jest.mock("../auth/auth.guard", () => ({ 8 + AuthGuard: class MockAuthGuard { 9 + canActivate() { 10 + return true; 11 + } 12 + }, 13 + })); 14 + 15 + describe("UsersController", () => { 16 + let controller: UsersController; 17 + 18 + const usersService = { 19 + completeOnboarding: jest.fn(), 20 + fetchTraktPublicHistory: jest.fn(), 21 + importNormalizedItems: jest.fn(), 22 + getUserSettings: jest.fn(), 23 + updateUserSettings: jest.fn(), 24 + deleteUser: jest.fn(), 25 + }; 26 + 27 + beforeEach(async () => { 28 + jest.clearAllMocks(); 29 + const module: TestingModule = await Test.createTestingModule({ 30 + controllers: [UsersController], 31 + providers: [{ provide: UsersService, useValue: usersService }], 32 + }).compile(); 33 + 34 + controller = module.get<UsersController>(UsersController); 35 + }); 36 + 37 + it("completes onboarding for current user", async () => { 38 + usersService.completeOnboarding.mockResolvedValue({ 39 + onboardingCompletedAt: "2026-03-03T12:00:00.000Z", 40 + needsOnboarding: false, 41 + }); 42 + 43 + const req = { 44 + user: { did: "did:plc:abc", session: {} }, 45 + } as AuthenticatedRequest; 46 + 47 + await expect(controller.completeOnboarding(req)).resolves.toEqual({ 48 + onboardingCompletedAt: "2026-03-03T12:00:00.000Z", 49 + needsOnboarding: false, 50 + }); 51 + expect(usersService.completeOnboarding).toHaveBeenCalledWith("did:plc:abc"); 52 + }); 53 + 54 + it("fetches public Trakt history", async () => { 55 + usersService.fetchTraktPublicHistory.mockResolvedValue({ 56 + items: [], 57 + skipped: [], 58 + sourceCount: 0, 59 + }); 60 + 61 + await expect( 62 + controller.fetchMyTraktPublicHistory({ username: "alice", maxItems: 10 }), 63 + ).resolves.toEqual({ items: [], skipped: [], sourceCount: 0 }); 64 + expect(usersService.fetchTraktPublicHistory).toHaveBeenCalledWith("alice", 10); 65 + }); 66 + 67 + it("imports normalized items for authenticated requests", async () => { 68 + usersService.importNormalizedItems.mockResolvedValue({ 69 + imported: 1, 70 + skipped: 0, 71 + failed: 0, 72 + errors: [], 73 + }); 74 + 75 + const req = { 76 + user: { did: "did:plc:abc", session: { did: "did:plc:abc" } }, 77 + } as AuthenticatedRequest; 78 + 79 + await expect( 80 + controller.importMyHistory( 81 + { 82 + items: [ 83 + { 84 + type: "movie", 85 + movieTmdbId: 10, 86 + watchedAt: "2026-01-01T00:00:00.000Z", 87 + }, 88 + ], 89 + }, 90 + req, 91 + ), 92 + ).resolves.toMatchObject({ imported: 1, skipped: 0, failed: 0 }); 93 + }); 94 + 95 + it("rejects import when session is missing", async () => { 96 + const req = { 97 + user: { did: "did:plc:abc", session: undefined }, 98 + } as unknown as AuthenticatedRequest; 99 + 100 + await expect( 101 + controller.importMyHistory( 102 + { 103 + items: [ 104 + { 105 + type: "movie", 106 + movieTmdbId: 10, 107 + watchedAt: "2026-01-01T00:00:00.000Z", 108 + }, 109 + ], 110 + }, 111 + req, 112 + ), 113 + ).rejects.toThrow(BadRequestException); 114 + }); 115 + });
+61
backend/src/users/users.controller.ts
··· 1 1 import { 2 2 Body, 3 + BadRequestException, 3 4 Controller, 4 5 Delete, 5 6 Get, 6 7 Patch, 8 + Post, 7 9 Req, 8 10 UseGuards, 9 11 } from "@nestjs/common"; 10 12 import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; 11 13 import { AuthGuard } from "../auth/auth.guard"; 12 14 import type { AuthenticatedRequest } from "../auth/types"; 15 + import { 16 + CompleteOnboardingResponseDto, 17 + FetchTraktPublicHistoryDto, 18 + FetchTraktPublicHistoryResponseDto, 19 + ImportHistoryDto, 20 + ImportHistoryResponseDto, 21 + } from "./dto/import-history.dto"; 13 22 import { 14 23 DeleteUserAccountDto, 15 24 UpdateUserSettingsDto, ··· 89 98 session, 90 99 dto.deletePDSData ?? false, 91 100 ); 101 + } 102 + 103 + @Post("me/onboarding/complete") 104 + @UseGuards(AuthGuard) 105 + @ApiOperation({ summary: "Complete onboarding for the current user" }) 106 + @ApiResponse({ status: 200, type: CompleteOnboardingResponseDto }) 107 + @ApiResponse({ status: 401, description: "Not authenticated" }) 108 + async completeOnboarding( 109 + @Req() req: AuthenticatedRequest, 110 + ): Promise<CompleteOnboardingResponseDto> { 111 + const did = req.user?.did; 112 + if (!did) { 113 + throw new BadRequestException("User not found in request"); 114 + } 115 + 116 + return this.usersService.completeOnboarding(did); 117 + } 118 + 119 + @Post("me/import/trakt/public/fetch") 120 + @UseGuards(AuthGuard) 121 + @ApiOperation({ summary: "Fetch normalized history from a public Trakt profile" }) 122 + @ApiResponse({ status: 200, type: FetchTraktPublicHistoryResponseDto }) 123 + @ApiResponse({ status: 401, description: "Not authenticated" }) 124 + async fetchMyTraktPublicHistory( 125 + @Body() dto: FetchTraktPublicHistoryDto, 126 + ): Promise<FetchTraktPublicHistoryResponseDto> { 127 + return this.usersService.fetchTraktPublicHistory( 128 + dto.username, 129 + dto.maxItems, 130 + ); 131 + } 132 + 133 + @Post("me/import/history") 134 + @UseGuards(AuthGuard) 135 + @ApiOperation({ summary: "Import normalized watch history items" }) 136 + @ApiResponse({ status: 200, type: ImportHistoryResponseDto }) 137 + @ApiResponse({ status: 401, description: "Not authenticated" }) 138 + async importMyHistory( 139 + @Body() dto: ImportHistoryDto, 140 + @Req() req: AuthenticatedRequest, 141 + ): Promise<ImportHistoryResponseDto> { 142 + const did = req.user?.did; 143 + if (!did) { 144 + throw new BadRequestException("User not found in request"); 145 + } 146 + 147 + const session = req.user?.session as ATSession | undefined; 148 + if (!session || !session.did) { 149 + throw new BadRequestException("Session not found in request"); 150 + } 151 + 152 + return this.usersService.importNormalizedItems(did, session, dto.items); 92 153 } 93 154 }
+8 -1
backend/src/users/users.module.ts
··· 1 1 import { forwardRef, Module } from "@nestjs/common"; 2 2 import { AuthModule } from "../auth/auth.module"; 3 + import { MoviesModule } from "../movies/movies.module"; 3 4 import { PrismaModule } from "../prisma/prisma.module"; 5 + import { ShowsModule } from "../shows/shows.module"; 4 6 import { UsersController } from "./users.controller"; 5 7 import { UsersService } from "./users.service"; 6 8 7 9 @Module({ 8 - imports: [PrismaModule, forwardRef(() => AuthModule)], 10 + imports: [ 11 + PrismaModule, 12 + MoviesModule, 13 + ShowsModule, 14 + forwardRef(() => AuthModule), 15 + ], 9 16 controllers: [UsersController], 10 17 providers: [UsersService], 11 18 exports: [UsersService],
+180
backend/src/users/users.service.spec.ts
··· 1 + import { BadRequestException, NotFoundException } from "@nestjs/common"; 2 + import { ConfigService } from "@nestjs/config"; 3 + import type { MoviesService } from "../movies/movies.service"; 4 + import type { PrismaService } from "../prisma/prisma.service"; 5 + import type { ShowsService } from "../shows/shows.service"; 6 + import { UsersService } from "./users.service"; 7 + 8 + describe("UsersService", () => { 9 + let service: UsersService; 10 + 11 + const prisma = { 12 + user: { 13 + findUnique: jest.fn(), 14 + update: jest.fn(), 15 + }, 16 + trackedMovie: { 17 + findFirst: jest.fn(), 18 + }, 19 + trackedEpisode: { 20 + findFirst: jest.fn(), 21 + }, 22 + } as unknown as PrismaService; 23 + 24 + const moviesService = { 25 + markWatched: jest.fn(), 26 + indexTrackedMovie: jest.fn(), 27 + } as unknown as MoviesService; 28 + 29 + const showsService = { 30 + markEpisodeWatched: jest.fn(), 31 + indexTrackedEpisode: jest.fn(), 32 + } as unknown as ShowsService; 33 + 34 + const configService = { 35 + get: jest.fn((key: string) => { 36 + if (key === "TRAKT_API_KEY") return "trakt-key"; 37 + return undefined; 38 + }), 39 + } as unknown as ConfigService; 40 + 41 + beforeEach(() => { 42 + jest.clearAllMocks(); 43 + service = new UsersService(prisma, moviesService, showsService, configService); 44 + }); 45 + 46 + it("completes onboarding for an existing user", async () => { 47 + prisma.user.findUnique = jest.fn().mockResolvedValue({ did: "did:plc:123" }); 48 + prisma.user.update = jest.fn().mockResolvedValue({ 49 + onboardingCompletedAt: new Date("2026-03-03T12:00:00.000Z"), 50 + }); 51 + 52 + await expect(service.completeOnboarding("did:plc:123")).resolves.toEqual({ 53 + onboardingCompletedAt: "2026-03-03T12:00:00.000Z", 54 + needsOnboarding: false, 55 + }); 56 + }); 57 + 58 + it("throws when completing onboarding for missing user", async () => { 59 + prisma.user.findUnique = jest.fn().mockResolvedValue(null); 60 + 61 + await expect(service.completeOnboarding("did:plc:missing")).rejects.toThrow( 62 + NotFoundException, 63 + ); 64 + }); 65 + 66 + it("normalizes Trakt movie/episode items and skips unsupported action", async () => { 67 + const payload = [ 68 + { 69 + type: "movie", 70 + action: "watch", 71 + watched_at: "2026-01-01T01:00:00.000Z", 72 + movie: { ids: { tmdb: 100 } }, 73 + }, 74 + { 75 + type: "episode", 76 + action: "scrobble", 77 + watched_at: "2026-01-02T02:00:00.000Z", 78 + show: { ids: { tmdb: 200 } }, 79 + episode: { season: 1, number: 2 }, 80 + }, 81 + { 82 + type: "movie", 83 + action: "rate", 84 + watched_at: "2026-01-03T03:00:00.000Z", 85 + movie: { ids: { tmdb: 300 } }, 86 + }, 87 + ]; 88 + 89 + global.fetch = jest.fn().mockResolvedValue({ 90 + ok: true, 91 + status: 200, 92 + json: async () => payload, 93 + }) as unknown as typeof fetch; 94 + 95 + const result = await service.fetchTraktPublicHistory("alice", 100); 96 + 97 + expect(result.items).toHaveLength(2); 98 + expect(result.items[0]).toMatchObject({ type: "movie", movieTmdbId: 100 }); 99 + expect(result.items[1]).toMatchObject({ 100 + type: "episode", 101 + showTmdbId: 200, 102 + seasonNumber: 1, 103 + episodeNumber: 2, 104 + }); 105 + expect(result.skipped).toHaveLength(1); 106 + expect(result.skipped[0]?.reason).toBe("unsupported_action"); 107 + }); 108 + 109 + it("returns dedupe skips without dropping rewatches", async () => { 110 + prisma.trackedMovie.findFirst = jest 111 + .fn() 112 + .mockResolvedValueOnce(null) 113 + .mockResolvedValueOnce(null); 114 + moviesService.markWatched = jest.fn().mockResolvedValue({ 115 + uri: "at://movie/1", 116 + cid: "cid-1", 117 + rkey: "rkey-1", 118 + }); 119 + moviesService.indexTrackedMovie = jest.fn().mockResolvedValue({}); 120 + 121 + const result = await service.importNormalizedItems( 122 + "did:plc:abc", 123 + { did: "did:plc:abc" }, 124 + [ 125 + { 126 + type: "movie", 127 + movieTmdbId: 10, 128 + watchedAt: "2026-01-01T00:00:00.000Z", 129 + }, 130 + { 131 + type: "movie", 132 + movieTmdbId: 10, 133 + watchedAt: "2026-01-01T00:00:00.000Z", 134 + }, 135 + { 136 + type: "movie", 137 + movieTmdbId: 10, 138 + watchedAt: "2026-01-02T00:00:00.000Z", 139 + }, 140 + ], 141 + ); 142 + 143 + expect(moviesService.markWatched).toHaveBeenCalledTimes(2); 144 + expect(result).toMatchObject({ imported: 2, skipped: 1, failed: 0 }); 145 + }); 146 + 147 + it("continues when a write fails", async () => { 148 + prisma.trackedMovie.findFirst = jest.fn().mockResolvedValue(null); 149 + moviesService.markWatched = jest.fn().mockRejectedValue(new Error("write failed")); 150 + 151 + const result = await service.importNormalizedItems( 152 + "did:plc:abc", 153 + { did: "did:plc:abc" }, 154 + [ 155 + { 156 + type: "movie", 157 + movieTmdbId: 10, 158 + watchedAt: "2026-01-01T00:00:00.000Z", 159 + }, 160 + ], 161 + ); 162 + 163 + expect(result.failed).toBe(1); 164 + expect(result.errors[0]?.code).toBe("write_failed"); 165 + }); 166 + 167 + it("rejects history import payloads larger than 100", async () => { 168 + await expect( 169 + service.importNormalizedItems( 170 + "did:plc:abc", 171 + { did: "did:plc:abc" }, 172 + Array.from({ length: 101 }, () => ({ 173 + type: "movie" as const, 174 + movieTmdbId: 1, 175 + watchedAt: "2026-01-01T00:00:00.000Z", 176 + })), 177 + ), 178 + ).rejects.toThrow(BadRequestException); 179 + }); 180 + });
+441 -30
backend/src/users/users.service.ts
··· 1 1 import { Agent } from "@atproto/api"; 2 - import { Injectable, Logger, NotFoundException } from "@nestjs/common"; 2 + import { 3 + BadRequestException, 4 + HttpException, 5 + HttpStatus, 6 + Injectable, 7 + Logger, 8 + NotFoundException, 9 + ServiceUnavailableException, 10 + } from "@nestjs/common"; 11 + import { ConfigService } from "@nestjs/config"; 3 12 import { $nsid as EPISODE_COLLECTION } from "../lexicons/xyz/opnshelf/episode"; 4 13 import { $nsid as LIST_COLLECTION } from "../lexicons/xyz/opnshelf/list"; 5 14 import { $nsid as LIST_ITEM_COLLECTION } from "../lexicons/xyz/opnshelf/listItem"; 6 15 import { $nsid as MOVIE_COLLECTION } from "../lexicons/xyz/opnshelf/movie"; 16 + import { MoviesService } from "../movies/movies.service"; 7 17 import { PrismaService } from "../prisma/prisma.service"; 18 + import { ShowsService } from "../shows/shows.service"; 19 + import type { 20 + CompleteOnboardingResponseDto, 21 + FetchTraktPublicHistoryResponseDto, 22 + ImportErrorDto, 23 + ImportHistoryResponseDto, 24 + ImportSkipDto, 25 + NormalizedImportItemDto, 26 + } from "./dto/import-history.dto"; 8 27 import type { 9 28 UpdateUserSettingsDto, 10 29 UserSettingsDto, ··· 17 36 @Injectable() 18 37 export class UsersService { 19 38 private readonly logger = new Logger(UsersService.name); 39 + private readonly traktApiKey: string; 40 + private readonly traktBaseUrl = "https://api.trakt.tv"; 41 + private readonly traktUserAgent = "OpnShelf/1.0 (+https://opnshelf.xyz)"; 42 + private readonly allowedActions = new Set(["watch", "scrobble", "checkin"]); 20 43 21 - constructor(private readonly prisma: PrismaService) {} 44 + constructor( 45 + private readonly prisma: PrismaService, 46 + private readonly moviesService: MoviesService, 47 + private readonly showsService: ShowsService, 48 + private readonly configService: ConfigService, 49 + ) { 50 + this.traktApiKey = this.configService.get<string>("TRAKT_API_KEY") ?? ""; 51 + } 22 52 23 53 /** 24 54 * Get user settings by DID ··· 77 107 }; 78 108 } 79 109 110 + async completeOnboarding(did: string): Promise<CompleteOnboardingResponseDto> { 111 + const user = await this.prisma.user.findUnique({ where: { did } }); 112 + if (!user) { 113 + throw new NotFoundException("User not found"); 114 + } 115 + 116 + const updated = await this.prisma.user.update({ 117 + where: { did }, 118 + data: { 119 + onboardingCompletedAt: new Date(), 120 + }, 121 + select: { 122 + onboardingCompletedAt: true, 123 + }, 124 + }); 125 + 126 + return { 127 + onboardingCompletedAt: 128 + updated.onboardingCompletedAt?.toISOString() ?? new Date().toISOString(), 129 + needsOnboarding: false, 130 + }; 131 + } 132 + 133 + async fetchTraktPublicHistory( 134 + username: string, 135 + maxItems?: number, 136 + ): Promise<FetchTraktPublicHistoryResponseDto> { 137 + if (!this.traktApiKey) { 138 + throw new BadRequestException( 139 + "Trakt import is not configured on this server. You can still import via CSV.", 140 + ); 141 + } 142 + 143 + const normalizedUsername = username.trim(); 144 + if (!normalizedUsername) { 145 + throw new BadRequestException("Trakt username is required"); 146 + } 147 + 148 + const safeMaxItems = 149 + typeof maxItems === "number" ? Math.max(Math.floor(maxItems), 1) : Number.POSITIVE_INFINITY; 150 + const pageSize = 100; 151 + let page = 1; 152 + let sourceCount = 0; 153 + const items: NormalizedImportItemDto[] = []; 154 + const skipped: ImportSkipDto[] = []; 155 + 156 + while (items.length < safeMaxItems) { 157 + const url = new URL( 158 + `/users/${encodeURIComponent(normalizedUsername)}/history`, 159 + this.traktBaseUrl, 160 + ); 161 + url.searchParams.set("page", String(page)); 162 + url.searchParams.set("limit", String(pageSize)); 163 + 164 + const response = await fetch(url.toString(), { 165 + headers: { 166 + "trakt-api-key": this.traktApiKey, 167 + "trakt-api-version": "2", 168 + "User-Agent": this.traktUserAgent, 169 + }, 170 + signal: AbortSignal.timeout(12_000), 171 + }); 172 + 173 + if (response.status === 404) { 174 + throw new NotFoundException("Trakt user not found"); 175 + } 176 + if (response.status === 401 || response.status === 403) { 177 + throw new BadRequestException( 178 + "Trakt profile is private or unavailable. Try CSV import instead.", 179 + ); 180 + } 181 + if (response.status === 429) { 182 + throw new HttpException( 183 + "Trakt rate limit reached. Please retry in a few minutes or use CSV import.", 184 + HttpStatus.TOO_MANY_REQUESTS, 185 + ); 186 + } 187 + if (response.status >= 500) { 188 + throw new ServiceUnavailableException( 189 + "Trakt is temporarily unavailable. Please retry later or use CSV import.", 190 + ); 191 + } 192 + if (!response.ok) { 193 + throw new BadRequestException("Failed to fetch Trakt public history"); 194 + } 195 + 196 + const payload = (await response.json()) as unknown; 197 + if (!Array.isArray(payload)) { 198 + throw new BadRequestException("Unexpected Trakt response format"); 199 + } 200 + 201 + sourceCount += payload.length; 202 + for (let i = 0; i < payload.length; i++) { 203 + if (items.length >= safeMaxItems) { 204 + break; 205 + } 206 + const result = this.normalizeTraktApiItem(payload[i], sourceCount - payload.length + i + 1); 207 + if (result.item) { 208 + items.push(result.item); 209 + } else if (result.skip) { 210 + skipped.push(result.skip); 211 + } 212 + } 213 + 214 + if (payload.length < pageSize) { 215 + break; 216 + } 217 + 218 + page += 1; 219 + } 220 + 221 + return { 222 + items, 223 + skipped, 224 + sourceCount, 225 + }; 226 + } 227 + 228 + async importNormalizedItems( 229 + userDid: string, 230 + session: ATSession, 231 + items: NormalizedImportItemDto[], 232 + ): Promise<ImportHistoryResponseDto> { 233 + if (items.length > 100) { 234 + throw new BadRequestException("A maximum of 100 items can be imported per request"); 235 + } 236 + 237 + let imported = 0; 238 + let skipped = 0; 239 + let failed = 0; 240 + const errors: ImportErrorDto[] = []; 241 + const dedupeSet = new Set<string>(); 242 + 243 + for (let index = 0; index < items.length; index++) { 244 + const item = items[index]; 245 + const dedupeKey = this.buildImportKey(item); 246 + if (dedupeSet.has(dedupeKey)) { 247 + skipped += 1; 248 + continue; 249 + } 250 + dedupeSet.add(dedupeKey); 251 + 252 + const alreadyImported = await this.alreadyImported(userDid, item); 253 + if (alreadyImported) { 254 + skipped += 1; 255 + continue; 256 + } 257 + 258 + try { 259 + if (item.type === "movie" && item.movieTmdbId) { 260 + const write = await this.moviesService.markWatched( 261 + userDid, 262 + session, 263 + String(item.movieTmdbId), 264 + item.watchedAt, 265 + ); 266 + await this.moviesService.indexTrackedMovie( 267 + write.uri, 268 + write.cid, 269 + write.rkey, 270 + userDid, 271 + String(item.movieTmdbId), 272 + item.watchedAt, 273 + ); 274 + imported += 1; 275 + continue; 276 + } 277 + 278 + if ( 279 + item.type === "episode" && 280 + item.showTmdbId && 281 + item.seasonNumber !== undefined && 282 + item.episodeNumber !== undefined 283 + ) { 284 + const write = await this.showsService.markEpisodeWatched( 285 + userDid, 286 + session, 287 + String(item.showTmdbId), 288 + item.seasonNumber, 289 + item.episodeNumber, 290 + item.watchedAt, 291 + ); 292 + await this.showsService.indexTrackedEpisode( 293 + write.uri, 294 + write.cid, 295 + write.rkey, 296 + userDid, 297 + String(item.showTmdbId), 298 + item.seasonNumber, 299 + item.episodeNumber, 300 + item.watchedAt, 301 + ); 302 + imported += 1; 303 + continue; 304 + } 305 + 306 + failed += 1; 307 + const itemContext = this.describeImportItem(item); 308 + errors.push({ 309 + index: index + 1, 310 + code: "invalid_item", 311 + message: `${itemContext}: missing required fields`, 312 + }); 313 + } catch (error) { 314 + failed += 1; 315 + const itemContext = this.describeImportItem(item); 316 + const rawMessage = 317 + error instanceof Error ? error.message : "Failed to import watch item"; 318 + this.logger.warn( 319 + `Failed to import item at index ${index + 1}: ${error instanceof Error ? error.message : String(error)}`, 320 + ); 321 + errors.push({ 322 + index: index + 1, 323 + code: "write_failed", 324 + message: `${itemContext}: ${rawMessage}`, 325 + }); 326 + } 327 + } 328 + 329 + return { 330 + imported, 331 + skipped, 332 + failed, 333 + errors, 334 + }; 335 + } 336 + 337 + private normalizeTraktApiItem( 338 + rawItem: unknown, 339 + index: number, 340 + ): { item?: NormalizedImportItemDto; skip?: ImportSkipDto } { 341 + if (!rawItem || typeof rawItem !== "object") { 342 + return { 343 + skip: { 344 + index, 345 + reason: "unsupported_type", 346 + message: "Invalid item format", 347 + }, 348 + }; 349 + } 350 + 351 + const item = rawItem as { 352 + type?: unknown; 353 + action?: unknown; 354 + watched_at?: unknown; 355 + movie?: { ids?: { tmdb?: unknown } }; 356 + show?: { ids?: { tmdb?: unknown } }; 357 + episode?: { season?: unknown; number?: unknown }; 358 + }; 359 + 360 + const action = 361 + typeof item.action === "string" ? (item.action as string) : "watch"; 362 + if (!this.allowedActions.has(action)) { 363 + return { 364 + skip: { 365 + index, 366 + reason: "unsupported_action", 367 + message: `Unsupported action: ${String(item.action ?? "unknown")}`, 368 + }, 369 + }; 370 + } 371 + 372 + const normalizedAction = action as "watch" | "scrobble" | "checkin"; 373 + 374 + if (typeof item.watched_at !== "string" || Number.isNaN(Date.parse(item.watched_at))) { 375 + return { 376 + skip: { 377 + index, 378 + reason: "invalid_watched_at", 379 + message: "Missing or invalid watched_at timestamp", 380 + }, 381 + }; 382 + } 383 + 384 + const watchedAt = new Date(item.watched_at).toISOString(); 385 + 386 + if (item.type === "movie") { 387 + const tmdbId = item.movie?.ids?.tmdb; 388 + if (typeof tmdbId !== "number" || !Number.isInteger(tmdbId) || tmdbId < 1) { 389 + return { 390 + skip: { 391 + index, 392 + reason: "missing_tmdb_id", 393 + message: "Movie item is missing a TMDB id", 394 + }, 395 + }; 396 + } 397 + 398 + return { 399 + item: { 400 + type: "movie", 401 + movieTmdbId: tmdbId, 402 + action: normalizedAction, 403 + watchedAt, 404 + }, 405 + }; 406 + } 407 + 408 + if (item.type === "episode") { 409 + const tmdbId = item.show?.ids?.tmdb; 410 + const seasonNumber = item.episode?.season; 411 + const episodeNumber = item.episode?.number; 412 + 413 + if (typeof tmdbId !== "number" || !Number.isInteger(tmdbId) || tmdbId < 1) { 414 + return { 415 + skip: { 416 + index, 417 + reason: "missing_tmdb_id", 418 + message: "Episode item is missing a show TMDB id", 419 + }, 420 + }; 421 + } 422 + 423 + if ( 424 + typeof seasonNumber !== "number" || 425 + typeof episodeNumber !== "number" || 426 + !Number.isInteger(seasonNumber) || 427 + !Number.isInteger(episodeNumber) || 428 + seasonNumber < 0 || 429 + episodeNumber < 1 430 + ) { 431 + return { 432 + skip: { 433 + index, 434 + reason: "missing_episode_ref", 435 + message: "Episode item is missing season and episode numbers", 436 + }, 437 + }; 438 + } 439 + 440 + return { 441 + item: { 442 + type: "episode", 443 + showTmdbId: tmdbId, 444 + seasonNumber, 445 + episodeNumber, 446 + action: normalizedAction, 447 + watchedAt, 448 + }, 449 + }; 450 + } 451 + 452 + return { 453 + skip: { 454 + index, 455 + reason: "unsupported_type", 456 + message: `Unsupported item type: ${String(item.type ?? "unknown")}`, 457 + }, 458 + }; 459 + } 460 + 461 + private buildImportKey(item: NormalizedImportItemDto): string { 462 + if (item.type === "movie") { 463 + return `movie:${item.movieTmdbId}:${item.watchedAt}`; 464 + } 465 + return `episode:${item.showTmdbId}:${item.seasonNumber}:${item.episodeNumber}:${item.watchedAt}`; 466 + } 467 + 468 + private async alreadyImported( 469 + userDid: string, 470 + item: NormalizedImportItemDto, 471 + ): Promise<boolean> { 472 + const watchedDate = new Date(item.watchedAt); 473 + 474 + if (item.type === "movie" && item.movieTmdbId) { 475 + const existing = await this.prisma.trackedMovie.findFirst({ 476 + where: { 477 + userDid, 478 + movieId: String(item.movieTmdbId), 479 + watchedDate, 480 + }, 481 + select: { id: true }, 482 + }); 483 + return !!existing; 484 + } 485 + 486 + if ( 487 + item.type === "episode" && 488 + item.showTmdbId && 489 + item.seasonNumber !== undefined && 490 + item.episodeNumber !== undefined 491 + ) { 492 + const existing = await this.prisma.trackedEpisode.findFirst({ 493 + where: { 494 + userDid, 495 + showId: String(item.showTmdbId), 496 + seasonNumber: item.seasonNumber, 497 + episodeNumber: item.episodeNumber, 498 + watchedDate, 499 + }, 500 + select: { id: true }, 501 + }); 502 + return !!existing; 503 + } 504 + 505 + return false; 506 + } 507 + 508 + private describeImportItem(item: NormalizedImportItemDto): string { 509 + const watchedAt = item.watchedAt; 510 + const action = item.action ?? "watch"; 511 + 512 + if (item.type === "movie") { 513 + return `movie tmdb=${item.movieTmdbId ?? "unknown"}, watchedAt=${watchedAt}, action=${action}`; 514 + } 515 + 516 + return `episode showTmdb=${item.showTmdbId ?? "unknown"}, season=${item.seasonNumber ?? "unknown"}, episode=${item.episodeNumber ?? "unknown"}, watchedAt=${watchedAt}, action=${action}`; 517 + } 518 + 80 519 /** 81 520 * Delete user account 82 521 * @param did - User's DID ··· 113 552 collection: MOVIE_COLLECTION, 114 553 rkey: tracked.rkey, 115 554 }); 116 - this.logger.log( 117 - `Deleted AT record with rkey ${tracked.rkey} from PDS`, 118 - ); 119 555 } catch (error) { 120 556 this.logger.warn( 121 557 `Failed to delete record ${tracked.rkey} from PDS: ${error}`, 122 558 ); 123 559 } 124 560 } 125 - 126 - this.logger.log( 127 - `Deleted ${trackedMovies.length} records from PDS for user ${did}`, 128 - ); 129 561 130 562 const trackedEpisodes = await this.prisma.trackedEpisode.findMany({ 131 563 where: { userDid: did }, ··· 138 570 collection: EPISODE_COLLECTION, 139 571 rkey: tracked.rkey, 140 572 }); 141 - this.logger.log( 142 - `Deleted episode record with rkey ${tracked.rkey} from PDS`, 143 - ); 144 573 } catch (error) { 145 574 this.logger.warn( 146 575 `Failed to delete episode record ${tracked.rkey} from PDS: ${error}`, 147 576 ); 148 577 } 149 578 } 150 - 151 - this.logger.log( 152 - `Deleted ${trackedEpisodes.length} episode records from PDS for user ${did}`, 153 - ); 154 579 155 580 const listItems = await this.prisma.listItem.findMany({ 156 581 where: { list: { userDid: did } }, ··· 163 588 collection: LIST_ITEM_COLLECTION, 164 589 rkey: item.rkey, 165 590 }); 166 - this.logger.log( 167 - `Deleted list item with rkey ${item.rkey} from PDS`, 168 - ); 169 591 } catch (error) { 170 592 this.logger.warn( 171 593 `Failed to delete list item ${item.rkey} from PDS: ${error}`, 172 594 ); 173 595 } 174 596 } 175 - 176 - this.logger.log( 177 - `Deleted ${listItems.length} list items from PDS for user ${did}`, 178 - ); 179 597 180 598 const lists = await this.prisma.movieList.findMany({ 181 599 where: { userDid: did }, ··· 188 606 collection: LIST_COLLECTION, 189 607 rkey: list.rkey, 190 608 }); 191 - this.logger.log(`Deleted list with rkey ${list.rkey} from PDS`); 192 609 } catch (error) { 193 610 this.logger.warn( 194 611 `Failed to delete list ${list.rkey} from PDS: ${error}`, 195 612 ); 196 613 } 197 614 } 198 - 199 - this.logger.log( 200 - `Deleted ${lists.length} lists from PDS for user ${did}`, 201 - ); 202 615 } catch (error) { 203 616 this.logger.error( 204 617 `Failed to delete PDS records for user ${did}`, ··· 210 623 await this.prisma.user.delete({ 211 624 where: { did }, 212 625 }); 213 - 214 - this.logger.log(`Deleted user ${did} and all associated data`); 215 626 } 216 627 }
+2
packages/api/src/client.ts
··· 65 65 handle: string; 66 66 displayName: string | null; 67 67 avatar: string | null; 68 + onboardingCompletedAt: string | null; 69 + needsOnboarding: boolean; 68 70 } 69 71 70 72 // Simple URL helper for login (not an API call)
+71 -2
packages/api/src/generated/@tanstack/react-query.gen.ts
··· 3 3 import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; 4 4 5 5 import { client } from '../client.gen'; 6 - import { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSuggestions, listsControllerAddItemToList, listsControllerAddToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetListsForMovie, listsControllerGetUserLists, listsControllerInitDefaultLists, listsControllerRemoveFromList, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerGetUserMoviesPaginated, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, searchControllerDiscoverAll, searchControllerSearchAll, shelfControllerGetUserShelf, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserEpisodesPaginated, showsControllerGetUserShows, showsControllerMarkSeasonWatched, showsControllerMarkShowWatched, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, usersControllerDeleteMyAccount, usersControllerGetMySettings, usersControllerUpdateMySettings } from '../sdk.gen'; 7 - import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerMeData, AuthControllerMeResponse, AuthControllerSuggestionsData, ListsControllerAddItemToListData, ListsControllerAddToListData, ListsControllerCreateListData, ListsControllerCreateListResponse, ListsControllerDeleteListData, ListsControllerGetListData, ListsControllerGetListResponse, ListsControllerGetListsForItemData, ListsControllerGetListsForItemResponse, ListsControllerGetListsForMovieData, ListsControllerGetUserListsData, ListsControllerGetUserListsResponse, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsResponse, ListsControllerRemoveFromListData, ListsControllerRemoveItemFromListData, ListsControllerUpdateListData, ListsControllerUpdateListResponse, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieResponse, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponse, MoviesControllerGetUserMoviesResponse, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedResponse, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedResponse, SearchControllerDiscoverAllData, SearchControllerDiscoverAllResponse, SearchControllerSearchAllData, SearchControllerSearchAllResponse, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowResponse, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponse, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerMarkSeasonWatchedData, ShowsControllerMarkSeasonWatchedResponse, ShowsControllerMarkShowWatchedData, ShowsControllerMarkShowWatchedResponse, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedResponse, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountResponse, UsersControllerGetMySettingsData, UsersControllerGetMySettingsResponse, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsResponse } from '../types.gen'; 6 + import { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSignup, authControllerSuggestions, listsControllerAddItemToList, listsControllerAddToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetListsForMovie, listsControllerGetUserLists, listsControllerInitDefaultLists, listsControllerRemoveFromList, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerGetUserMoviesPaginated, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, searchControllerDiscoverAll, searchControllerSearchAll, shelfControllerGetUserShelf, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserEpisodesPaginated, showsControllerGetUserShows, showsControllerMarkSeasonWatched, showsControllerMarkShowWatched, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, usersControllerCompleteOnboarding, usersControllerDeleteMyAccount, usersControllerFetchMyTraktPublicHistory, usersControllerGetMySettings, usersControllerImportMyHistory, usersControllerUpdateMySettings } from '../sdk.gen'; 7 + import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerMeData, AuthControllerMeResponse, AuthControllerSignupData, AuthControllerSuggestionsData, ListsControllerAddItemToListData, ListsControllerAddToListData, ListsControllerCreateListData, ListsControllerCreateListResponse, ListsControllerDeleteListData, ListsControllerGetListData, ListsControllerGetListResponse, ListsControllerGetListsForItemData, ListsControllerGetListsForItemResponse, ListsControllerGetListsForMovieData, ListsControllerGetUserListsData, ListsControllerGetUserListsResponse, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsResponse, ListsControllerRemoveFromListData, ListsControllerRemoveItemFromListData, ListsControllerUpdateListData, ListsControllerUpdateListResponse, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieResponse, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponse, MoviesControllerGetUserMoviesResponse, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedResponse, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedResponse, SearchControllerDiscoverAllData, SearchControllerDiscoverAllResponse, SearchControllerSearchAllData, SearchControllerSearchAllResponse, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowResponse, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponse, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerMarkSeasonWatchedData, ShowsControllerMarkSeasonWatchedResponse, ShowsControllerMarkShowWatchedData, ShowsControllerMarkShowWatchedResponse, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedResponse, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, UsersControllerCompleteOnboardingData, UsersControllerCompleteOnboardingResponse, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountResponse, UsersControllerFetchMyTraktPublicHistoryData, UsersControllerFetchMyTraktPublicHistoryResponse, UsersControllerGetMySettingsData, UsersControllerGetMySettingsResponse, UsersControllerImportMyHistoryData, UsersControllerImportMyHistoryResponse, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsResponse } from '../types.gen'; 8 8 9 9 export type QueryKey<TOptions extends Options> = [ 10 10 Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & { ··· 306 306 return data; 307 307 }, 308 308 queryKey: authControllerLoginQueryKey(options) 309 + }); 310 + 311 + export const authControllerSignupQueryKey = (options?: Options<AuthControllerSignupData>) => createQueryKey('authControllerSignup', options); 312 + 313 + /** 314 + * Start AT Protocol OAuth signup via PDS 315 + */ 316 + export const authControllerSignupOptions = (options?: Options<AuthControllerSignupData>) => queryOptions<unknown, DefaultError, unknown, ReturnType<typeof authControllerSignupQueryKey>>({ 317 + queryFn: async ({ queryKey, signal }) => { 318 + const { data } = await authControllerSignup({ 319 + ...options, 320 + ...queryKey[0], 321 + signal, 322 + throwOnError: true 323 + }); 324 + return data; 325 + }, 326 + queryKey: authControllerSignupQueryKey(options) 309 327 }); 310 328 311 329 export const authControllerSuggestionsQueryKey = (options: Options<AuthControllerSuggestionsData>) => createQueryKey('authControllerSuggestions', options); ··· 903 921 const mutationOptions: UseMutationOptions<UsersControllerDeleteMyAccountResponse, DefaultError, Options<UsersControllerDeleteMyAccountData>> = { 904 922 mutationFn: async (fnOptions) => { 905 923 const { data } = await usersControllerDeleteMyAccount({ 924 + ...options, 925 + ...fnOptions, 926 + throwOnError: true 927 + }); 928 + return data; 929 + } 930 + }; 931 + return mutationOptions; 932 + }; 933 + 934 + /** 935 + * Complete onboarding for the current user 936 + */ 937 + export const usersControllerCompleteOnboardingMutation = (options?: Partial<Options<UsersControllerCompleteOnboardingData>>): UseMutationOptions<UsersControllerCompleteOnboardingResponse, DefaultError, Options<UsersControllerCompleteOnboardingData>> => { 938 + const mutationOptions: UseMutationOptions<UsersControllerCompleteOnboardingResponse, DefaultError, Options<UsersControllerCompleteOnboardingData>> = { 939 + mutationFn: async (fnOptions) => { 940 + const { data } = await usersControllerCompleteOnboarding({ 941 + ...options, 942 + ...fnOptions, 943 + throwOnError: true 944 + }); 945 + return data; 946 + } 947 + }; 948 + return mutationOptions; 949 + }; 950 + 951 + /** 952 + * Fetch normalized history from a public Trakt profile 953 + */ 954 + export const usersControllerFetchMyTraktPublicHistoryMutation = (options?: Partial<Options<UsersControllerFetchMyTraktPublicHistoryData>>): UseMutationOptions<UsersControllerFetchMyTraktPublicHistoryResponse, DefaultError, Options<UsersControllerFetchMyTraktPublicHistoryData>> => { 955 + const mutationOptions: UseMutationOptions<UsersControllerFetchMyTraktPublicHistoryResponse, DefaultError, Options<UsersControllerFetchMyTraktPublicHistoryData>> = { 956 + mutationFn: async (fnOptions) => { 957 + const { data } = await usersControllerFetchMyTraktPublicHistory({ 958 + ...options, 959 + ...fnOptions, 960 + throwOnError: true 961 + }); 962 + return data; 963 + } 964 + }; 965 + return mutationOptions; 966 + }; 967 + 968 + /** 969 + * Import normalized watch history items 970 + */ 971 + export const usersControllerImportMyHistoryMutation = (options?: Partial<Options<UsersControllerImportMyHistoryData>>): UseMutationOptions<UsersControllerImportMyHistoryResponse, DefaultError, Options<UsersControllerImportMyHistoryData>> => { 972 + const mutationOptions: UseMutationOptions<UsersControllerImportMyHistoryResponse, DefaultError, Options<UsersControllerImportMyHistoryData>> = { 973 + mutationFn: async (fnOptions) => { 974 + const { data } = await usersControllerImportMyHistory({ 906 975 ...options, 907 976 ...fnOptions, 908 977 throwOnError: true
+2 -2
packages/api/src/generated/index.ts
··· 1 1 // This file is auto-generated by @hey-api/openapi-ts 2 2 3 - export { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSuggestions, listsControllerAddItemToList, listsControllerAddToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetListsForMovie, listsControllerGetUserLists, listsControllerInitDefaultLists, listsControllerRemoveFromList, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerGetUserMoviesPaginated, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, searchControllerDiscoverAll, searchControllerSearchAll, shelfControllerGetUserShelf, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserEpisodesPaginated, showsControllerGetUserShows, showsControllerMarkSeasonWatched, showsControllerMarkShowWatched, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, usersControllerDeleteMyAccount, usersControllerGetMySettings, usersControllerUpdateMySettings } from './sdk.gen'; 4 - export type { AddToListDto, AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponse, AuthControllerMeResponses, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ClientOptions, CreateListDto, DeleteUserAccountDto, EpisodeContextDto, EpisodeHistoryItemDto, EpisodeReferenceDto, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponse, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponse, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponse, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponse, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponse, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponse, ListsControllerUpdateListResponses, MarkedEpisodesResponseDto, MarkEpisodeWatchedDto, MarkSeasonWatchedDto, MarkShowWatchedDto, MediaInListDto, MovieColorsDto, MovieDto, MovieListDto, MovieListsForItemDto, MovieListSummaryDto, MovieListWithMoviesDto, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponse, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponse, MoviesControllerGetUserMoviesPaginatedResponses, MoviesControllerGetUserMoviesResponse, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponse, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponse, MoviesControllerUnmarkWatchedResponses, PaginatedEpisodesResponseDto, PaginatedMoviesResponseDto, SearchControllerDiscoverAllData, SearchControllerDiscoverAllResponse, SearchControllerDiscoverAllResponses, SearchControllerSearchAllData, SearchControllerSearchAllResponse, SearchControllerSearchAllResponses, SearchResultsDto, SearchShowsResultsDto, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponse, ShelfControllerGetUserShelfResponses, ShelfResponseDto, ShowDto, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponse, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponse, ShowsControllerGetUserEpisodesPaginatedResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerGetUserShowsResponses, ShowsControllerMarkSeasonWatchedData, ShowsControllerMarkSeasonWatchedErrors, ShowsControllerMarkSeasonWatchedResponse, ShowsControllerMarkSeasonWatchedResponses, ShowsControllerMarkShowWatchedData, ShowsControllerMarkShowWatchedErrors, ShowsControllerMarkShowWatchedResponse, ShowsControllerMarkShowWatchedResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponse, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, ShowsControllerUnmarkWatchedResponses, TmdbCastDto, TmdbCreditsDto, TmdbCrewDto, TmdbEpisodeDto, TmdbGenreDto, TmdbMovieDetailDto, TmdbMovieResultDto, TmdbNetworkDto, TmdbSeasonDetailDto, TmdbSeasonSummaryDto, TmdbShowDetailDto, TmdbShowResultDto, TrackedEpisodeDto, TrackedMovieDto, TrackedShowSummaryDto, UnifiedDiscoverResponseDto, UnifiedSearchResponseDto, UnifiedSearchResultDto, UpdateListDto, UpdateUserSettingsDto, UserDto, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponse, UsersControllerDeleteMyAccountResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponse, UsersControllerGetMySettingsResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponse, UsersControllerUpdateMySettingsResponses, UserSettingsDto, WatchHistoryItemDto } from './types.gen'; 3 + export { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSignup, authControllerSuggestions, listsControllerAddItemToList, listsControllerAddToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetListsForMovie, listsControllerGetUserLists, listsControllerInitDefaultLists, listsControllerRemoveFromList, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerGetUserMoviesPaginated, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, searchControllerDiscoverAll, searchControllerSearchAll, shelfControllerGetUserShelf, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserEpisodesPaginated, showsControllerGetUserShows, showsControllerMarkSeasonWatched, showsControllerMarkShowWatched, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, usersControllerCompleteOnboarding, usersControllerDeleteMyAccount, usersControllerFetchMyTraktPublicHistory, usersControllerGetMySettings, usersControllerImportMyHistory, usersControllerUpdateMySettings } from './sdk.gen'; 4 + export type { AddToListDto, AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponse, AuthControllerMeResponses, AuthControllerSignupData, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ClientOptions, CompleteOnboardingResponseDto, CreateListDto, DeleteUserAccountDto, EpisodeContextDto, EpisodeHistoryItemDto, EpisodeReferenceDto, FetchTraktPublicHistoryDto, FetchTraktPublicHistoryResponseDto, ImportErrorDto, ImportHistoryDto, ImportHistoryResponseDto, ImportSkipDto, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponse, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponse, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponse, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponse, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponse, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponse, ListsControllerUpdateListResponses, MarkedEpisodesResponseDto, MarkEpisodeWatchedDto, MarkSeasonWatchedDto, MarkShowWatchedDto, MediaInListDto, MovieColorsDto, MovieDto, MovieListDto, MovieListsForItemDto, MovieListSummaryDto, MovieListWithMoviesDto, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponse, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponse, MoviesControllerGetUserMoviesPaginatedResponses, MoviesControllerGetUserMoviesResponse, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponse, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponse, MoviesControllerUnmarkWatchedResponses, NormalizedImportItemDto, PaginatedEpisodesResponseDto, PaginatedMoviesResponseDto, SearchControllerDiscoverAllData, SearchControllerDiscoverAllResponse, SearchControllerDiscoverAllResponses, SearchControllerSearchAllData, SearchControllerSearchAllResponse, SearchControllerSearchAllResponses, SearchResultsDto, SearchShowsResultsDto, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponse, ShelfControllerGetUserShelfResponses, ShelfResponseDto, ShowDto, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponse, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponse, ShowsControllerGetUserEpisodesPaginatedResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerGetUserShowsResponses, ShowsControllerMarkSeasonWatchedData, ShowsControllerMarkSeasonWatchedErrors, ShowsControllerMarkSeasonWatchedResponse, ShowsControllerMarkSeasonWatchedResponses, ShowsControllerMarkShowWatchedData, ShowsControllerMarkShowWatchedErrors, ShowsControllerMarkShowWatchedResponse, ShowsControllerMarkShowWatchedResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponse, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, ShowsControllerUnmarkWatchedResponses, TmdbCastDto, TmdbCreditsDto, TmdbCrewDto, TmdbEpisodeDto, TmdbGenreDto, TmdbMovieDetailDto, TmdbMovieResultDto, TmdbNetworkDto, TmdbSeasonDetailDto, TmdbSeasonSummaryDto, TmdbShowDetailDto, TmdbShowResultDto, TrackedEpisodeDto, TrackedMovieDto, TrackedShowSummaryDto, UnifiedDiscoverResponseDto, UnifiedSearchResponseDto, UnifiedSearchResultDto, UpdateListDto, UpdateUserSettingsDto, UserDto, UsersControllerCompleteOnboardingData, UsersControllerCompleteOnboardingErrors, UsersControllerCompleteOnboardingResponse, UsersControllerCompleteOnboardingResponses, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponse, UsersControllerDeleteMyAccountResponses, UsersControllerFetchMyTraktPublicHistoryData, UsersControllerFetchMyTraktPublicHistoryErrors, UsersControllerFetchMyTraktPublicHistoryResponse, UsersControllerFetchMyTraktPublicHistoryResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponse, UsersControllerGetMySettingsResponses, UsersControllerImportMyHistoryData, UsersControllerImportMyHistoryErrors, UsersControllerImportMyHistoryResponse, UsersControllerImportMyHistoryResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponse, UsersControllerUpdateMySettingsResponses, UserSettingsDto, WatchHistoryItemDto } from './types.gen';
+35 -1
packages/api/src/generated/sdk.gen.ts
··· 2 2 3 3 import type { Client, Options as Options2, TDataShape } from './client'; 4 4 import { client } from './client.gen'; 5 - import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponses, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponses, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponses, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponses, SearchControllerDiscoverAllData, SearchControllerDiscoverAllResponses, SearchControllerSearchAllData, SearchControllerSearchAllResponses, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponses, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponses, ShowsControllerMarkSeasonWatchedData, ShowsControllerMarkSeasonWatchedErrors, ShowsControllerMarkSeasonWatchedResponses, ShowsControllerMarkShowWatchedData, ShowsControllerMarkShowWatchedErrors, ShowsControllerMarkShowWatchedResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponses, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponses } from './types.gen'; 5 + import type { AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponses, AuthControllerSignupData, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponses, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponses, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponses, SearchControllerDiscoverAllData, SearchControllerDiscoverAllResponses, SearchControllerSearchAllData, SearchControllerSearchAllResponses, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponses, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponses, ShowsControllerMarkSeasonWatchedData, ShowsControllerMarkSeasonWatchedErrors, ShowsControllerMarkSeasonWatchedResponses, ShowsControllerMarkShowWatchedData, ShowsControllerMarkShowWatchedErrors, ShowsControllerMarkShowWatchedResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponses, UsersControllerCompleteOnboardingData, UsersControllerCompleteOnboardingErrors, UsersControllerCompleteOnboardingResponses, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponses, UsersControllerFetchMyTraktPublicHistoryData, UsersControllerFetchMyTraktPublicHistoryErrors, UsersControllerFetchMyTraktPublicHistoryResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponses, UsersControllerImportMyHistoryData, UsersControllerImportMyHistoryErrors, UsersControllerImportMyHistoryResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponses } from './types.gen'; 6 6 7 7 export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { 8 8 /** ··· 84 84 * Start AT Protocol OAuth login 85 85 */ 86 86 export const authControllerLogin = <ThrowOnError extends boolean = false>(options: Options<AuthControllerLoginData, ThrowOnError>) => (options.client ?? client).get<unknown, unknown, ThrowOnError>({ url: '/auth/login', ...options }); 87 + 88 + /** 89 + * Start AT Protocol OAuth signup via PDS 90 + */ 91 + export const authControllerSignup = <ThrowOnError extends boolean = false>(options?: Options<AuthControllerSignupData, ThrowOnError>) => (options?.client ?? client).get<unknown, unknown, ThrowOnError>({ url: '/auth/signup', ...options }); 87 92 88 93 /** 89 94 * Search for actor suggestions by handle prefix ··· 299 304 */ 300 305 export const usersControllerDeleteMyAccount = <ThrowOnError extends boolean = false>(options: Options<UsersControllerDeleteMyAccountData, ThrowOnError>) => (options.client ?? client).delete<UsersControllerDeleteMyAccountResponses, UsersControllerDeleteMyAccountErrors, ThrowOnError>({ 301 306 url: '/users/me/account', 307 + ...options, 308 + headers: { 309 + 'Content-Type': 'application/json', 310 + ...options.headers 311 + } 312 + }); 313 + 314 + /** 315 + * Complete onboarding for the current user 316 + */ 317 + export const usersControllerCompleteOnboarding = <ThrowOnError extends boolean = false>(options?: Options<UsersControllerCompleteOnboardingData, ThrowOnError>) => (options?.client ?? client).post<UsersControllerCompleteOnboardingResponses, UsersControllerCompleteOnboardingErrors, ThrowOnError>({ url: '/users/me/onboarding/complete', ...options }); 318 + 319 + /** 320 + * Fetch normalized history from a public Trakt profile 321 + */ 322 + export const usersControllerFetchMyTraktPublicHistory = <ThrowOnError extends boolean = false>(options: Options<UsersControllerFetchMyTraktPublicHistoryData, ThrowOnError>) => (options.client ?? client).post<UsersControllerFetchMyTraktPublicHistoryResponses, UsersControllerFetchMyTraktPublicHistoryErrors, ThrowOnError>({ 323 + url: '/users/me/import/trakt/public/fetch', 324 + ...options, 325 + headers: { 326 + 'Content-Type': 'application/json', 327 + ...options.headers 328 + } 329 + }); 330 + 331 + /** 332 + * Import normalized watch history items 333 + */ 334 + export const usersControllerImportMyHistory = <ThrowOnError extends boolean = false>(options: Options<UsersControllerImportMyHistoryData, ThrowOnError>) => (options.client ?? client).post<UsersControllerImportMyHistoryResponses, UsersControllerImportMyHistoryErrors, ThrowOnError>({ 335 + url: '/users/me/import/history', 302 336 ...options, 303 337 headers: { 304 338 'Content-Type': 'application/json',
+170
packages/api/src/generated/types.gen.ts
··· 132 132 avatar: { 133 133 [key: string]: unknown; 134 134 } | null; 135 + /** 136 + * When onboarding was completed 137 + */ 138 + onboardingCompletedAt: { 139 + [key: string]: unknown; 140 + } | null; 141 + /** 142 + * Whether this user should complete onboarding 143 + */ 144 + needsOnboarding: boolean; 135 145 }; 136 146 137 147 export type TmdbShowResultDto = { ··· 361 371 mediaType: string; 362 372 mediaId: string; 363 373 /** 374 + * Season number for season/episode show items 375 + */ 376 + seasonNumber?: number; 377 + /** 378 + * Episode number for episode show items 379 + */ 380 + episodeNumber?: number; 381 + /** 364 382 * Legacy movieId field for movie items 365 383 */ 366 384 movieId?: string; ··· 469 487 deletePDSData: boolean; 470 488 }; 471 489 490 + export type CompleteOnboardingResponseDto = { 491 + /** 492 + * Timestamp when onboarding was completed 493 + */ 494 + onboardingCompletedAt: string; 495 + needsOnboarding: boolean; 496 + }; 497 + 498 + export type FetchTraktPublicHistoryDto = { 499 + /** 500 + * Trakt username or slug 501 + */ 502 + username: string; 503 + /** 504 + * Maximum items to fetch. If omitted, fetches full available history via pagination. 505 + */ 506 + maxItems?: number; 507 + }; 508 + 509 + export type NormalizedImportItemDto = { 510 + type: 'movie' | 'episode'; 511 + /** 512 + * UTC datetime in ISO-8601 format 513 + */ 514 + watchedAt: string; 515 + /** 516 + * TMDB movie id 517 + */ 518 + movieTmdbId?: number; 519 + /** 520 + * TMDB show id 521 + */ 522 + showTmdbId?: number; 523 + seasonNumber?: number; 524 + episodeNumber?: number; 525 + action?: 'watch' | 'scrobble' | 'checkin'; 526 + }; 527 + 528 + export type ImportSkipDto = { 529 + /** 530 + * 1-based item index from source payload 531 + */ 532 + index: number; 533 + reason: 'unsupported_type' | 'unsupported_action' | 'missing_tmdb_id' | 'missing_episode_ref' | 'invalid_watched_at'; 534 + message?: string; 535 + }; 536 + 537 + export type FetchTraktPublicHistoryResponseDto = { 538 + items: Array<NormalizedImportItemDto>; 539 + skipped: Array<ImportSkipDto>; 540 + /** 541 + * Count of rows returned by Trakt before filtering 542 + */ 543 + sourceCount: number; 544 + }; 545 + 546 + export type ImportHistoryDto = { 547 + items: Array<NormalizedImportItemDto>; 548 + }; 549 + 550 + export type ImportErrorDto = { 551 + /** 552 + * 1-based item index from request payload 553 + */ 554 + index: number; 555 + code: 'invalid_item' | 'already_exists' | 'write_failed' | 'duplicate_in_request'; 556 + message: string; 557 + }; 558 + 559 + export type ImportHistoryResponseDto = { 560 + imported: number; 561 + skipped: number; 562 + failed: number; 563 + errors: Array<ImportErrorDto>; 564 + }; 565 + 472 566 export type ShelfResponseDto = { 473 567 items: Array<{ 474 568 id: string; ··· 798 892 handle: unknown; 799 893 }; 800 894 url: '/auth/login'; 895 + }; 896 + 897 + export type AuthControllerSignupData = { 898 + body?: never; 899 + path?: never; 900 + query?: { 901 + /** 902 + * User's IANA timezone (e.g., Europe/London) 903 + */ 904 + timezone?: unknown; 905 + /** 906 + * Platform identifier (e.g., "mobile") for redirect handling 907 + */ 908 + platform?: unknown; 909 + }; 910 + url: '/auth/signup'; 801 911 }; 802 912 803 913 export type AuthControllerSuggestionsData = { ··· 1513 1623 }; 1514 1624 1515 1625 export type UsersControllerDeleteMyAccountResponse = UsersControllerDeleteMyAccountResponses[keyof UsersControllerDeleteMyAccountResponses]; 1626 + 1627 + export type UsersControllerCompleteOnboardingData = { 1628 + body?: never; 1629 + path?: never; 1630 + query?: never; 1631 + url: '/users/me/onboarding/complete'; 1632 + }; 1633 + 1634 + export type UsersControllerCompleteOnboardingErrors = { 1635 + /** 1636 + * Not authenticated 1637 + */ 1638 + 401: unknown; 1639 + }; 1640 + 1641 + export type UsersControllerCompleteOnboardingResponses = { 1642 + 200: CompleteOnboardingResponseDto; 1643 + }; 1644 + 1645 + export type UsersControllerCompleteOnboardingResponse = UsersControllerCompleteOnboardingResponses[keyof UsersControllerCompleteOnboardingResponses]; 1646 + 1647 + export type UsersControllerFetchMyTraktPublicHistoryData = { 1648 + body: FetchTraktPublicHistoryDto; 1649 + path?: never; 1650 + query?: never; 1651 + url: '/users/me/import/trakt/public/fetch'; 1652 + }; 1653 + 1654 + export type UsersControllerFetchMyTraktPublicHistoryErrors = { 1655 + /** 1656 + * Not authenticated 1657 + */ 1658 + 401: unknown; 1659 + }; 1660 + 1661 + export type UsersControllerFetchMyTraktPublicHistoryResponses = { 1662 + 200: FetchTraktPublicHistoryResponseDto; 1663 + }; 1664 + 1665 + export type UsersControllerFetchMyTraktPublicHistoryResponse = UsersControllerFetchMyTraktPublicHistoryResponses[keyof UsersControllerFetchMyTraktPublicHistoryResponses]; 1666 + 1667 + export type UsersControllerImportMyHistoryData = { 1668 + body: ImportHistoryDto; 1669 + path?: never; 1670 + query?: never; 1671 + url: '/users/me/import/history'; 1672 + }; 1673 + 1674 + export type UsersControllerImportMyHistoryErrors = { 1675 + /** 1676 + * Not authenticated 1677 + */ 1678 + 401: unknown; 1679 + }; 1680 + 1681 + export type UsersControllerImportMyHistoryResponses = { 1682 + 200: ImportHistoryResponseDto; 1683 + }; 1684 + 1685 + export type UsersControllerImportMyHistoryResponse = UsersControllerImportMyHistoryResponses[keyof UsersControllerImportMyHistoryResponses]; 1516 1686 1517 1687 export type ShelfControllerGetUserShelfData = { 1518 1688 body?: never;
+18
pnpm-lock.yaml
··· 213 213 nitro: 214 214 specifier: npm:nitro-nightly@latest 215 215 version: nitro-nightly@3.0.1-20260127-164246-ef01b092(@electric-sql/pglite@0.3.15)(chokidar@5.0.0)(lru-cache@11.2.5)(mysql2@3.15.3)(rollup@4.57.0)(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) 216 + papaparse: 217 + specifier: ^5.5.3 218 + version: 5.5.3 216 219 posthog-js: 217 220 specifier: ^1.356.1 218 221 version: 1.356.1 ··· 256 259 '@types/node': 257 260 specifier: ^22.10.2 258 261 version: 22.19.7 262 + '@types/papaparse': 263 + specifier: ^5.5.2 264 + version: 5.5.2 259 265 '@types/react': 260 266 specifier: ^19.2.0 261 267 version: 19.2.10 ··· 4074 4080 4075 4081 '@types/node@22.19.7': 4076 4082 resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} 4083 + 4084 + '@types/papaparse@5.5.2': 4085 + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} 4077 4086 4078 4087 '@types/qs@6.14.0': 4079 4088 resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} ··· 7121 7130 pako@1.0.11: 7122 7131 resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} 7123 7132 7133 + papaparse@5.5.3: 7134 + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} 7135 + 7124 7136 parent-module@1.0.1: 7125 7137 resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} 7126 7138 engines: {node: '>=6'} ··· 13674 13686 dependencies: 13675 13687 undici-types: 6.21.0 13676 13688 13689 + '@types/papaparse@5.5.2': 13690 + dependencies: 13691 + '@types/node': 22.19.7 13692 + 13677 13693 '@types/qs@6.14.0': {} 13678 13694 13679 13695 '@types/range-parser@1.2.7': {} ··· 17215 17231 package-json-from-dist@1.0.1: {} 17216 17232 17217 17233 pako@1.0.11: {} 17234 + 17235 + papaparse@5.5.3: {} 17218 17236 17219 17237 parent-module@1.0.1: 17220 17238 dependencies: