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(people): enhance person detail pages with merged cast/crew credits and improved UI

- Merge cast and crew credits in filmography when person has multiple roles
- Add biography truncation with 'Show more/less' toggle
- Display dynamic backdrop images from filmography on person pages
- Separate directors from other crew in crew sections
- Add metaText support to display role information on poster cards
- Update README with clearer feature list and improved structure
- Add backdrop_path field to filmography items
- Update API generated types for new person DTOs

+592 -300
+65 -180
README.md
··· 1 1 # OpnShelf 2 2 3 - A personal media tracker built on the AT Protocol. Track movies you've watched and discover what others are watching - all while owning your data. 3 + Track what you watch and discover what others are watching. A personal media tracker built on the AT Protocol that keeps you in control of your data. 4 + 5 + ## Features 6 + 7 + - **Track Movies & TV Shows** - Log movies and episodes you've watched with timestamps 8 + - **Custom Lists** - Create and manage lists (Want to Watch, Favorites, Custom collections) 9 + - **Social Discovery** - Follow friends and see what they're watching 10 + - **Release Calendar** - Track upcoming releases and never miss a premiere 11 + - **Trakt Import** - Import your watch history from Trakt.tv 12 + - **AT Protocol OAuth** - Secure authentication with Bluesky/AT Protocol accounts 13 + - **Cross-Platform** - Web app and mobile apps (iOS/Android) with synced data 4 14 5 15 ## Tech Stack 6 16 7 - - **Backend**: NestJS + Prisma + PostgreSQL + AT Protocol (TAP/Firehose) 8 - - **Web**: React + TanStack Start (SSR) + TanStack Router + Vite + Tailwind CSS 9 - - **Mobile**: Expo / React Native + Tailwind CSS 10 - - **Protocol**: AT Protocol (decentralized data storage with OAuth authentication) 17 + - **Backend**: NestJS + Prisma + PostgreSQL + AT Protocol (TAP) 18 + - **Web**: React + TanStack Start (SSR) + TanStack Router + Vite + Tailwind CSS v4 19 + - **Mobile**: Expo + React Native + React Native Paper 20 + - **Protocol**: AT Protocol (decentralized storage via `xyz.opnshelf.*` lexicons) 21 + - **Design**: Material You (dynamic theming based on poster colors) 22 + - **Analytics**: PostHog 11 23 - **Monorepo**: pnpm workspaces + Turbo 12 24 13 25 ## Project Structure ··· 17 29 ├── apps/ 18 30 │ ├── web/ # TanStack Start web app (port 3000) 19 31 │ └── mobile/ # Expo mobile app 32 + ├── backend/ # NestJS API (port 3001) 20 33 ├── packages/ 21 - │ ├── api/ # Shared API client (OpenAPI generated types + TanStack Query hooks) 22 - │ └── types/ # Shared TypeScript types 23 - └── backend/ # NestJS API + Firehose indexer (port 3001) 34 + │ └── api/ # Shared API client (OpenAPI generated) 35 + └── lexicons/ # AT Protocol record definitions 24 36 ``` 25 37 26 - ## Getting Started 38 + ## Quick Start 27 39 28 40 ### Prerequisites 29 41 30 42 - Node.js 18+ 31 43 - pnpm 10+ 32 - - Docker & Docker Compose (for local TAP development) 44 + - Docker (for local TAP) 33 45 34 46 ### Setup 35 47 36 - 1. Clone and install dependencies: 37 48 ```bash 49 + # Install dependencies 38 50 pnpm install 39 - ``` 40 51 41 - 2. Start the local database and TAP: 42 - ```bash 52 + # Start PostgreSQL and TAP 43 53 docker-compose up -d 44 - ``` 45 54 46 - 3. Configure environment variables: 47 - ```bash 48 - # backend/.env 55 + # Configure backend/.env 49 56 DATABASE_URL="postgresql://opnshelf:opnshelf@127.0.0.1:5432/opnshelf" 50 - TMDB_API_KEY="your-tmdb-api-key" 51 - BACKEND_PUBLIC_URL="http://127.0.0.1:3001" 52 - FRONTEND_URL="http://127.0.0.1:3000" 53 - PORT=3001 54 - 55 - # Local TAP (development) 57 + TMDB_API_KEY="your-tmdb-key" 56 58 TAP_URL="http://localhost:2480" 57 - TAP_ADMIN_PASSWORD="y29d6b572f17af0f150cd4b480bec85cf" 58 - ``` 59 59 60 - 4. Run database migrations: 61 - ```bash 60 + # Run migrations 62 61 pnpm prisma:migrate 63 - ``` 64 62 65 - 5. Generate the Prisma client: 66 - ```bash 67 - pnpm prisma:generate 68 - ``` 69 - 70 - 6. Start development servers: 71 - ```bash 72 - # All services 63 + # Start all services 73 64 pnpm dev 74 - 75 - # Or individually 76 - pnpm dev:backend # Backend API (port 3001) 77 - pnpm dev:web # Web app (port 3000) 78 - pnpm dev:mobile # Mobile app (Expo) 79 65 ``` 80 66 81 - ### Generate API Types 67 + ## AT Protocol Lexicons 82 68 83 - After backend changes, regenerate the shared API client: 84 - ```bash 85 - pnpm generate:api 86 - ``` 69 + User data is stored as AT Protocol records in their personal repository: 87 70 88 - This generates TypeScript types and TanStack Query hooks from the OpenAPI spec. 71 + - `xyz.opnshelf.movie` - Tracked movies 72 + - `xyz.opnshelf.episode` - Tracked TV episodes 73 + - `xyz.opnshelf.list` - Custom lists 74 + - `xyz.opnshelf.listItem` - Items in lists 75 + - `xyz.opnshelf.follow` - Social follows 76 + - `xyz.opnshelf.profile` - User profiles 89 77 90 - ## Local TAP Development 91 - 92 - TAP (The AT Protocol ingestion service) syncs user movie records from the AT Protocol network. For local development, you can run your own TAP instance using Docker Compose. 93 - 94 - ### Start TAP Locally 95 - 96 - ```bash 97 - # Start TAP and PostgreSQL 98 - docker-compose up -d 99 - 100 - # Check TAP is running 101 - curl http://localhost:2480/health 102 - ``` 103 - 104 - This will start: 105 - - **TAP** on port 2480 (WebSocket for AT Protocol events) 106 - - **PostgreSQL** on port 5432 (for your app data) 107 - 108 - ### Environment Configuration 109 - 110 - The backend `.env` is already configured to use the local TAP instance: 111 - 112 - ```bash 113 - TAP_URL="http://localhost:2480" 114 - TAP_ADMIN_PASSWORD="y29d6b572f17af0f150cd4b480bec85cf" 115 - ``` 116 - 117 - ### Switching Between Environments 118 - 119 - Comment/uncomment the appropriate lines in `backend/.env` to switch between: 120 - - **Local development**: `http://localhost:2480` (isolated, no interference with production) 121 - - **Production**: Your deployed TAP instance 122 - 123 - ### Stop TAP 124 - 125 - ```bash 126 - docker-compose down 127 - 128 - # To also remove data volumes: 129 - docker-compose down -v 130 - ``` 78 + The backend subscribes to the AT Protocol firehose via TAP to index public records for social discovery. 131 79 132 80 ## Development Commands 133 - 134 - ### Web App (apps/web) 135 - 136 - ```bash 137 - cd apps/web 138 - pnpm dev # Start dev server 139 - pnpm build # Production build 140 - pnpm test # Run Vitest tests 141 - pnpm lint # Run Biome linter 142 - pnpm format # Format code with Biome 143 - pnpm check # Run both lint and format checks 144 - ``` 145 - 146 - ### Backend 147 - 148 - ```bash 149 - cd backend 150 - pnpm dev # Start dev server with watch 151 - pnpm build # Production build 152 - pnpm test # Run Jest tests 153 - pnpm test:watch # Watch mode 154 - pnpm test:cov # Coverage report 155 - pnpm lint # ESLint + Prettier 156 - pnpm format # Format with Prettier 157 - pnpm lex:build # Build AT Protocol lexicons 158 - ``` 159 - 160 - ### Mobile App 161 81 162 82 ```bash 163 - cd apps/mobile 164 - pnpm start # Start Expo development server 165 - pnpm android # Run on Android 166 - pnpm ios # Run on iOS 167 - pnpm typecheck # TypeScript check 168 - ``` 169 - 170 - ## Workspace Commands 171 - 172 - Run commands in specific packages: 173 - 174 - ```bash 175 - pnpm --filter web <command> 176 - pnpm --filter backend <command> 177 - pnpm --filter mobile <command> 178 - pnpm --filter @opnshelf/api <command> 179 - ``` 180 - 181 - ## Features 182 - 183 - - **Movie Search**: Search movies via TMDB API 184 - - **Track Watched Movies**: Store watched movies as AT Protocol records in your personal data repository 185 - - **Browse Trending/Popular**: Discover movies without logging in 186 - - **AT Protocol OAuth**: Secure authentication with Bluesky/AT Protocol accounts 187 - - **Social Discovery**: Browse what others are watching (public records indexed via Firehose) 188 - - **Dark Mode**: Material You inspired design system 189 - 190 - ## Architecture 191 - 192 - Users track movies which are stored as AT Protocol records in their personal data repository (`xyz.opnshelf.movie` lexicon). The backend subscribes to the AT Protocol firehose via TAP to index public records, enabling discovery and social features while users maintain ownership of their data. 83 + # Individual services 84 + pnpm dev:backend # Backend API (port 3001) 85 + pnpm dev:web # Web app (port 3000) 86 + pnpm dev:mobile # Mobile app (Expo) 193 87 194 - ### Database Schema 88 + # Code quality 89 + pnpm check # Lint + format all packages 90 + pnpm check:write # Auto-fix issues 195 91 196 - - **User**: AT Protocol users (DID, handle, profile info) 197 - - **Movie**: Movie metadata from TMDB (with extracted poster colors) 198 - - **TrackedMovie**: Links users to movies they track (with AT Protocol record URI/CID) 199 - - **AuthSession/AuthState**: OAuth session storage for AT Protocol authentication 200 - 201 - ## Testing 202 - 203 - ### Backend Tests 204 - 205 - ```bash 206 - cd backend 207 - pnpm test # Run all tests 208 - pnpm test -- auth.service.spec.ts # Single test file 209 - pnpm test -- --testNamePattern="should create" # Pattern matching 92 + # Database 93 + pnpm prisma:migrate 94 + pnpm prisma:generate 95 + pnpm generate:api # Regenerate API client from OpenAPI 210 96 ``` 211 97 212 - ### Web Tests 98 + ## Environment Variables 213 99 214 - ```bash 215 - cd apps/web 216 - pnpm test # Run Vitest 217 - pnpm test -- src/routes/index.test.tsx # Single file 218 - ``` 100 + ### Backend (`backend/.env`) 219 101 220 - ## Lint & Type Check 221 - 222 - Before committing, ensure code quality: 223 - 224 - ```bash 225 - # Web 226 - cd apps/web && pnpm check && pnpm tsc --noEmit 102 + | Variable | Description | 103 + |----------|-------------| 104 + | `DATABASE_URL` | PostgreSQL connection string | 105 + | `TMDB_API_KEY` | TMDB API key for movie data | 106 + | `TRAKT_API_KEY` | Trakt.tv API key for imports | 107 + | `TAP_URL` | TAP ingestion service URL | 108 + | `PDS_URL` | Personal Data Server (e.g., `https://opnshelf.social`) | 109 + | `BACKEND_PUBLIC_URL` | Public URL for OAuth callbacks | 110 + | `FRONTEND_URL` | Frontend URL for redirects | 227 111 228 - # Backend 229 - cd backend && pnpm check && pnpm tsc --noEmit 112 + ### Web (`apps/web/.env`) 230 113 231 - # Mobile 232 - cd apps/mobile && pnpm check && pnpm tsc --noEmit 233 - ``` 114 + | Variable | Description | 115 + |----------|-------------| 116 + | `VITE_API_URL` | Backend API URL | 117 + | `VITE_PUBLIC_POSTHOG_KEY` | PostHog analytics key | 118 + | `VITE_PUBLIC_POSTHOG_HOST` | PostHog host URL | 234 119 235 120 ## License 236 121
+97 -10
apps/mobile/app/person/[id].tsx
··· 46 46 import { invalidateUserShelfQueries } from "@/lib/invalidate-shelf"; 47 47 48 48 const POSTER_BASE_URL = "https://image.tmdb.org/t/p/w92"; 49 + const BACKDROP_BASE_URL = "https://image.tmdb.org/t/p/w780"; 49 50 const SCREEN_WIDTH = Dimensions.get("window").width; 50 51 const GAP = spacing.md; 51 52 const H_PADDING = spacing.lg; 52 53 const COLUMNS = 2; 53 54 const ITEM_MARGIN = GAP / 2; 54 55 const ITEM_WIDTH = (SCREEN_WIDTH - H_PADDING * 2) / COLUMNS - ITEM_MARGIN * 2; 56 + const BIO_TRUNCATE_LENGTH = 300; 55 57 56 58 function formatDate(dateString?: string): string | null { 57 59 if (!dateString) return null; ··· 72 74 return `${birthYear} - Present`; 73 75 } 74 76 77 + // Truncate biography text at word boundary 78 + function truncateBiography(text: string, maxLength: number): string { 79 + if (text.length <= maxLength) return text; 80 + 81 + // Find the last space before maxLength 82 + const truncated = text.slice(0, maxLength); 83 + const lastSpace = truncated.lastIndexOf(" "); 84 + 85 + // If no space found, just truncate at maxLength 86 + if (lastSpace === -1) return `${truncated}...`; 87 + 88 + return `${truncated.slice(0, lastSpace)}...`; 89 + } 90 + 91 + // Format roles array for display 92 + // Cast role first (with "as Character"), then crew jobs alphabetically 93 + function formatRoles(item: PersonFilmographyItemDto): string | undefined { 94 + if (!item.roles || item.roles.length === 0) { 95 + // Fallback to legacy fields 96 + if (item.character) return `as ${item.character}`; 97 + if (item.job) return item.job; 98 + return undefined; 99 + } 100 + 101 + const roleStrings = item.roles 102 + .map((role) => { 103 + if (role.type === "cast" && role.character) { 104 + return `as ${role.character}`; 105 + } 106 + if (role.type === "crew" && role.job) { 107 + return role.job; 108 + } 109 + return undefined; 110 + }) 111 + .filter((r): r is string => !!r); 112 + 113 + if (roleStrings.length === 0) return undefined; 114 + return roleStrings.join(" • "); 115 + } 116 + 75 117 // Convert filmography item to MovieItem format 76 118 function toMovieItem(item: PersonFilmographyItemDto): { 77 119 id: number; ··· 103 145 const { colors } = useTheme(); 104 146 const { showCompactHeader, onScroll } = useScrollRevealHeader(); 105 147 const queryClient = useQueryClient(); 148 + const [isBioExpanded, setIsBioExpanded] = useState(false); 106 149 107 150 const { data: user } = useQuery({ 108 151 ...authControllerMeOptions(), ··· 156 199 ? `${POSTER_BASE_URL}${person.profile_path}` 157 200 : null; 158 201 202 + // Biography truncation logic 203 + const biography = person?.biography || ""; 204 + const shouldTruncate = biography.length > BIO_TRUNCATE_LENGTH; 205 + const displayedBiography = useMemo(() => { 206 + if (!shouldTruncate || isBioExpanded) return biography; 207 + return truncateBiography(biography, BIO_TRUNCATE_LENGTH); 208 + }, [biography, shouldTruncate, isBioExpanded]); 209 + 159 210 const subtitle = useMemo(() => { 160 211 const lifespan = formatLifespan(person?.birthday, person?.deathday); 161 212 if (person?.known_for_department && lifespan) { ··· 179 230 return filmographyData?.pages.flatMap((page) => page.items) ?? []; 180 231 }, [filmographyData]); 181 232 233 + // Get backdrop from first filmography item with a backdrop 234 + const backdropUrl = useMemo(() => { 235 + const itemWithBackdrop = filmographyItems.find( 236 + (item) => item.backdrop_path, 237 + ); 238 + return itemWithBackdrop?.backdrop_path 239 + ? `${BACKDROP_BASE_URL}${itemWithBackdrop.backdrop_path}` 240 + : null; 241 + }, [filmographyItems]); 242 + 182 243 const totalFilmographyCount = filmographyData?.pages[0]?.total ?? 0; 183 244 184 245 // Create lookup sets for watched items ··· 337 398 : unmarkShowMutation.isPending && 338 399 unmarkShowMutation.variables?.path?.showId === mediaId; 339 400 340 - const metaText = item.character || item.job || undefined; 401 + const metaText = formatRoles(item); 341 402 342 403 if (isMovie) { 343 404 return ( ··· 348 409 isUnmarking={user ? isUnmarking : undefined} 349 410 onToggle={user ? () => handleToggleWatched(item) : undefined} 350 411 onPress={() => handleNavigateToMedia(item)} 412 + metaText={metaText} 351 413 width={ITEM_WIDTH} 352 414 /> 353 415 ); ··· 396 458 <DetailHero 397 459 title={person?.name || "Person"} 398 460 subtitle={subtitle} 399 - backdropUrl={null} 461 + backdropUrl={backdropUrl} 400 462 posterUrl={profileUrl} 401 463 colors={{ 402 464 primary: colors.primary, ··· 538 600 > 539 601 Biography 540 602 </Text> 541 - <Text 542 - style={[ 543 - styles.biography, 544 - { color: colors.onSurfaceVariant }, 545 - ]} 546 - > 547 - {person.biography} 548 - </Text> 603 + <View style={styles.biographyRow}> 604 + <Text 605 + style={[ 606 + styles.biography, 607 + { color: colors.onSurfaceVariant }, 608 + ]} 609 + > 610 + {displayedBiography} 611 + {shouldTruncate && ( 612 + <Text 613 + onPress={() => setIsBioExpanded(!isBioExpanded)} 614 + style={[ 615 + styles.bioToggleTextInline, 616 + { color: colors.primary }, 617 + ]} 618 + > 619 + {" "} 620 + {isBioExpanded ? "Show less" : "Show more"} 621 + </Text> 622 + )} 623 + </Text> 624 + </View> 549 625 </View> 550 626 )} 551 627 ··· 674 750 biography: { 675 751 fontSize: 15, 676 752 lineHeight: 22, 753 + flex: 1, 754 + }, 755 + biographyRow: { 756 + flexDirection: "row", 757 + flexWrap: "wrap", 758 + alignItems: "flex-start", 759 + }, 760 + bioToggleTextInline: { 761 + fontSize: 15, 762 + lineHeight: 22, 763 + fontWeight: "500", 677 764 }, 678 765 loadMoreContainer: { 679 766 padding: spacing.md,
+22 -8
apps/mobile/components/MovieItem.tsx
··· 30 30 isUnmarking?: boolean; 31 31 onToggle?: (movieId: string, isWatched: boolean) => void; 32 32 onPress: () => void; 33 + metaText?: string; 33 34 width?: number; 34 35 } 35 36 ··· 62 63 isUnmarking = false, 63 64 onToggle, 64 65 onPress, 66 + metaText, 65 67 width, 66 68 }: MovieItemProps) { 67 69 const { colors } = useTheme(); ··· 175 177 yearBadge: { 176 178 marginTop: spacing.xs, 177 179 }, 178 - movieYear: { 179 - fontSize: 12, 180 - color: colors.onSurfaceVariant, 181 - fontWeight: "500", 182 - letterSpacing: 0.5, 183 - }, 184 - }), 180 + movieYear: { 181 + fontSize: 12, 182 + color: colors.onSurfaceVariant, 183 + fontWeight: "500", 184 + letterSpacing: 0.5, 185 + }, 186 + metaText: { 187 + fontSize: 12, 188 + color: colors.onSurfaceVariant, 189 + fontWeight: "500", 190 + letterSpacing: 0.5, 191 + marginTop: spacing.xs, 192 + }, 193 + }), 185 194 [ 186 195 width, 187 196 colors.surfaceContainer, ··· 238 247 <Text style={styles.movieTitle} numberOfLines={2}> 239 248 {movie.title} 240 249 </Text> 241 - {movie.release_date && ( 250 + {metaText ? ( 251 + <Text style={styles.metaText} numberOfLines={2}> 252 + {metaText} 253 + </Text> 254 + ) : null} 255 + {!metaText && movie.release_date && ( 242 256 <View style={styles.yearBadge}> 243 257 <Text style={styles.movieYear}> 244 258 {movie.release_date.split("-")[0]}
+46 -19
apps/mobile/components/detail/sections/CrewSection.tsx
··· 41 41 }); 42 42 }; 43 43 44 + // Separate directors from other crew 45 + const directors = crew.filter((person) => person.job === "Director"); 46 + const otherCrew = crew.filter((person) => person.job !== "Director"); 47 + 48 + const renderCrewCard = (person: CrewMember) => ( 49 + <TouchableOpacity 50 + key={`${person.id}-${person.job || "crew"}`} 51 + style={[styles.crewCard, { backgroundColor: colors.surfaceContainer }]} 52 + activeOpacity={0.8} 53 + onPress={() => handlePress(person)} 54 + > 55 + <Text style={[styles.crewName, { color: colors.onSurface }]} numberOfLines={1}> 56 + {person.name} 57 + </Text> 58 + <Text style={[styles.crewJob, { color: colors.onSurfaceVariant }]} numberOfLines={1}> 59 + {person.job || person.department || "Crew"} 60 + </Text> 61 + </TouchableOpacity> 62 + ); 63 + 44 64 return ( 45 65 <View style={styles.section}> 46 66 <Text style={[styles.sectionTitle, { color: titleColor ?? colors.primary }]}>Crew</Text> 47 - <View style={styles.crewGrid}> 48 - {crew.map((person) => ( 49 - <TouchableOpacity 50 - key={`${person.id}-${person.job || "crew"}`} 51 - style={[ 52 - styles.crewCard, 53 - { backgroundColor: colors.surfaceContainer }, 54 - ]} 55 - activeOpacity={0.8} 56 - onPress={() => handlePress(person)} 57 - > 58 - <Text style={[styles.crewName, { color: colors.onSurface }]} numberOfLines={1}> 59 - {person.name} 60 - </Text> 61 - <Text style={[styles.crewJob, { color: colors.onSurfaceVariant }]} numberOfLines={1}> 62 - {person.job || person.department || "Crew"} 67 + {directors.length > 0 && ( 68 + <> 69 + <Text style={[styles.subSectionTitle, { color: colors.onSurfaceVariant }]}> 70 + Director{directors.length > 1 ? "s" : ""} 71 + </Text> 72 + <View style={styles.crewGrid}> 73 + {directors.map(renderCrewCard)} 74 + </View> 75 + </> 76 + )} 77 + {otherCrew.length > 0 && ( 78 + <> 79 + {directors.length > 0 && ( 80 + <Text style={[styles.subSectionTitle, { color: colors.onSurfaceVariant, marginTop: spacing.md }]}> 81 + Other Crew 63 82 </Text> 64 - </TouchableOpacity> 65 - ))} 66 - </View> 83 + )} 84 + <View style={styles.crewGrid}> 85 + {otherCrew.map(renderCrewCard)} 86 + </View> 87 + </> 88 + )} 67 89 </View> 68 90 ); 69 91 } ··· 75 97 sectionTitle: { 76 98 fontSize: 18, 77 99 fontWeight: "600", 100 + }, 101 + subSectionTitle: { 102 + fontSize: 14, 103 + fontWeight: "500", 104 + marginBottom: spacing.xs, 78 105 }, 79 106 crewGrid: { 80 107 flexDirection: "row",
+47 -15
apps/mobile/components/movie/CrewSection.tsx
··· 12 12 13 13 if (!movie?.credits?.crew || movie.credits.crew.length === 0) return null; 14 14 15 + const crew = movie.credits.crew; 16 + 17 + // Separate directors from other crew 18 + const directors = crew.filter((person: TmdbCrewDto) => person.job === "Director"); 19 + const otherCrew = crew.filter((person: TmdbCrewDto) => person.job !== "Director"); 20 + 21 + const renderCrewCard = (person: TmdbCrewDto) => ( 22 + <TouchableOpacity 23 + key={`${person.id}-${person.job}`} 24 + style={[styles.crewCard, { backgroundColor: colors.surfaceContainer }]} 25 + activeOpacity={0.8} 26 + > 27 + <Text style={[styles.crewName, { color: colors.onSurface }]} numberOfLines={1}> 28 + {person.name} 29 + </Text> 30 + <Text style={[styles.crewJob, { color: colors.onSurfaceVariant }]}> 31 + {person.job} 32 + </Text> 33 + </TouchableOpacity> 34 + ); 35 + 15 36 return ( 16 37 <View style={styles.section}> 17 38 <Text style={[styles.sectionTitle, { color: colors.primary }]}> 18 39 Crew 19 40 </Text> 20 - <View style={styles.crewGrid}> 21 - {movie.credits.crew.map((person: TmdbCrewDto) => ( 22 - <TouchableOpacity 23 - key={`${person.id}-${person.job}`} 24 - style={[styles.crewCard, { backgroundColor: colors.surfaceContainer }]} 25 - activeOpacity={0.8} 26 - > 27 - <Text style={[styles.crewName, { color: colors.onSurface }]} numberOfLines={1}> 28 - {person.name} 29 - </Text> 30 - <Text style={[styles.crewJob, { color: colors.onSurfaceVariant }]}> 31 - {person.job} 41 + {directors.length > 0 && ( 42 + <> 43 + <Text style={[styles.subSectionTitle, { color: colors.onSurfaceVariant }]}> 44 + Director{directors.length > 1 ? "s" : ""} 45 + </Text> 46 + <View style={styles.crewGrid}> 47 + {directors.map(renderCrewCard)} 48 + </View> 49 + </> 50 + )} 51 + {otherCrew.length > 0 && ( 52 + <> 53 + {directors.length > 0 && ( 54 + <Text style={[styles.subSectionTitle, { color: colors.onSurfaceVariant, marginTop: spacing.md }]}> 55 + Other Crew 32 56 </Text> 33 - </TouchableOpacity> 34 - ))} 35 - </View> 57 + )} 58 + <View style={styles.crewGrid}> 59 + {otherCrew.map(renderCrewCard)} 60 + </View> 61 + </> 62 + )} 36 63 </View> 37 64 ); 38 65 } ··· 45 72 fontSize: 18, 46 73 fontWeight: "600", 47 74 marginBottom: spacing.md, 75 + }, 76 + subSectionTitle: { 77 + fontSize: 14, 78 + fontWeight: "500", 79 + marginBottom: spacing.xs, 48 80 }, 49 81 crewGrid: { 50 82 flexDirection: "row",
+10
apps/web/src/components/MediaPosterCard.tsx
··· 10 10 posterPath?: string | null; 11 11 title: string; 12 12 subtitle?: string; 13 + metaText?: string; // Role information (e.g., "as Character • Director") 13 14 badge?: string; 14 15 15 16 to: string; ··· 37 38 posterPath, 38 39 title, 39 40 subtitle, 41 + metaText, 40 42 badge, 41 43 to, 42 44 params, ··· 161 163 style={{ color: "var(--md-sys-color-on-surface-variant)" }} 162 164 > 163 165 {subtitle} 166 + </p> 167 + )} 168 + {metaText && ( 169 + <p 170 + className="text-sm line-clamp-2" 171 + style={{ color: "var(--md-sys-color-on-surface-variant)" }} 172 + > 173 + {metaText} 164 174 </p> 165 175 )} 166 176 </Link>
+3
apps/web/src/components/up-next/UpNextShowCollection.tsx
··· 110 110 <div className={`flex items-stretch gap-4 p-4 ${cardMinHeight}`}> 111 111 <div className="w-24 shrink-0 self-center"> 112 112 <Skeleton className="aspect-2/3 w-full rounded-xl bg-(--md-sys-color-surface-container-highest)" /> 113 + <div className="mt-1.5 h-1 w-1/2 overflow-hidden rounded-full bg-(--md-sys-color-surface-container-highest)"> 114 + <div className="h-full w-full bg-(--md-sys-color-primary)/40" /> 115 + </div> 113 116 </div> 114 117 <div className="flex min-w-0 flex-1 flex-col justify-between"> 115 118 <div>
+80 -4
apps/web/src/routes/person.$personId.$name.tsx
··· 26 26 import { DetailHero } from "@/components/detail"; 27 27 import { MediaPosterCard } from "@/components/MediaPosterCard"; 28 28 import { useTheme } from "@/components/theme-provider"; 29 - import { createTitleSlug, getTmdbProfileUrl } from "@/lib/utils"; 29 + import { 30 + createTitleSlug, 31 + getTmdbBackdropUrl, 32 + getTmdbProfileUrl, 33 + } from "@/lib/utils"; 34 + 35 + const BIO_TRUNCATE_LENGTH = 300; 36 + 37 + // Truncate biography text at word boundary 38 + function truncateBiography(text: string, maxLength: number): string { 39 + if (text.length <= maxLength) return text; 40 + 41 + // Find the last space before maxLength 42 + const truncated = text.slice(0, maxLength); 43 + const lastSpace = truncated.lastIndexOf(" "); 44 + 45 + // If no space found, just truncate at maxLength 46 + if (lastSpace === -1) return `${truncated}...`; 47 + 48 + return `${truncated.slice(0, lastSpace)}...`; 49 + } 30 50 31 51 export const Route = createFileRoute("/person/$personId/$name")({ 32 52 loader: async ({ params, context }) => { ··· 87 107 }); 88 108 } 89 109 110 + // Format roles array for display 111 + // Cast role first (with "as Character"), then crew jobs alphabetically 112 + function formatRoles(item: PersonFilmographyItemDto): string | undefined { 113 + if (!item.roles || item.roles.length === 0) { 114 + // Fallback to legacy fields 115 + if (item.character) return `as ${item.character}`; 116 + if (item.job) return item.job; 117 + return undefined; 118 + } 119 + 120 + const roleStrings = item.roles 121 + .map((role) => { 122 + if (role.type === "cast" && role.character) { 123 + return `as ${role.character}`; 124 + } 125 + if (role.type === "crew" && role.job) { 126 + return role.job; 127 + } 128 + return undefined; 129 + }) 130 + .filter((r): r is string => !!r); 131 + 132 + if (roleStrings.length === 0) return undefined; 133 + return roleStrings.join(" • "); 134 + } 135 + 90 136 function formatLifespan(birthday?: string, deathday?: string): string | null { 91 137 if (!birthday) return null; 92 138 ··· 105 151 const router = useRouter(); 106 152 const { seedColor } = useTheme(); 107 153 const queryClient = useQueryClient(); 154 + const [isBioExpanded, setIsBioExpanded] = useState(false); 108 155 109 156 const { data: user } = useQuery({ 110 157 ...authControllerMeOptions(), ··· 154 201 155 202 const person = personData as TmdbPersonDetailDto | undefined; 156 203 204 + // Biography truncation logic 205 + const biography = person?.biography || ""; 206 + const shouldTruncate = biography.length > BIO_TRUNCATE_LENGTH; 207 + const displayedBiography = useMemo(() => { 208 + if (!shouldTruncate || isBioExpanded) return biography; 209 + return truncateBiography(biography, BIO_TRUNCATE_LENGTH); 210 + }, [biography, shouldTruncate, isBioExpanded]); 211 + 157 212 const colors = { 158 213 primary: seedColor, 159 214 secondary: seedColor, ··· 219 274 return filmographyData?.pages.flatMap((page) => page.items) ?? []; 220 275 }, [filmographyData]); 221 276 277 + // Get backdrop from first filmography item with a backdrop 278 + const backdropUrl = useMemo(() => { 279 + const itemWithBackdrop = filmographyItems.find( 280 + (item) => item.backdrop_path, 281 + ); 282 + return getTmdbBackdropUrl(itemWithBackdrop?.backdrop_path); 283 + }, [filmographyItems]); 284 + 222 285 const totalFilmographyCount = filmographyData?.pages[0]?.total ?? 0; 223 286 224 287 // Create lookup sets for watched items ··· 359 422 <DetailHero 360 423 title={person?.name || name.replace(/-/g, " ")} 361 424 subtitle={subtitle ?? undefined} 362 - backdropUrl={null} 425 + backdropUrl={backdropUrl} 363 426 posterUrl={profileUrl} 364 427 colors={colors} 365 428 isLoading={isPersonLoading} ··· 439 502 > 440 503 Biography 441 504 </h2> 442 - <p className="text-(--md-sys-color-on-surface-variant) leading-relaxed whitespace-pre-line"> 443 - {person.biography} 505 + <p className="text-(--md-sys-color-on-surface-variant) leading-relaxed whitespace-pre-line inline"> 506 + {displayedBiography} 507 + {shouldTruncate && ( 508 + <button 509 + type="button" 510 + onClick={() => setIsBioExpanded(!isBioExpanded)} 511 + className="text-sm font-medium hover:underline ml-1" 512 + style={{ color: colors.primary }} 513 + > 514 + {isBioExpanded ? "Show less" : "Show more"} 515 + </button> 516 + )} 444 517 </p> 445 518 </section> 446 519 )} ··· 481 554 (unmarkShowMutation.isPending && 482 555 unmarkShowMutation.variables?.path?.showId === id); 483 556 557 + const metaText = formatRoles(item); 558 + 484 559 return ( 485 560 <MediaPosterCard 486 561 key={`${item.media_type}-${item.id}`} 487 562 posterPath={item.poster_path} 488 563 title={item.title} 489 564 subtitle={year} 565 + metaText={metaText} 490 566 to={ 491 567 isMovie 492 568 ? "/movies/$movieId/$title"
+47 -4
backend/src/people/dto/person.dto.ts
··· 1 1 import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; 2 2 import { IsDateString, IsNumber, IsOptional, IsString } from "class-validator"; 3 3 4 + export class PersonFilmographyRoleDto { 5 + @ApiProperty({ description: "Type of role (cast or crew)" }) 6 + type: "cast" | "crew"; 7 + 8 + @ApiPropertyOptional({ description: "Character name for cast roles" }) 9 + character?: string; 10 + 11 + @ApiPropertyOptional({ description: "Job title for crew roles" }) 12 + job?: string; 13 + 14 + @ApiPropertyOptional({ description: "Department for crew roles" }) 15 + department?: string; 16 + 17 + @ApiPropertyOptional({ 18 + description: "Billing order for cast roles (lower is higher billing)", 19 + }) 20 + order?: number; 21 + } 22 + 4 23 export class PersonFilmographyItemDto { 5 24 @ApiProperty() 6 25 id: number; ··· 15 34 poster_path?: string; 16 35 17 36 @ApiPropertyOptional() 37 + backdrop_path?: string; 38 + 39 + @ApiPropertyOptional() 18 40 release_date?: string; 19 41 20 42 @ApiPropertyOptional() 21 43 first_air_date?: string; 22 44 23 - @ApiPropertyOptional() 45 + @ApiPropertyOptional({ 46 + description: 47 + "Legacy field: character name (use roles array for merged items)", 48 + deprecated: true, 49 + }) 24 50 character?: string; 25 51 26 - @ApiPropertyOptional() 52 + @ApiPropertyOptional({ 53 + description: "Legacy field: job title (use roles array for merged items)", 54 + deprecated: true, 55 + }) 27 56 job?: string; 28 57 29 - @ApiPropertyOptional() 58 + @ApiPropertyOptional({ 59 + description: "Legacy field: department (use roles array for merged items)", 60 + deprecated: true, 61 + }) 30 62 department?: string; 31 63 32 - @ApiPropertyOptional() 64 + @ApiPropertyOptional({ 65 + description: 66 + "Legacy field: billing order (use roles array for merged items)", 67 + deprecated: true, 68 + }) 33 69 order?: number; 34 70 35 71 @ApiPropertyOptional() 36 72 vote_average?: number; 73 + 74 + @ApiPropertyOptional({ 75 + type: [PersonFilmographyRoleDto], 76 + description: 77 + "Array of roles when person has multiple credits for the same title (e.g., actor + director)", 78 + }) 79 + roles?: PersonFilmographyRoleDto[]; 37 80 } 38 81 39 82 export class TmdbPersonDetailDto {
+126 -59
backend/src/people/people-tmdb.service.ts
··· 18 18 id: number; 19 19 title: string; 20 20 poster_path?: string; 21 + backdrop_path?: string; 21 22 release_date?: string; 22 23 vote_average?: number; 23 24 character?: string; ··· 30 31 id: number; 31 32 name: string; 32 33 poster_path?: string; 34 + backdrop_path?: string; 33 35 first_air_date?: string; 34 36 vote_average?: number; 35 37 character?: string; ··· 106 108 this.getPersonTvCredits(personId), 107 109 ]); 108 110 109 - // Transform movie credits 110 - const movieItems: PersonFilmographyItemDto[] = [ 111 - ...movieCredits.cast.map( 112 - (credit): PersonFilmographyItemDto => ({ 113 - id: credit.id, 114 - media_type: "movie", 115 - title: credit.title, 116 - poster_path: credit.poster_path, 117 - release_date: credit.release_date, 118 - character: credit.character, 119 - department: "Acting", 120 - order: credit.order, 121 - vote_average: credit.vote_average, 122 - }), 123 - ), 124 - ...movieCredits.crew.map( 125 - (credit): PersonFilmographyItemDto => ({ 126 - id: credit.id, 127 - media_type: "movie", 128 - title: credit.title, 129 - poster_path: credit.poster_path, 130 - release_date: credit.release_date, 131 - job: credit.job, 132 - department: credit.department, 133 - vote_average: credit.vote_average, 134 - }), 135 - ), 136 - ]; 111 + // Use a Map to deduplicate and merge cast/crew credits 112 + const itemsMap = new Map<string, PersonFilmographyItemDto>(); 113 + 114 + // Helper to get or create item 115 + const getOrCreateItem = ( 116 + key: string, 117 + baseItem: Omit<PersonFilmographyItemDto, "roles">, 118 + ): PersonFilmographyItemDto => { 119 + if (!itemsMap.has(key)) { 120 + itemsMap.set(key, { ...baseItem, roles: [] }); 121 + } 122 + return itemsMap.get(key)!; 123 + }; 124 + 125 + // Process movie cast credits 126 + for (const credit of movieCredits.cast) { 127 + const key = `movie-${credit.id}`; 128 + const item = getOrCreateItem(key, { 129 + id: credit.id, 130 + media_type: "movie", 131 + title: credit.title, 132 + poster_path: credit.poster_path, 133 + backdrop_path: credit.backdrop_path, 134 + release_date: credit.release_date, 135 + vote_average: credit.vote_average, 136 + }); 137 + item.roles?.push({ 138 + type: "cast", 139 + character: credit.character, 140 + order: credit.order, 141 + }); 142 + } 143 + 144 + // Process movie crew credits 145 + for (const credit of movieCredits.crew) { 146 + const key = `movie-${credit.id}`; 147 + const item = getOrCreateItem(key, { 148 + id: credit.id, 149 + media_type: "movie", 150 + title: credit.title, 151 + poster_path: credit.poster_path, 152 + backdrop_path: credit.backdrop_path, 153 + release_date: credit.release_date, 154 + vote_average: credit.vote_average, 155 + }); 156 + item.roles?.push({ 157 + type: "crew", 158 + job: credit.job, 159 + department: credit.department, 160 + }); 161 + } 162 + 163 + // Process TV cast credits 164 + for (const credit of tvCredits.cast) { 165 + const key = `tv-${credit.id}`; 166 + const item = getOrCreateItem(key, { 167 + id: credit.id, 168 + media_type: "tv", 169 + title: credit.name, 170 + poster_path: credit.poster_path, 171 + backdrop_path: credit.backdrop_path, 172 + first_air_date: credit.first_air_date, 173 + vote_average: credit.vote_average, 174 + }); 175 + item.roles?.push({ 176 + type: "cast", 177 + character: credit.character, 178 + order: credit.order, 179 + }); 180 + } 181 + 182 + // Process TV crew credits 183 + for (const credit of tvCredits.crew) { 184 + const key = `tv-${credit.id}`; 185 + const item = getOrCreateItem(key, { 186 + id: credit.id, 187 + media_type: "tv", 188 + title: credit.name, 189 + poster_path: credit.poster_path, 190 + backdrop_path: credit.backdrop_path, 191 + first_air_date: credit.first_air_date, 192 + vote_average: credit.vote_average, 193 + }); 194 + item.roles?.push({ 195 + type: "crew", 196 + job: credit.job, 197 + department: credit.department, 198 + }); 199 + } 200 + 201 + // Convert map to array and sort roles for each item 202 + const allItems = Array.from(itemsMap.values()); 137 203 138 - // Transform TV credits 139 - const tvItems: PersonFilmographyItemDto[] = [ 140 - ...tvCredits.cast.map( 141 - (credit): PersonFilmographyItemDto => ({ 142 - id: credit.id, 143 - media_type: "tv", 144 - title: credit.name, 145 - poster_path: credit.poster_path, 146 - first_air_date: credit.first_air_date, 147 - character: credit.character, 148 - department: "Acting", 149 - order: credit.order, 150 - vote_average: credit.vote_average, 151 - }), 152 - ), 153 - ...tvCredits.crew.map( 154 - (credit): PersonFilmographyItemDto => ({ 155 - id: credit.id, 156 - media_type: "tv", 157 - title: credit.name, 158 - poster_path: credit.poster_path, 159 - first_air_date: credit.first_air_date, 160 - job: credit.job, 161 - department: credit.department, 162 - vote_average: credit.vote_average, 163 - }), 164 - ), 165 - ]; 204 + for (const item of allItems) { 205 + if (item.roles && item.roles.length > 0) { 206 + // Sort roles: cast first (by order), then crew (alphabetically by job) 207 + item.roles.sort((a, b) => { 208 + // Cast roles come first 209 + if (a.type === "cast" && b.type !== "cast") return -1; 210 + if (a.type !== "cast" && b.type === "cast") return 1; 166 211 167 - // Combine all items 168 - const allItems = [...movieItems, ...tvItems]; 212 + // For cast roles, sort by order (lower order = higher billing) 213 + if (a.type === "cast" && b.type === "cast") { 214 + const orderA = a.order ?? Number.MAX_SAFE_INTEGER; 215 + const orderB = b.order ?? Number.MAX_SAFE_INTEGER; 216 + return orderA - orderB; 217 + } 169 218 170 - // Sort by release date (newest first), with unknown dates at the end 219 + // For crew roles, sort alphabetically by job 220 + const jobA = a.job ?? ""; 221 + const jobB = b.job ?? ""; 222 + return jobA.localeCompare(jobB); 223 + }); 224 + 225 + // Set legacy fields from first role for backward compatibility 226 + const firstRole = item.roles[0]; 227 + if (firstRole.type === "cast") { 228 + item.character = firstRole.character; 229 + item.order = firstRole.order; 230 + } else { 231 + item.job = firstRole.job; 232 + item.department = firstRole.department; 233 + } 234 + } 235 + } 236 + 237 + // Sort items by release date (newest first), with unknown dates at the end 171 238 allItems.sort((a, b) => { 172 239 const dateA = a.release_date || a.first_air_date || ""; 173 240 const dateB = b.release_date || b.first_air_date || "";
+1 -1
packages/api/src/generated/index.ts
··· 1 1 // This file is auto-generated by @hey-api/openapi-ts 2 2 3 3 export { authControllerBlueskyProfileStatus, authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSignup, authControllerSuggestions, listsControllerAddItemToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetPublicUserList, listsControllerGetPublicUserLists, listsControllerGetUserLists, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerGetUserMoviesPaginated, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, peopleControllerGetPersonDetails, peopleControllerGetPersonFilmography, searchControllerDiscoverAll, searchControllerSearchAll, shelfControllerGetUserActivitySummary, shelfControllerGetUserShelf, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetLocalEpisodes, showsControllerGetLocalSeasons, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserEpisodesPaginated, showsControllerGetUserReleaseCalendar, showsControllerGetUserShows, showsControllerGetUserUpNext, showsControllerMarkSeasonWatched, showsControllerMarkShowWatched, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, socialControllerFollow, socialControllerGetFeed, socialControllerGetFollowers, socialControllerGetFollowing, socialControllerGetRelationship, socialControllerGetWatchers, socialControllerSearchPeople, socialControllerUnfollow, usersControllerCompleteOnboarding, usersControllerDeleteMyAccount, usersControllerDeleteMyAvatar, usersControllerFetchMyTraktPublicHistory, usersControllerGetAvatar, usersControllerGetMyAccountDeletion, usersControllerGetMyCurrentTraktImport, usersControllerGetMySettings, usersControllerGetPublicProfile, usersControllerImportMyBlueskyFollows, usersControllerImportMyHistory, usersControllerStartMyTraktImport, usersControllerUpdateMyProfile, usersControllerUpdateMySettings, usersControllerUploadMyAvatar } from './sdk.gen'; 4 - export type { AccountDeletionJobDto, AddToListDto, AuthControllerBlueskyProfileStatusData, AuthControllerBlueskyProfileStatusErrors, AuthControllerBlueskyProfileStatusResponse, AuthControllerBlueskyProfileStatusResponses, AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponse, AuthControllerMeResponses, AuthControllerSignupData, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, BlueskyProfileStatusDto, ClientOptions, CompleteOnboardingResponseDto, CreateListDto, DeleteUserAccountDto, EpisodeContextDto, EpisodeHistoryItemDto, EpisodeReferenceDto, FetchTraktPublicHistoryDto, FetchTraktPublicHistoryResponseDto, FollowedActivityFeedDto, FollowedActivityItemDto, FollowedWatcherActorDto, FollowedWatcherDto, FollowedWatchersDto, ImportBlueskyFollowsResponseDto, ImportErrorDto, ImportHistoryDto, ImportHistoryResponseDto, ImportSkipDto, ListDto, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponse, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponse, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponse, ListsControllerGetListsForItemResponses, ListsControllerGetPublicUserListData, ListsControllerGetPublicUserListErrors, ListsControllerGetPublicUserListResponse, ListsControllerGetPublicUserListResponses, ListsControllerGetPublicUserListsData, ListsControllerGetPublicUserListsResponse, ListsControllerGetPublicUserListsResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponse, ListsControllerGetUserListsResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponse, ListsControllerUpdateListResponses, ListsForItemDto, ListSummaryDto, ListWithItemsDto, LocalEpisodeDto, LocalSeasonDto, MarkedEpisodesResponseDto, MarkEpisodeWatchedDto, MarkSeasonWatchedDto, MarkShowWatchedDto, MediaInListDto, MovieColorsDto, MovieDto, 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, PaginatedSocialUsersDto, PaginatedUpNextResponseDto, PeopleControllerGetPersonDetailsData, PeopleControllerGetPersonDetailsErrors, PeopleControllerGetPersonDetailsResponse, PeopleControllerGetPersonDetailsResponses, PeopleControllerGetPersonFilmographyData, PeopleControllerGetPersonFilmographyErrors, PeopleControllerGetPersonFilmographyResponse, PeopleControllerGetPersonFilmographyResponses, PersonFilmographyItemDto, PersonFilmographyResponseDto, PublicUserProfileDto, ReleaseCalendarItemDto, ReleaseCalendarResponseDto, SearchControllerDiscoverAllData, SearchControllerDiscoverAllResponse, SearchControllerDiscoverAllResponses, SearchControllerSearchAllData, SearchControllerSearchAllResponse, SearchControllerSearchAllResponses, SearchResultsDto, SearchShowsResultsDto, ShelfActivityBucketDto, ShelfActivitySummaryDto, ShelfControllerGetUserActivitySummaryData, ShelfControllerGetUserActivitySummaryResponse, ShelfControllerGetUserActivitySummaryResponses, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponse, ShelfControllerGetUserShelfResponses, ShelfResponseDto, ShowDto, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetLocalEpisodesData, ShowsControllerGetLocalEpisodesResponse, ShowsControllerGetLocalEpisodesResponses, ShowsControllerGetLocalSeasonsData, ShowsControllerGetLocalSeasonsResponse, ShowsControllerGetLocalSeasonsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponse, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponse, ShowsControllerGetUserEpisodesPaginatedResponses, ShowsControllerGetUserReleaseCalendarData, ShowsControllerGetUserReleaseCalendarResponse, ShowsControllerGetUserReleaseCalendarResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerGetUserShowsResponses, ShowsControllerGetUserUpNextData, ShowsControllerGetUserUpNextResponse, ShowsControllerGetUserUpNextResponses, ShowsControllerMarkSeasonWatchedData, ShowsControllerMarkSeasonWatchedErrors, ShowsControllerMarkSeasonWatchedResponse, ShowsControllerMarkSeasonWatchedResponses, ShowsControllerMarkShowWatchedData, ShowsControllerMarkShowWatchedErrors, ShowsControllerMarkShowWatchedResponse, ShowsControllerMarkShowWatchedResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponse, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, ShowsControllerUnmarkWatchedResponses, SocialActorDto, SocialControllerFollowData, SocialControllerFollowResponse, SocialControllerFollowResponses, SocialControllerGetFeedData, SocialControllerGetFeedResponse, SocialControllerGetFeedResponses, SocialControllerGetFollowersData, SocialControllerGetFollowersResponse, SocialControllerGetFollowersResponses, SocialControllerGetFollowingData, SocialControllerGetFollowingResponse, SocialControllerGetFollowingResponses, SocialControllerGetRelationshipData, SocialControllerGetRelationshipResponse, SocialControllerGetRelationshipResponses, SocialControllerGetWatchersData, SocialControllerGetWatchersResponse, SocialControllerGetWatchersResponses, SocialControllerSearchPeopleData, SocialControllerSearchPeopleResponse, SocialControllerSearchPeopleResponses, SocialControllerUnfollowData, SocialControllerUnfollowResponse, SocialControllerUnfollowResponses, SocialUserCardDto, StartTraktImportDto, StartTraktImportResponseDto, TmdbCastDto, TmdbCreditsDto, TmdbCrewDto, TmdbEpisodeDto, TmdbGenreDto, TmdbMovieDetailDto, TmdbMovieResultDto, TmdbNetworkDto, TmdbPersonDetailDto, TmdbSeasonDetailDto, TmdbSeasonSummaryDto, TmdbShowDetailDto, TmdbShowResultDto, TmdbTrailerDto, TrackedEpisodeDto, TrackedMovieDto, TrackedShowSummaryDto, TraktHistoryPreviewItemDto, TraktImportJobDto, TraktPublicProfileDto, UnifiedDiscoverResponseDto, UnifiedSearchResponseDto, UnifiedSearchResultDto, UpdateListDto, UpdateUserProfileDto, UpdateUserSettingsDto, UpNextEpisodeDto, UpNextShowDto, UserDto, UserProfileDto, UserRelationshipDto, UsersControllerCompleteOnboardingData, UsersControllerCompleteOnboardingErrors, UsersControllerCompleteOnboardingResponse, UsersControllerCompleteOnboardingResponses, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponse, UsersControllerDeleteMyAccountResponses, UsersControllerDeleteMyAvatarData, UsersControllerDeleteMyAvatarResponse, UsersControllerDeleteMyAvatarResponses, UsersControllerFetchMyTraktPublicHistoryData, UsersControllerFetchMyTraktPublicHistoryErrors, UsersControllerFetchMyTraktPublicHistoryResponse, UsersControllerFetchMyTraktPublicHistoryResponses, UsersControllerGetAvatarData, UsersControllerGetAvatarResponses, UsersControllerGetMyAccountDeletionData, UsersControllerGetMyAccountDeletionErrors, UsersControllerGetMyAccountDeletionResponse, UsersControllerGetMyAccountDeletionResponses, UsersControllerGetMyCurrentTraktImportData, UsersControllerGetMyCurrentTraktImportErrors, UsersControllerGetMyCurrentTraktImportResponse, UsersControllerGetMyCurrentTraktImportResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponse, UsersControllerGetMySettingsResponses, UsersControllerGetPublicProfileData, UsersControllerGetPublicProfileErrors, UsersControllerGetPublicProfileResponse, UsersControllerGetPublicProfileResponses, UsersControllerImportMyBlueskyFollowsData, UsersControllerImportMyBlueskyFollowsErrors, UsersControllerImportMyBlueskyFollowsResponse, UsersControllerImportMyBlueskyFollowsResponses, UsersControllerImportMyHistoryData, UsersControllerImportMyHistoryErrors, UsersControllerImportMyHistoryResponse, UsersControllerImportMyHistoryResponses, UsersControllerStartMyTraktImportData, UsersControllerStartMyTraktImportErrors, UsersControllerStartMyTraktImportResponse, UsersControllerStartMyTraktImportResponses, UsersControllerUpdateMyProfileData, UsersControllerUpdateMyProfileErrors, UsersControllerUpdateMyProfileResponse, UsersControllerUpdateMyProfileResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponse, UsersControllerUpdateMySettingsResponses, UsersControllerUploadMyAvatarData, UsersControllerUploadMyAvatarResponse, UsersControllerUploadMyAvatarResponses, UserSettingsDto, WatchHistoryItemDto } from './types.gen'; 4 + export type { AccountDeletionJobDto, AddToListDto, AuthControllerBlueskyProfileStatusData, AuthControllerBlueskyProfileStatusErrors, AuthControllerBlueskyProfileStatusResponse, AuthControllerBlueskyProfileStatusResponses, AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponse, AuthControllerMeResponses, AuthControllerSignupData, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, BlueskyProfileStatusDto, ClientOptions, CompleteOnboardingResponseDto, CreateListDto, DeleteUserAccountDto, EpisodeContextDto, EpisodeHistoryItemDto, EpisodeReferenceDto, FetchTraktPublicHistoryDto, FetchTraktPublicHistoryResponseDto, FollowedActivityFeedDto, FollowedActivityItemDto, FollowedWatcherActorDto, FollowedWatcherDto, FollowedWatchersDto, ImportBlueskyFollowsResponseDto, ImportErrorDto, ImportHistoryDto, ImportHistoryResponseDto, ImportSkipDto, ListDto, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponse, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponse, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponse, ListsControllerGetListsForItemResponses, ListsControllerGetPublicUserListData, ListsControllerGetPublicUserListErrors, ListsControllerGetPublicUserListResponse, ListsControllerGetPublicUserListResponses, ListsControllerGetPublicUserListsData, ListsControllerGetPublicUserListsResponse, ListsControllerGetPublicUserListsResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponse, ListsControllerGetUserListsResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponse, ListsControllerUpdateListResponses, ListsForItemDto, ListSummaryDto, ListWithItemsDto, LocalEpisodeDto, LocalSeasonDto, MarkedEpisodesResponseDto, MarkEpisodeWatchedDto, MarkSeasonWatchedDto, MarkShowWatchedDto, MediaInListDto, MovieColorsDto, MovieDto, 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, PaginatedSocialUsersDto, PaginatedUpNextResponseDto, PeopleControllerGetPersonDetailsData, PeopleControllerGetPersonDetailsErrors, PeopleControllerGetPersonDetailsResponse, PeopleControllerGetPersonDetailsResponses, PeopleControllerGetPersonFilmographyData, PeopleControllerGetPersonFilmographyErrors, PeopleControllerGetPersonFilmographyResponse, PeopleControllerGetPersonFilmographyResponses, PersonFilmographyItemDto, PersonFilmographyResponseDto, PersonFilmographyRoleDto, PublicUserProfileDto, ReleaseCalendarItemDto, ReleaseCalendarResponseDto, SearchControllerDiscoverAllData, SearchControllerDiscoverAllResponse, SearchControllerDiscoverAllResponses, SearchControllerSearchAllData, SearchControllerSearchAllResponse, SearchControllerSearchAllResponses, SearchResultsDto, SearchShowsResultsDto, ShelfActivityBucketDto, ShelfActivitySummaryDto, ShelfControllerGetUserActivitySummaryData, ShelfControllerGetUserActivitySummaryResponse, ShelfControllerGetUserActivitySummaryResponses, ShelfControllerGetUserShelfData, ShelfControllerGetUserShelfResponse, ShelfControllerGetUserShelfResponses, ShelfResponseDto, ShowDto, ShowsControllerDeleteEpisodeWatchHistoryEntryData, ShowsControllerDeleteEpisodeWatchHistoryEntryErrors, ShowsControllerDeleteEpisodeWatchHistoryEntryResponse, ShowsControllerDeleteEpisodeWatchHistoryEntryResponses, ShowsControllerDiscoverShowsData, ShowsControllerDiscoverShowsResponse, ShowsControllerDiscoverShowsResponses, ShowsControllerGetEpisodeDetailsData, ShowsControllerGetEpisodeDetailsResponse, ShowsControllerGetEpisodeDetailsResponses, ShowsControllerGetLocalEpisodesData, ShowsControllerGetLocalEpisodesResponse, ShowsControllerGetLocalEpisodesResponses, ShowsControllerGetLocalSeasonsData, ShowsControllerGetLocalSeasonsResponse, ShowsControllerGetLocalSeasonsResponses, ShowsControllerGetSeasonDetailsData, ShowsControllerGetSeasonDetailsResponse, ShowsControllerGetSeasonDetailsResponses, ShowsControllerGetShowData, ShowsControllerGetShowDetailsData, ShowsControllerGetShowDetailsResponse, ShowsControllerGetShowDetailsResponses, ShowsControllerGetShowResponse, ShowsControllerGetShowResponses, ShowsControllerGetShowWatchHistoryData, ShowsControllerGetShowWatchHistoryErrors, ShowsControllerGetShowWatchHistoryResponse, ShowsControllerGetShowWatchHistoryResponses, ShowsControllerGetUserEpisodesPaginatedData, ShowsControllerGetUserEpisodesPaginatedResponse, ShowsControllerGetUserEpisodesPaginatedResponses, ShowsControllerGetUserReleaseCalendarData, ShowsControllerGetUserReleaseCalendarResponse, ShowsControllerGetUserReleaseCalendarResponses, ShowsControllerGetUserShowsData, ShowsControllerGetUserShowsResponse, ShowsControllerGetUserShowsResponses, ShowsControllerGetUserUpNextData, ShowsControllerGetUserUpNextResponse, ShowsControllerGetUserUpNextResponses, ShowsControllerMarkSeasonWatchedData, ShowsControllerMarkSeasonWatchedErrors, ShowsControllerMarkSeasonWatchedResponse, ShowsControllerMarkSeasonWatchedResponses, ShowsControllerMarkShowWatchedData, ShowsControllerMarkShowWatchedErrors, ShowsControllerMarkShowWatchedResponse, ShowsControllerMarkShowWatchedResponses, ShowsControllerMarkWatchedData, ShowsControllerMarkWatchedErrors, ShowsControllerMarkWatchedResponse, ShowsControllerMarkWatchedResponses, ShowsControllerSearchShowsData, ShowsControllerSearchShowsResponse, ShowsControllerSearchShowsResponses, ShowsControllerUnmarkWatchedData, ShowsControllerUnmarkWatchedResponse, ShowsControllerUnmarkWatchedResponses, SocialActorDto, SocialControllerFollowData, SocialControllerFollowResponse, SocialControllerFollowResponses, SocialControllerGetFeedData, SocialControllerGetFeedResponse, SocialControllerGetFeedResponses, SocialControllerGetFollowersData, SocialControllerGetFollowersResponse, SocialControllerGetFollowersResponses, SocialControllerGetFollowingData, SocialControllerGetFollowingResponse, SocialControllerGetFollowingResponses, SocialControllerGetRelationshipData, SocialControllerGetRelationshipResponse, SocialControllerGetRelationshipResponses, SocialControllerGetWatchersData, SocialControllerGetWatchersResponse, SocialControllerGetWatchersResponses, SocialControllerSearchPeopleData, SocialControllerSearchPeopleResponse, SocialControllerSearchPeopleResponses, SocialControllerUnfollowData, SocialControllerUnfollowResponse, SocialControllerUnfollowResponses, SocialUserCardDto, StartTraktImportDto, StartTraktImportResponseDto, TmdbCastDto, TmdbCreditsDto, TmdbCrewDto, TmdbEpisodeDto, TmdbGenreDto, TmdbMovieDetailDto, TmdbMovieResultDto, TmdbNetworkDto, TmdbPersonDetailDto, TmdbSeasonDetailDto, TmdbSeasonSummaryDto, TmdbShowDetailDto, TmdbShowResultDto, TmdbTrailerDto, TrackedEpisodeDto, TrackedMovieDto, TrackedShowSummaryDto, TraktHistoryPreviewItemDto, TraktImportJobDto, TraktPublicProfileDto, UnifiedDiscoverResponseDto, UnifiedSearchResponseDto, UnifiedSearchResultDto, UpdateListDto, UpdateUserProfileDto, UpdateUserSettingsDto, UpNextEpisodeDto, UpNextShowDto, UserDto, UserProfileDto, UserRelationshipDto, UsersControllerCompleteOnboardingData, UsersControllerCompleteOnboardingErrors, UsersControllerCompleteOnboardingResponse, UsersControllerCompleteOnboardingResponses, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponse, UsersControllerDeleteMyAccountResponses, UsersControllerDeleteMyAvatarData, UsersControllerDeleteMyAvatarResponse, UsersControllerDeleteMyAvatarResponses, UsersControllerFetchMyTraktPublicHistoryData, UsersControllerFetchMyTraktPublicHistoryErrors, UsersControllerFetchMyTraktPublicHistoryResponse, UsersControllerFetchMyTraktPublicHistoryResponses, UsersControllerGetAvatarData, UsersControllerGetAvatarResponses, UsersControllerGetMyAccountDeletionData, UsersControllerGetMyAccountDeletionErrors, UsersControllerGetMyAccountDeletionResponse, UsersControllerGetMyAccountDeletionResponses, UsersControllerGetMyCurrentTraktImportData, UsersControllerGetMyCurrentTraktImportErrors, UsersControllerGetMyCurrentTraktImportResponse, UsersControllerGetMyCurrentTraktImportResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponse, UsersControllerGetMySettingsResponses, UsersControllerGetPublicProfileData, UsersControllerGetPublicProfileErrors, UsersControllerGetPublicProfileResponse, UsersControllerGetPublicProfileResponses, UsersControllerImportMyBlueskyFollowsData, UsersControllerImportMyBlueskyFollowsErrors, UsersControllerImportMyBlueskyFollowsResponse, UsersControllerImportMyBlueskyFollowsResponses, UsersControllerImportMyHistoryData, UsersControllerImportMyHistoryErrors, UsersControllerImportMyHistoryResponse, UsersControllerImportMyHistoryResponses, UsersControllerStartMyTraktImportData, UsersControllerStartMyTraktImportErrors, UsersControllerStartMyTraktImportResponse, UsersControllerStartMyTraktImportResponses, UsersControllerUpdateMyProfileData, UsersControllerUpdateMyProfileErrors, UsersControllerUpdateMyProfileResponse, UsersControllerUpdateMyProfileResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponse, UsersControllerUpdateMySettingsResponses, UsersControllerUploadMyAvatarData, UsersControllerUploadMyAvatarResponse, UsersControllerUploadMyAvatarResponses, UserSettingsDto, WatchHistoryItemDto } from './types.gen';
+48
packages/api/src/generated/types.gen.ts
··· 1069 1069 page: number; 1070 1070 }; 1071 1071 1072 + export type PersonFilmographyRoleDto = { 1073 + /** 1074 + * Type of role (cast or crew) 1075 + */ 1076 + type: string; 1077 + /** 1078 + * Character name for cast roles 1079 + */ 1080 + character?: string; 1081 + /** 1082 + * Job title for crew roles 1083 + */ 1084 + job?: string; 1085 + /** 1086 + * Department for crew roles 1087 + */ 1088 + department?: string; 1089 + /** 1090 + * Billing order for cast roles (lower is higher billing) 1091 + */ 1092 + order?: number; 1093 + }; 1094 + 1072 1095 export type PersonFilmographyItemDto = { 1073 1096 id: number; 1074 1097 media_type: string; 1075 1098 title: string; 1076 1099 poster_path?: string; 1100 + backdrop_path?: string; 1077 1101 release_date?: string; 1078 1102 first_air_date?: string; 1103 + /** 1104 + * Legacy field: character name (use roles array for merged items) 1105 + * 1106 + * @deprecated 1107 + */ 1079 1108 character?: string; 1109 + /** 1110 + * Legacy field: job title (use roles array for merged items) 1111 + * 1112 + * @deprecated 1113 + */ 1080 1114 job?: string; 1115 + /** 1116 + * Legacy field: department (use roles array for merged items) 1117 + * 1118 + * @deprecated 1119 + */ 1081 1120 department?: string; 1121 + /** 1122 + * Legacy field: billing order (use roles array for merged items) 1123 + * 1124 + * @deprecated 1125 + */ 1082 1126 order?: number; 1083 1127 vote_average?: number; 1128 + /** 1129 + * Array of roles when person has multiple credits for the same title (e.g., actor + director) 1130 + */ 1131 + roles?: Array<PersonFilmographyRoleDto>; 1084 1132 }; 1085 1133 1086 1134 export type TmdbPersonDetailDto = {