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.

Fix mobile show detail list modal

+347 -49
+50 -2
apps/mobile/app/list/[slug].tsx
··· 33 33 import { useAuth } from "@/contexts/auth"; 34 34 import { useTheme } from "@/contexts/theme"; 35 35 import { useToast } from "@/contexts/toast"; 36 - import { createTitleSlug, getTmdbPosterUrl } from "@/lib/utils"; 36 + import { 37 + createTitleSlug, 38 + getTmdbPosterUrl, 39 + parseScopedShowMediaId, 40 + } from "@/lib/utils"; 37 41 38 42 export default function ListDetailScreen() { 39 43 const { slug } = useLocalSearchParams<{ slug: string }>(); ··· 102 106 (item: MediaInListDto) => { 103 107 const title = item.media.title as string; 104 108 if (item.mediaType === "show") { 109 + const scoped = parseScopedShowMediaId(item.mediaId); 110 + const showId = 111 + (item.media as { showId?: string }).showId ?? scoped.showId ?? item.mediaId; 112 + if ( 113 + typeof scoped.seasonNumber === "number" && 114 + typeof scoped.episodeNumber === "number" 115 + ) { 116 + router.push({ 117 + pathname: 118 + "/show/[id]/season/[seasonNumber]/episode/[episodeNumber]", 119 + params: { 120 + id: showId, 121 + seasonNumber: String(scoped.seasonNumber), 122 + episodeNumber: String(scoped.episodeNumber), 123 + title: createTitleSlug(title), 124 + }, 125 + }); 126 + return; 127 + } 128 + if (typeof scoped.seasonNumber === "number") { 129 + router.push({ 130 + pathname: "/show/[id]/season/[seasonNumber]", 131 + params: { 132 + id: showId, 133 + seasonNumber: String(scoped.seasonNumber), 134 + title: createTitleSlug(title), 135 + }, 136 + }); 137 + return; 138 + } 105 139 router.push({ 106 140 pathname: "/show/[id]", 107 141 params: { 108 - id: item.mediaId, 142 + id: showId, 109 143 title: createTitleSlug(title), 110 144 }, 111 145 }); ··· 516 550 ); 517 551 const movieTitle = movie.title as string; 518 552 const releaseYear = movie.releaseYear as number | null | undefined; 553 + const scopedShow = 554 + item.mediaType === "show" ? parseScopedShowMediaId(item.mediaId) : null; 555 + const listContext = 556 + typeof scopedShow?.seasonNumber === "number" && 557 + typeof scopedShow?.episodeNumber === "number" 558 + ? `S${scopedShow.seasonNumber} E${scopedShow.episodeNumber}` 559 + : typeof scopedShow?.seasonNumber === "number" 560 + ? `Season ${scopedShow.seasonNumber}` 561 + : null; 519 562 520 563 return ( 521 564 <MediaCard ··· 561 604 {releaseYear && ( 562 605 <Text style={[styles.movieYear, { color: colors.onSurfaceVariant }]}> 563 606 {releaseYear} 607 + </Text> 608 + )} 609 + {listContext && ( 610 + <Text style={[styles.movieYear, { color: colors.onSurfaceVariant }]}> 611 + {listContext} 564 612 </Text> 565 613 )} 566 614 </View>
+10 -1
apps/mobile/app/show/[id].tsx
··· 28 28 View, 29 29 } from "react-native"; 30 30 import { SafeAreaView } from "react-native-safe-area-context"; 31 + import { AddToListModal } from "@/components/AddToListModal"; 31 32 import { 32 33 DetailActions, 33 34 DetailHero, ··· 61 62 const { showToast } = useToast(); 62 63 const queryClient = useQueryClient(); 63 64 64 - const [_showListModal, setShowListModal] = useState(false); 65 + const [showListModal, setShowListModal] = useState(false); 65 66 const [showDateModal, setShowDateModal] = useState(false); 66 67 const [showCompactHeader, setShowCompactHeader] = useState(false); 67 68 ··· 561 562 onConfirm={handleMarkWatchedWithDate} 562 563 isLoading={markShowWatchedMutation.isPending} 563 564 is24Hour={is24Hour} 565 + /> 566 + 567 + <AddToListModal 568 + visible={showListModal} 569 + onClose={() => setShowListModal(false)} 570 + mediaType="show" 571 + mediaId={id} 572 + mediaTitle={show?.name || "Show"} 564 573 /> 565 574 566 575 <ScrollRevealHeader
+8 -2
apps/mobile/app/show/[id]/season/[seasonNumber]/episode/[episodeNumber]/index.tsx
··· 52 52 import { useTheme } from "@/contexts/theme"; 53 53 import { useToast } from "@/contexts/toast"; 54 54 import { 55 + buildScopedShowMediaId, 55 56 getTmdbBackdropUrl, 56 57 getTmdbPosterUrl, 57 58 getTmdbProfileUrl, ··· 98 99 const [showAddToListModal, setShowAddToListModal] = useState(false); 99 100 const [showHistoryModal, setShowHistoryModal] = useState(false); 100 101 const [showCompactHeader, setShowCompactHeader] = useState(false); 102 + const scopedEpisodeMediaId = buildScopedShowMediaId( 103 + id, 104 + Number(seasonNumber), 105 + Number(episodeNumber), 106 + ); 101 107 102 108 const { data: user, refetch: refetchUser } = useQuery({ 103 109 ...authControllerMeOptions(), ··· 163 169 refetch: refetchLists, 164 170 } = useQuery({ 165 171 ...listsControllerGetListsForItemOptions({ 166 - path: { mediaType: "show", mediaId: id }, 172 + path: { mediaType: "show", mediaId: scopedEpisodeMediaId }, 167 173 }), 168 174 enabled: !!resolvedUserDid, 169 175 }); ··· 780 786 visible={showAddToListModal} 781 787 onClose={() => setShowAddToListModal(false)} 782 788 mediaType="show" 783 - mediaId={id} 789 + mediaId={scopedEpisodeMediaId} 784 790 mediaTitle={show?.name || title || "Show"} 785 791 /> 786 792 </>
+16 -2
apps/mobile/app/show/[id]/season/[seasonNumber]/index.tsx
··· 30 30 View, 31 31 } from "react-native"; 32 32 import { SafeAreaView } from "react-native-safe-area-context"; 33 + import { AddToListModal } from "@/components/AddToListModal"; 33 34 import { 34 35 DetailActions, 35 36 DetailHero, ··· 44 45 import { useTheme } from "@/contexts/theme"; 45 46 import { useToast } from "@/contexts/toast"; 46 47 import { 48 + buildScopedShowMediaId, 47 49 getTmdbBackdropUrl, 48 50 getTmdbPosterUrl, 49 51 getTmdbProfileUrl, ··· 69 71 const { showToast } = useToast(); 70 72 const queryClient = useQueryClient(); 71 73 72 - const [_showListModal, setShowListModal] = useState(false); 74 + const [showListModal, setShowListModal] = useState(false); 73 75 const [showDateModal, setShowDateModal] = useState(false); 74 76 const [showCompactHeader, setShowCompactHeader] = useState(false); 75 77 ··· 101 103 }), 102 104 }); 103 105 const season = seasonData as TmdbSeasonDetailDto | undefined; 106 + const scopedSeasonMediaId = buildScopedShowMediaId( 107 + id, 108 + Number(seasonNumber), 109 + ); 104 110 105 111 const { 106 112 data: history, ··· 119 125 refetch: refetchLists, 120 126 } = useQuery({ 121 127 ...listsControllerGetListsForItemOptions({ 122 - path: { mediaType: "show", mediaId: id }, 128 + path: { mediaType: "show", mediaId: scopedSeasonMediaId }, 123 129 }), 124 130 enabled: !!resolvedUserDid, 125 131 }); ··· 612 618 onConfirm={handleMarkWatchedWithDate} 613 619 isLoading={markSeasonWatchedMutation.isPending} 614 620 is24Hour={is24Hour} 621 + /> 622 + 623 + <AddToListModal 624 + visible={showListModal} 625 + onClose={() => setShowListModal(false)} 626 + mediaType="show" 627 + mediaId={scopedSeasonMediaId} 628 + mediaTitle={show?.name || title || "Show"} 615 629 /> 616 630 617 631 <ScrollRevealHeader
+39
apps/mobile/lib/utils.ts
··· 35 35 return `https://image.tmdb.org/t/p/w185${path}`; 36 36 } 37 37 38 + export function buildScopedShowMediaId( 39 + showId: string, 40 + seasonNumber?: number, 41 + episodeNumber?: number, 42 + ): string { 43 + if (typeof seasonNumber === "number" && Number.isFinite(seasonNumber)) { 44 + if (typeof episodeNumber === "number" && Number.isFinite(episodeNumber)) { 45 + return `${showId}:season:${seasonNumber}:episode:${episodeNumber}`; 46 + } 47 + return `${showId}:season:${seasonNumber}`; 48 + } 49 + return showId; 50 + } 51 + 52 + export function parseScopedShowMediaId(mediaId: string): { 53 + showId: string; 54 + seasonNumber?: number; 55 + episodeNumber?: number; 56 + } { 57 + const episodeMatch = mediaId.match(/^([^:]+):season:(\d+):episode:(\d+)$/); 58 + if (episodeMatch) { 59 + return { 60 + showId: episodeMatch[1], 61 + seasonNumber: Number(episodeMatch[2]), 62 + episodeNumber: Number(episodeMatch[3]), 63 + }; 64 + } 65 + 66 + const seasonMatch = mediaId.match(/^([^:]+):season:(\d+)$/); 67 + if (seasonMatch) { 68 + return { 69 + showId: seasonMatch[1], 70 + seasonNumber: Number(seasonMatch[2]), 71 + }; 72 + } 73 + 74 + return { showId: mediaId }; 75 + } 76 + 38 77 export interface DateFormatOptions { 39 78 timezone: string; 40 79 is24Hour: boolean;
+39
apps/web/src/lib/utils.ts
··· 42 42 return `https://image.tmdb.org/t/p/w185${path}`; 43 43 } 44 44 45 + export function buildScopedShowMediaId( 46 + showId: string, 47 + seasonNumber?: number, 48 + episodeNumber?: number, 49 + ): string { 50 + if (typeof seasonNumber === "number" && Number.isFinite(seasonNumber)) { 51 + if (typeof episodeNumber === "number" && Number.isFinite(episodeNumber)) { 52 + return `${showId}:season:${seasonNumber}:episode:${episodeNumber}`; 53 + } 54 + return `${showId}:season:${seasonNumber}`; 55 + } 56 + return showId; 57 + } 58 + 59 + export function parseScopedShowMediaId(mediaId: string): { 60 + showId: string; 61 + seasonNumber?: number; 62 + episodeNumber?: number; 63 + } { 64 + const episodeMatch = mediaId.match(/^([^:]+):season:(\d+):episode:(\d+)$/); 65 + if (episodeMatch) { 66 + return { 67 + showId: episodeMatch[1], 68 + seasonNumber: Number(episodeMatch[2]), 69 + episodeNumber: Number(episodeMatch[3]), 70 + }; 71 + } 72 + 73 + const seasonMatch = mediaId.match(/^([^:]+):season:(\d+)$/); 74 + if (seasonMatch) { 75 + return { 76 + showId: seasonMatch[1], 77 + seasonNumber: Number(seasonMatch[2]), 78 + }; 79 + } 80 + 81 + return { showId: mediaId }; 82 + } 83 + 45 84 export interface DateFormatOptions { 46 85 timezone: string; 47 86 is24Hour: boolean;
+57 -15
apps/web/src/routes/lists.$slug.tsx
··· 24 24 M3CardHeader, 25 25 M3CardTitle, 26 26 } from "@/components/ui/m3-card"; 27 - import { getTmdbPosterUrl } from "@/lib/utils"; 27 + import { getTmdbPosterUrl, parseScopedShowMediaId } from "@/lib/utils"; 28 28 29 29 export const Route = createFileRoute("/lists/$slug")({ 30 30 head: ({ params }) => ({ ··· 291 291 title?: string; 292 292 posterPath?: string | null; 293 293 releaseYear?: number | null; 294 + showId?: string; 294 295 }; 295 296 const mediaType: "movie" | "show" = 296 297 item.mediaType === "show" ? "show" : "movie"; 297 - const posterUrl = getTmdbPosterUrl(media.posterPath ?? null); 298 + const scopedShow = mediaType === "show" ? parseScopedShowMediaId(item.mediaId) : null; 299 + const showIdForNav = media.showId ?? scopedShow?.showId ?? item.mediaId; 300 + const seasonNumber = scopedShow?.seasonNumber; 301 + const episodeNumber = scopedShow?.episodeNumber; 298 302 const mediaTitle = media.title ?? "Untitled"; 299 - const releaseYear = media.releaseYear; 300 303 const mediaSlug = mediaTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-"); 301 304 const isMovie = mediaType === "movie"; 305 + const listContext = 306 + typeof seasonNumber === "number" && typeof episodeNumber === "number" 307 + ? `S${seasonNumber} E${episodeNumber}` 308 + : typeof seasonNumber === "number" 309 + ? `Season ${seasonNumber}` 310 + : null; 311 + const linkTo = isMovie 312 + ? "/movies/$movieId/$title" 313 + : typeof seasonNumber === "number" && typeof episodeNumber === "number" 314 + ? "/shows/$showId/$title/seasons/$seasonNumber/episodes/$episodeNumber" 315 + : typeof seasonNumber === "number" 316 + ? "/shows/$showId/$title/seasons/$seasonNumber" 317 + : "/shows/$showId/$title"; 318 + const linkParams = isMovie 319 + ? { movieId: item.mediaId, title: mediaSlug } 320 + : typeof seasonNumber === "number" && typeof episodeNumber === "number" 321 + ? { 322 + showId: showIdForNav, 323 + title: mediaSlug, 324 + seasonNumber: String(seasonNumber), 325 + episodeNumber: String(episodeNumber), 326 + } 327 + : typeof seasonNumber === "number" 328 + ? { 329 + showId: showIdForNav, 330 + title: mediaSlug, 331 + seasonNumber: String(seasonNumber), 332 + } 333 + : { showId: showIdForNav, title: mediaSlug }; 334 + const posterUrl = getTmdbPosterUrl(media.posterPath ?? null); 335 + const releaseYear = media.releaseYear; 302 336 const { seedColor } = useTheme(); 303 337 304 338 return ( 305 339 <div className="group"> 306 340 <Link 307 - to={isMovie ? "/movies/$movieId/$title" : "/shows/$showId/$title"} 308 - params={ 309 - isMovie 310 - ? { movieId: item.mediaId, title: mediaSlug } 311 - : { showId: item.mediaId, title: mediaSlug } 312 - } 341 + to={linkTo as never} 342 + params={linkParams as never} 313 343 className="block relative aspect-2/3 rounded-lg overflow-hidden mb-2" 314 344 style={{ 315 345 backgroundColor: "var(--md-sys-color-surface-container-highest)", ··· 353 383 </M3Button> 354 384 </Link> 355 385 <Link 356 - to={isMovie ? "/movies/$movieId/$title" : "/shows/$showId/$title"} 357 - params={ 358 - isMovie 359 - ? { movieId: item.mediaId, title: mediaSlug } 360 - : { showId: item.mediaId, title: mediaSlug } 361 - } 386 + to={linkTo as never} 387 + params={linkParams as never} 362 388 className="block" 363 389 > 364 390 <h3 ··· 379 405 style={{ color: "var(--md-sys-color-on-surface-variant)" }} 380 406 > 381 407 {releaseYear} 408 + </p> 409 + )} 410 + {!releaseYear && listContext && ( 411 + <p 412 + className="text-sm" 413 + style={{ color: "var(--md-sys-color-on-surface-variant)" }} 414 + > 415 + {listContext} 416 + </p> 417 + )} 418 + {releaseYear && listContext && ( 419 + <p 420 + className="text-sm" 421 + style={{ color: "var(--md-sys-color-on-surface-variant)" }} 422 + > 423 + {listContext} 382 424 </p> 383 425 )} 384 426 </Link>
+8 -2
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.episodes.$episodeNumber.tsx
··· 40 40 } from "@/components/ui/dialog"; 41 41 import { M3Button } from "@/components/ui/m3-button"; 42 42 import { 43 + buildScopedShowMediaId, 43 44 formatDateOnly, 44 45 formatDateWithTimezone, 45 46 formatRuntime, ··· 168 169 ...usersControllerGetMySettingsOptions(), 169 170 enabled: !!user?.did, 170 171 }); 172 + const scopedEpisodeMediaId = buildScopedShowMediaId( 173 + showId, 174 + Number(seasonNumber), 175 + Number(episodeNumber), 176 + ); 171 177 172 178 const { data: listsForShow } = useQuery({ 173 179 ...listsControllerGetListsForItemOptions({ 174 - path: { mediaType: "show", mediaId: showId }, 180 + path: { mediaType: "show", mediaId: scopedEpisodeMediaId }, 175 181 }), 176 182 enabled: !!user?.did, 177 183 }); ··· 506 512 open={showListModal} 507 513 onOpenChange={setShowListModal} 508 514 mediaType="show" 509 - mediaId={showId} 515 + mediaId={scopedEpisodeMediaId} 510 516 mediaTitle={show?.name || "Show"} 511 517 user={user} 512 518 />
+7 -2
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.tsx
··· 36 36 import { GenresSection } from "@/components/GenresSection"; 37 37 import { useTheme } from "@/components/theme-provider"; 38 38 import { 39 + buildScopedShowMediaId, 39 40 formatDateOnly, 40 41 getTmdbBackdropUrl, 41 42 getTmdbPosterUrl, ··· 146 147 147 148 const show = showData as TmdbShowDetailDto | undefined; 148 149 const season = seasonData as TmdbSeasonDetailDto | undefined; 150 + const scopedSeasonMediaId = buildScopedShowMediaId( 151 + showId, 152 + Number(seasonNumber), 153 + ); 149 154 150 155 const { data: history } = useQuery({ 151 156 ...showsControllerGetShowWatchHistoryOptions({ ··· 156 161 157 162 const { data: listsForShow } = useQuery({ 158 163 ...listsControllerGetListsForItemOptions({ 159 - path: { mediaType: "show", mediaId: showId }, 164 + path: { mediaType: "show", mediaId: scopedSeasonMediaId }, 160 165 }), 161 166 enabled: !!user?.did, 162 167 }); ··· 390 395 open={showListModal} 391 396 onOpenChange={setShowListModal} 392 397 mediaType="show" 393 - mediaId={showId} 398 + mediaId={scopedSeasonMediaId} 394 399 mediaTitle={show?.name || ""} 395 400 user={user} 396 401 />
+8
backend/src/lists/dto/list.dto.ts
··· 83 83 @ApiProperty() 84 84 mediaId: string; 85 85 86 + @ApiPropertyOptional({ description: "Season number for season/episode show items" }) 87 + seasonNumber?: number; 88 + 89 + @ApiPropertyOptional({ description: "Episode number for episode show items" }) 90 + episodeNumber?: number; 91 + 86 92 @ApiPropertyOptional({ description: "Legacy movieId field for movie items" }) 87 93 movieId?: string; 88 94 ··· 101 107 mediaId: string; 102 108 movieId?: string; 103 109 showId?: string; 110 + seasonNumber?: number; 111 + episodeNumber?: number; 104 112 title: string; 105 113 posterPath?: string; 106 114 backdropPath?: string;
+105 -23
backend/src/lists/lists.service.ts
··· 103 103 mediaType: "movie" | "show", 104 104 mediaId: string, 105 105 ): Promise<MovieListsForItemDto[]> { 106 + const scopedMediaId = 107 + mediaType === "show" ? this.buildScopedShowMediaId(mediaId) : mediaId; 108 + 106 109 const lists = await this.prisma.movieList.findMany({ 107 110 where: { userDid }, 108 111 orderBy: [{ isDefault: "desc" }, { createdAt: "asc" }], 109 112 include: { 110 - items: { 111 - where: { mediaType, mediaId }, 112 - select: { id: true }, 113 - }, 113 + items: { where: { mediaType, mediaId: scopedMediaId }, select: { id: true } }, 114 114 }, 115 115 }); 116 116 ··· 385 385 throw new NotFoundException("List not found"); 386 386 } 387 387 388 + const scopedMediaId = 389 + dto.mediaType === "show" 390 + ? this.buildScopedShowMediaId(dto.mediaId) 391 + : dto.mediaId; 392 + const showScope = 393 + dto.mediaType === "show" 394 + ? this.parseScopedShowMediaId(scopedMediaId) 395 + : undefined; 396 + 388 397 const existing = await this.prisma.listItem.findUnique({ 389 398 where: { 390 399 listId_mediaType_mediaId: { 391 400 listId: list.id, 392 401 mediaType: dto.mediaType, 393 - mediaId: dto.mediaId, 402 + mediaId: scopedMediaId, 394 403 }, 395 404 }, 396 405 include: { movie: true, show: true }, ··· 404 413 const movieData = await this.moviesService.getMovieDetails(dto.mediaId); 405 414 await this.moviesService.upsertMovie(movieData); 406 415 } else { 407 - const showData = await this.showsService.getShowDetails(dto.mediaId); 416 + const showData = await this.showsService.getShowDetails(showScope?.showId ?? dto.mediaId); 408 417 await this.showsService.upsertShow(showData); 409 418 } 410 419 ··· 414 423 const record: ListItemRecord = listItemSchema.build({ 415 424 listRkey: list.rkey, 416 425 mediaType: dto.mediaType, 417 - mediaId: dto.mediaId, 426 + mediaId: scopedMediaId, 418 427 notes: dto.notes, 419 428 createdAt: now, 420 429 }); ··· 430 439 validate: false, 431 440 }); 432 441 433 - this.logger.log(`Added ${dto.mediaType} ${dto.mediaId} to list ${slug}`); 442 + this.logger.log(`Added ${dto.mediaType} ${scopedMediaId} to list ${slug}`); 434 443 435 444 const itemCount = await this.prisma.listItem.count({ 436 445 where: { listId: list.id }, ··· 443 452 cid: response.data.cid, 444 453 listId: list.id, 445 454 mediaType: dto.mediaType, 446 - mediaId: dto.mediaId, 455 + mediaId: scopedMediaId, 447 456 movieId: dto.mediaType === "movie" ? dto.mediaId : null, 448 - showId: dto.mediaType === "show" ? dto.mediaId : null, 457 + showId: dto.mediaType === "show" ? (showScope?.showId ?? dto.mediaId) : null, 449 458 notes: dto.notes, 450 459 position: itemCount, 451 460 }, ··· 462 471 mediaType: "movie" | "show", 463 472 mediaId: string, 464 473 ): Promise<void> { 474 + const scopedMediaId = 475 + mediaType === "show" ? this.buildScopedShowMediaId(mediaId) : mediaId; 476 + 465 477 const list = await this.prisma.movieList.findFirst({ 466 478 where: { userDid, slug }, 467 479 }); ··· 475 487 listId_mediaType_mediaId: { 476 488 listId: list.id, 477 489 mediaType, 478 - mediaId, 490 + mediaId: scopedMediaId, 479 491 }, 480 492 }, 481 493 }); ··· 497 509 where: { id: item.id }, 498 510 }); 499 511 500 - this.logger.log(`Removed ${mediaType} ${mediaId} from list ${slug}`); 512 + this.logger.log(`Removed ${mediaType} ${scopedMediaId} from list ${slug}`); 501 513 } 502 514 503 515 async indexListRecord( ··· 576 588 } 577 589 } 578 590 } else { 579 - const existingShow = await this.showsService.getShowByTMDBId( 580 - record.mediaId, 581 - ); 591 + const scopedShow = this.parseScopedShowMediaId(record.mediaId); 592 + const baseShowId = scopedShow?.showId ?? record.mediaId; 593 + const existingShow = await this.showsService.getShowByTMDBId(baseShowId); 582 594 if (!existingShow) { 583 595 try { 584 - const showData = await this.showsService.getShowDetails( 585 - record.mediaId, 586 - ); 596 + const showData = await this.showsService.getShowDetails(baseShowId); 587 597 await this.showsService.upsertShow(showData); 588 598 } catch (err) { 589 599 this.logger.error( 590 - `Failed to fetch show ${record.mediaId} from TMDB, skipping item`, 600 + `Failed to fetch show ${baseShowId} from TMDB, skipping item`, 591 601 err, 592 602 ); 593 603 return; ··· 605 615 mediaType: record.mediaType, 606 616 mediaId: record.mediaId, 607 617 movieId: record.mediaType === "movie" ? record.mediaId : null, 608 - showId: record.mediaType === "show" ? record.mediaId : null, 618 + showId: 619 + record.mediaType === "show" 620 + ? (this.parseScopedShowMediaId(record.mediaId)?.showId ?? record.mediaId) 621 + : null, 609 622 notes: record.notes, 610 623 }, 611 624 update: { ··· 613 626 mediaType: record.mediaType, 614 627 mediaId: record.mediaId, 615 628 movieId: record.mediaType === "movie" ? record.mediaId : null, 616 - showId: record.mediaType === "show" ? record.mediaId : null, 629 + showId: 630 + record.mediaType === "show" 631 + ? (this.parseScopedShowMediaId(record.mediaId)?.showId ?? record.mediaId) 632 + : null, 617 633 notes: record.notes, 618 634 }, 619 635 }); ··· 725 741 colors: unknown; 726 742 } | null; 727 743 }): MediaInListDto { 744 + const parsedShowScope = 745 + item.mediaType === "show" 746 + ? this.parseScopedShowMediaId(item.mediaId) 747 + : undefined; 748 + const baseMediaId = 749 + item.mediaType === "show" 750 + ? parsedShowScope?.showId ?? item.mediaId 751 + : item.mediaId; 728 752 const mediaTitle = 729 753 item.mediaType === "movie" ? item.movie?.title : item.show?.title; 730 754 const mediaPosterPath = ··· 753 777 rkey: item.rkey, 754 778 mediaType: item.mediaType, 755 779 mediaId: item.mediaId, 780 + seasonNumber: parsedShowScope?.seasonNumber, 781 + episodeNumber: parsedShowScope?.episodeNumber, 756 782 movieId: item.mediaType === "movie" ? item.mediaId : undefined, 757 783 notes: item.notes ?? undefined, 758 784 position: item.position, 759 785 createdAt: item.createdAt.toISOString(), 760 786 media: { 761 787 mediaType: item.mediaType, 762 - mediaId: item.mediaId, 788 + mediaId: baseMediaId, 763 789 movieId: item.movie?.movieId, 764 - showId: item.show?.showId, 790 + showId: item.show?.showId ?? parsedShowScope?.showId, 791 + seasonNumber: parsedShowScope?.seasonNumber, 792 + episodeNumber: parsedShowScope?.episodeNumber, 765 793 title: mediaTitle ?? "", 766 794 posterPath: mediaPosterPath ?? undefined, 767 795 backdropPath: mediaBackdropPath ?? undefined, ··· 785 813 } 786 814 : undefined, 787 815 }; 816 + } 817 + 818 + private buildScopedShowMediaId( 819 + mediaId: string, 820 + seasonNumber?: number, 821 + episodeNumber?: number, 822 + ): string { 823 + const parsed = this.parseScopedShowMediaId(mediaId); 824 + if (parsed) { 825 + return mediaId; 826 + } 827 + 828 + if (typeof seasonNumber === "number" && Number.isFinite(seasonNumber)) { 829 + if ( 830 + typeof episodeNumber === "number" && 831 + Number.isFinite(episodeNumber) 832 + ) { 833 + return `${mediaId}:season:${seasonNumber}:episode:${episodeNumber}`; 834 + } 835 + return `${mediaId}:season:${seasonNumber}`; 836 + } 837 + 838 + return mediaId; 839 + } 840 + 841 + private parseScopedShowMediaId( 842 + mediaId: string, 843 + ): 844 + | { 845 + showId: string; 846 + seasonNumber?: number; 847 + episodeNumber?: number; 848 + } 849 + | undefined { 850 + const episodeMatch = mediaId.match( 851 + /^([^:]+):season:(\d+):episode:(\d+)$/, 852 + ); 853 + if (episodeMatch) { 854 + return { 855 + showId: episodeMatch[1], 856 + seasonNumber: Number(episodeMatch[2]), 857 + episodeNumber: Number(episodeMatch[3]), 858 + }; 859 + } 860 + 861 + const seasonMatch = mediaId.match(/^([^:]+):season:(\d+)$/); 862 + if (seasonMatch) { 863 + return { 864 + showId: seasonMatch[1], 865 + seasonNumber: Number(seasonMatch[2]), 866 + }; 867 + } 868 + 869 + return undefined; 788 870 } 789 871 }