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.

chore: format & tsc

+143 -72
+1 -2
apps/web/src/routes/__root.tsx
··· 1 - import { configureApiClient } from "@opnshelf/api"; 1 + import { authControllerMeOptions, configureApiClient } from "@opnshelf/api"; 2 2 import { PostHogProvider, usePostHog } from "@posthog/react"; 3 3 import { TanStackDevtools } from "@tanstack/react-devtools"; 4 - import { authControllerMeOptions } from "@opnshelf/api"; 5 4 import { 6 5 type QueryClient, 7 6 QueryClientProvider,
+74 -48
apps/web/src/routes/onboarding.tsx
··· 144 144 const isImportBusy = isImporting || importProgress.phase === "parsing_csv"; 145 145 const importPercent = 146 146 importProgress.totalItems > 0 147 - ? Math.round((importProgress.processedItems / importProgress.totalItems) * 100) 147 + ? Math.round( 148 + (importProgress.processedItems / importProgress.totalItems) * 100, 149 + ) 148 150 : 0; 149 151 const userAvatarUrl = typeof user?.avatar === "string" ? user.avatar : ""; 150 152 const userDisplayName = ··· 218 220 219 221 const completeOnboardingAndRedirect = async () => { 220 222 await completeOnboardingMutation.mutateAsync({}); 221 - queryClient.setQueryData(authControllerMeOptions().queryKey, (previousUser) => { 222 - if (!previousUser) { 223 - return previousUser; 224 - } 223 + queryClient.setQueryData( 224 + authControllerMeOptions().queryKey, 225 + (previousUser) => { 226 + if (!previousUser) { 227 + return previousUser; 228 + } 225 229 226 - return { 227 - ...previousUser, 228 - needsOnboarding: false, 229 - }; 230 - }); 230 + return { 231 + ...previousUser, 232 + needsOnboarding: false, 233 + }; 234 + }, 235 + ); 231 236 232 237 navigate({ to: "/profile/shelf", replace: true }); 233 - void queryClient.invalidateQueries({ queryKey: authControllerMeOptions().queryKey }); 238 + void queryClient.invalidateQueries({ 239 + queryKey: authControllerMeOptions().queryKey, 240 + }); 234 241 }; 235 242 236 243 const handleTraktImport = async () => { ··· 370 377 setStep(4); 371 378 } catch (error) { 372 379 const message = 373 - error instanceof Error 374 - ? error.message 375 - : "Unable to parse CSV file"; 380 + error instanceof Error ? error.message : "Unable to parse CSV file"; 376 381 setImportProgress((prev) => ({ 377 382 ...prev, 378 383 phase: "error", ··· 403 408 {step === 1 && ( 404 409 <div className="space-y-5"> 405 410 <p className="md-body-large text-(--md-sys-color-on-surface-variant)"> 406 - Step 1 of 4: Set your profile and time preferences, then import watch 407 - history from Trakt or CSV. 411 + Step 1 of 4: Set your profile and time preferences, then import 412 + watch history from Trakt or CSV. 408 413 </p> 409 414 <div className="flex gap-3"> 410 415 <M3Button variant="filled" onClick={() => setStep(2)}> ··· 456 461 457 462 <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> 458 463 <div className="space-y-2"> 459 - <label className="md-label-large" htmlFor={displayNameId}> 460 - Display name 461 - </label> 462 - <input 463 - id={displayNameId} 464 - type="text" 464 + <label className="md-label-large" htmlFor={displayNameId}> 465 + Display name 466 + </label> 467 + <input 468 + id={displayNameId} 469 + type="text" 465 470 value={displayName} 466 471 onChange={(event) => setDisplayName(event.target.value)} 467 472 placeholder="How your name appears" ··· 470 475 </div> 471 476 472 477 <div className="space-y-2"> 473 - <label className="md-label-large" htmlFor={timezoneId}> 474 - Timezone 475 - </label> 476 - <select 477 - id={timezoneId} 478 - value={timezone} 478 + <label className="md-label-large" htmlFor={timezoneId}> 479 + Timezone 480 + </label> 481 + <select 482 + id={timezoneId} 483 + value={timezone} 479 484 onChange={(event) => setTimezone(event.target.value)} 480 485 className="w-full rounded-(--md-sys-shape-corner-medium) border px-3 py-2 bg-(--md-sys-color-surface)" 481 486 > ··· 525 530 </div> 526 531 527 532 <div className="flex gap-3"> 528 - <M3Button variant="text" onClick={() => setStep(1)} disabled={isSavingProfile}> 533 + <M3Button 534 + variant="text" 535 + onClick={() => setStep(1)} 536 + disabled={isSavingProfile} 537 + > 529 538 Back 530 539 </M3Button> 531 540 <M3Button ··· 558 567 /> 559 568 </div> 560 569 <p className="md-body-small text-(--md-sys-color-on-surface-variant)"> 561 - {importProgress.processedItems} / {importProgress.totalItems} items 562 - ({importPercent}%) 570 + {importProgress.processedItems} /{" "} 571 + {importProgress.totalItems} items ({importPercent}%) 563 572 </p> 564 573 <p className="md-body-small text-(--md-sys-color-on-surface-variant)"> 565 - Batch {importProgress.currentBatch} / {importProgress.totalBatches} 566 - · Imported {importProgress.imported} · Skipped{" "} 574 + Batch {importProgress.currentBatch} /{" "} 575 + {importProgress.totalBatches}· Imported{" "} 576 + {importProgress.imported} · Skipped{" "} 567 577 {importProgress.skipped} · Failed {importProgress.failed} 568 578 </p> 569 579 </> ··· 635 645 disabled={isImportBusy} 636 646 /> 637 647 <p className="md-body-small text-(--md-sys-color-on-surface-variant)"> 638 - Upload a Trakt history CSV export using the standard Trakt columns. 648 + Upload a Trakt history CSV export using the standard Trakt 649 + columns. 639 650 </p> 640 651 </div> 641 652 )} 642 653 643 654 <div className="flex gap-3"> 644 - <M3Button variant="text" onClick={() => setStep(2)} disabled={isImportBusy}> 655 + <M3Button 656 + variant="text" 657 + onClick={() => setStep(2)} 658 + disabled={isImportBusy} 659 + > 645 660 Back 646 661 </M3Button> 647 662 <M3Button ··· 659 674 <div className="space-y-4"> 660 675 <h2 className="md-title-large">You&apos;re all set</h2> 661 676 <p className="md-body-medium text-(--md-sys-color-on-surface-variant)"> 662 - Imported: {importResult.imported} | Skipped: {importResult.skipped} | 663 - Failed: {importResult.failed} 677 + Imported: {importResult.imported} | Skipped:{" "} 678 + {importResult.skipped} | Failed: {importResult.failed} 664 679 </p> 665 680 {importResult.errors.length > 0 && ( 666 681 <div className="rounded-(--md-sys-shape-corner-medium) border p-3 max-h-56 overflow-auto"> 667 - <p className="md-label-large mb-2">Errors</p> 668 - <ul className="space-y-1"> 669 - {importResult.errors.map((error) => ( 670 - <li key={error} className="md-body-small"> 671 - {error} 672 - </li> 682 + <p className="md-label-large mb-2">Errors</p> 683 + <ul className="space-y-1"> 684 + {importResult.errors.map((error) => ( 685 + <li key={error} className="md-body-small"> 686 + {error} 687 + </li> 673 688 ))} 674 689 </ul> 675 690 </div> ··· 822 837 return { 823 838 error: { 824 839 row: rowNumber, 825 - message: `Row ${rowNumber}: unsupported action \"${actionRaw || "unknown"}\"`, 840 + message: `Row ${rowNumber}: unsupported action "${actionRaw || "unknown"}"`, 826 841 }, 827 842 }; 828 843 } 829 844 830 845 if (!watchedAt) { 831 - return { error: { row: rowNumber, message: `Row ${rowNumber}: invalid watched_at` } }; 846 + return { 847 + error: { 848 + row: rowNumber, 849 + message: `Row ${rowNumber}: invalid watched_at`, 850 + }, 851 + }; 832 852 } 833 853 834 854 if (type === "movie") { 835 855 const movieTmdbId = Number.parseInt(getCsvValue(row, "tmdb_id"), 10); 836 856 if (!Number.isInteger(movieTmdbId) || movieTmdbId < 1) { 837 857 return { 838 - error: { row: rowNumber, message: `Row ${rowNumber}: missing movie TMDB id` }, 858 + error: { 859 + row: rowNumber, 860 + message: `Row ${rowNumber}: missing movie TMDB id`, 861 + }, 839 862 }; 840 863 } 841 864 ··· 859 882 860 883 if (!Number.isInteger(showTmdbId) || showTmdbId < 1) { 861 884 return { 862 - error: { row: rowNumber, message: `Row ${rowNumber}: missing show TMDB id` }, 885 + error: { 886 + row: rowNumber, 887 + message: `Row ${rowNumber}: missing show TMDB id`, 888 + }, 863 889 }; 864 890 } 865 891 ··· 892 918 return { 893 919 error: { 894 920 row: rowNumber, 895 - message: `Row ${rowNumber}: unsupported type \"${type || "unknown"}\"`, 921 + message: `Row ${rowNumber}: unsupported type "${type || "unknown"}"`, 896 922 }, 897 923 }; 898 924 }
+2 -2
apps/web/src/routes/profile.settings.tsx
··· 21 21 import { toast } from "sonner"; 22 22 import { useTheme } from "@/components/theme-provider"; 23 23 import { UnauthenticatedState } from "@/components/UnauthenticatedState"; 24 - import { TIMEZONE_GROUPS } from "@/lib/timezones"; 25 24 import { 26 25 Dialog, 27 26 DialogContent, ··· 49 48 } from "@/components/ui/select"; 50 49 import { Skeleton } from "@/components/ui/skeleton"; 51 50 import { Switch } from "@/components/ui/switch"; 51 + import { TIMEZONE_GROUPS } from "@/lib/timezones"; 52 52 53 53 export const Route = createFileRoute("/profile/settings")({ 54 54 head: () => ({ ··· 226 226 <SelectValue placeholder="Select timezone" /> 227 227 </SelectTrigger> 228 228 <SelectContent className="bg-[var(--md-sys-color-surface-container)] border-[var(--md-sys-color-outline)] max-h-80"> 229 - {TIMEZONE_GROUPS.map((group) => ( 229 + {TIMEZONE_GROUPS.map((group) => ( 230 230 <div key={group.region}> 231 231 <div className="px-2 py-1.5 text-xs font-semibold text-[var(--md-sys-color-on-surface-variant)]"> 232 232 {group.region}
-2
backend/src/ingester/ingester.service.ts
··· 183 183 const dids = users.map((u) => u.did); 184 184 185 185 // Register each user individually to handle partial failures 186 - let successCount = 0; 187 186 for (const did of dids) { 188 187 try { 189 188 await this.addRepo(did); 190 - successCount++; 191 189 } catch (err) { 192 190 this.logger.error(`Failed to register repo ${did} with TAP`, err); 193 191 // Continue with next user even if one fails
+8 -2
backend/src/users/dto/import-history.dto.ts
··· 94 94 "duplicate_in_request", 95 95 ], 96 96 }) 97 - code: "invalid_item" | "already_exists" | "write_failed" | "duplicate_in_request"; 97 + code: 98 + | "invalid_item" 99 + | "already_exists" 100 + | "write_failed" 101 + | "duplicate_in_request"; 98 102 99 103 @ApiProperty() 100 104 message: string; ··· 124 128 @ApiProperty({ type: [ImportSkipDto] }) 125 129 skipped: ImportSkipDto[]; 126 130 127 - @ApiProperty({ description: "Count of rows returned by Trakt before filtering" }) 131 + @ApiProperty({ 132 + description: "Count of rows returned by Trakt before filtering", 133 + }) 128 134 sourceCount: number; 129 135 } 130 136
+4 -1
backend/src/users/dto/user-settings.dto.ts
··· 56 56 } 57 57 58 58 export class UserProfileDto { 59 - @ApiProperty({ description: "Display name shown in OpnShelf", nullable: true }) 59 + @ApiProperty({ 60 + description: "Display name shown in OpnShelf", 61 + nullable: true, 62 + }) 60 63 displayName!: string | null; 61 64 62 65 @ApiProperty({
+4 -1
backend/src/users/users.controller.spec.ts
··· 62 62 await expect( 63 63 controller.fetchMyTraktPublicHistory({ username: "alice", maxItems: 10 }), 64 64 ).resolves.toEqual({ items: [], skipped: [], sourceCount: 0 }); 65 - expect(usersService.fetchTraktPublicHistory).toHaveBeenCalledWith("alice", 10); 65 + expect(usersService.fetchTraktPublicHistory).toHaveBeenCalledWith( 66 + "alice", 67 + 10, 68 + ); 66 69 }); 67 70 68 71 it("updates profile for authenticated requests", async () => {
+3 -1
backend/src/users/users.controller.ts
··· 140 140 141 141 @Post("me/import/trakt/public/fetch") 142 142 @UseGuards(AuthGuard) 143 - @ApiOperation({ summary: "Fetch normalized history from a public Trakt profile" }) 143 + @ApiOperation({ 144 + summary: "Fetch normalized history from a public Trakt profile", 145 + }) 144 146 @ApiResponse({ status: 200, type: FetchTraktPublicHistoryResponseDto }) 145 147 @ApiResponse({ status: 401, description: "Not authenticated" }) 146 148 async fetchMyTraktPublicHistory(
+15 -4
backend/src/users/users.service.spec.ts
··· 40 40 41 41 beforeEach(() => { 42 42 jest.clearAllMocks(); 43 - service = new UsersService(prisma, moviesService, showsService, configService); 43 + service = new UsersService( 44 + prisma, 45 + moviesService, 46 + showsService, 47 + configService, 48 + ); 44 49 }); 45 50 46 51 it("completes onboarding for an existing user", async () => { 47 - prisma.user.findUnique = jest.fn().mockResolvedValue({ did: "did:plc:123" }); 52 + prisma.user.findUnique = jest 53 + .fn() 54 + .mockResolvedValue({ did: "did:plc:123" }); 48 55 prisma.user.update = jest.fn().mockResolvedValue({ 49 56 onboardingCompletedAt: new Date("2026-03-03T12:00:00.000Z"), 50 57 }); ··· 64 71 }); 65 72 66 73 it("updates user profile display name", async () => { 67 - prisma.user.findUnique = jest.fn().mockResolvedValue({ did: "did:plc:123" }); 74 + prisma.user.findUnique = jest 75 + .fn() 76 + .mockResolvedValue({ did: "did:plc:123" }); 68 77 prisma.user.update = jest.fn().mockResolvedValue({ 69 78 displayName: "Updated User", 70 79 avatar: "https://example.com/avatar.jpg", ··· 169 178 170 179 it("continues when a write fails", async () => { 171 180 prisma.trackedMovie.findFirst = jest.fn().mockResolvedValue(null); 172 - moviesService.markWatched = jest.fn().mockRejectedValue(new Error("write failed")); 181 + moviesService.markWatched = jest 182 + .fn() 183 + .mockRejectedValue(new Error("write failed")); 173 184 174 185 const result = await service.importNormalizedItems( 175 186 "did:plc:abc",
+32 -9
backend/src/users/users.service.ts
··· 140 140 }; 141 141 } 142 142 143 - async completeOnboarding(did: string): Promise<CompleteOnboardingResponseDto> { 143 + async completeOnboarding( 144 + did: string, 145 + ): Promise<CompleteOnboardingResponseDto> { 144 146 const user = await this.prisma.user.findUnique({ where: { did } }); 145 147 if (!user) { 146 148 throw new NotFoundException("User not found"); ··· 158 160 159 161 return { 160 162 onboardingCompletedAt: 161 - updated.onboardingCompletedAt?.toISOString() ?? new Date().toISOString(), 163 + updated.onboardingCompletedAt?.toISOString() ?? 164 + new Date().toISOString(), 162 165 needsOnboarding: false, 163 166 }; 164 167 } ··· 179 182 } 180 183 181 184 const safeMaxItems = 182 - typeof maxItems === "number" ? Math.max(Math.floor(maxItems), 1) : Number.POSITIVE_INFINITY; 185 + typeof maxItems === "number" 186 + ? Math.max(Math.floor(maxItems), 1) 187 + : Number.POSITIVE_INFINITY; 183 188 const pageSize = 100; 184 189 let page = 1; 185 190 let sourceCount = 0; ··· 236 241 if (items.length >= safeMaxItems) { 237 242 break; 238 243 } 239 - const result = this.normalizeTraktApiItem(payload[i], sourceCount - payload.length + i + 1); 244 + const result = this.normalizeTraktApiItem( 245 + payload[i], 246 + sourceCount - payload.length + i + 1, 247 + ); 240 248 if (result.item) { 241 249 items.push(result.item); 242 250 } else if (result.skip) { ··· 264 272 items: NormalizedImportItemDto[], 265 273 ): Promise<ImportHistoryResponseDto> { 266 274 if (items.length > 100) { 267 - throw new BadRequestException("A maximum of 100 items can be imported per request"); 275 + throw new BadRequestException( 276 + "A maximum of 100 items can be imported per request", 277 + ); 268 278 } 269 279 270 280 let imported = 0; ··· 347 357 failed += 1; 348 358 const itemContext = this.describeImportItem(item); 349 359 const rawMessage = 350 - error instanceof Error ? error.message : "Failed to import watch item"; 360 + error instanceof Error 361 + ? error.message 362 + : "Failed to import watch item"; 351 363 this.logger.warn( 352 364 `Failed to import item at index ${index + 1}: ${error instanceof Error ? error.message : String(error)}`, 353 365 ); ··· 404 416 405 417 const normalizedAction = action as "watch" | "scrobble" | "checkin"; 406 418 407 - if (typeof item.watched_at !== "string" || Number.isNaN(Date.parse(item.watched_at))) { 419 + if ( 420 + typeof item.watched_at !== "string" || 421 + Number.isNaN(Date.parse(item.watched_at)) 422 + ) { 408 423 return { 409 424 skip: { 410 425 index, ··· 418 433 419 434 if (item.type === "movie") { 420 435 const tmdbId = item.movie?.ids?.tmdb; 421 - if (typeof tmdbId !== "number" || !Number.isInteger(tmdbId) || tmdbId < 1) { 436 + if ( 437 + typeof tmdbId !== "number" || 438 + !Number.isInteger(tmdbId) || 439 + tmdbId < 1 440 + ) { 422 441 return { 423 442 skip: { 424 443 index, ··· 443 462 const seasonNumber = item.episode?.season; 444 463 const episodeNumber = item.episode?.number; 445 464 446 - if (typeof tmdbId !== "number" || !Number.isInteger(tmdbId) || tmdbId < 1) { 465 + if ( 466 + typeof tmdbId !== "number" || 467 + !Number.isInteger(tmdbId) || 468 + tmdbId < 1 469 + ) { 447 470 return { 448 471 skip: { 449 472 index,