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.

Add TMDB trailer support across web and mobile detail pages

- fetch and expose ranked TMDB trailers for movies, shows, seasons, and episodes
- add shared trailer helpers and API exports for embed/thumbnail URL resolution
- render trailer sections in detail views with show-level fallback for season/episode
- add mobile trailer playback modal via `react-native-webview` and web trailer component tests

+1209 -79
+17
apps/mobile/app/movie/[id].tsx
··· 36 36 GenresSection, 37 37 MetadataPills, 38 38 OverviewSection, 39 + TrailerPlayerModal, 40 + TrailerSection, 39 41 WatchHistoryModal, 40 42 } from "@/components/detail"; 41 43 import { ScrollRevealHeader } from "@/components/ScrollRevealHeader"; ··· 101 103 const [showDateModal, setShowDateModal] = useState(false); 102 104 const [showAddToListModal, setShowAddToListModal] = useState(false); 103 105 const [showHistoryModal, setShowHistoryModal] = useState(false); 106 + const [activeTrailer, setActiveTrailer] = useState< 107 + TmdbMovieDetailDto["trailer"] | null 108 + >(null); 104 109 const { showCompactHeader, onScroll } = useScrollRevealHeader(); 105 110 106 111 const { ··· 463 468 titleColor={movieColors.primary} 464 469 content={movie?.overview || ""} 465 470 /> 471 + <TrailerSection 472 + mediaType="movie" 473 + detailTrailer={movie?.trailer} 474 + titleColor={movieColors.primary} 475 + onPress={setActiveTrailer} 476 + /> 466 477 <GenresSection 467 478 titleColor={movieColors.primary} 468 479 textColor={movieColors.accent} ··· 512 523 visible={showCompactHeader} 513 524 onBack={() => router.back()} 514 525 title={movie?.title || title || "Movie"} 526 + /> 527 + 528 + <TrailerPlayerModal 529 + visible={!!activeTrailer} 530 + trailer={activeTrailer ?? null} 531 + onClose={() => setActiveTrailer(null)} 515 532 /> 516 533 </SafeAreaView> 517 534 );
+17
apps/mobile/app/show/[id].tsx
··· 34 34 MetadataPills, 35 35 OverviewSection, 36 36 SeasonCard, 37 + TrailerPlayerModal, 38 + TrailerSection, 37 39 } from "@/components/detail"; 38 40 import { ScrollRevealHeader } from "@/components/ScrollRevealHeader"; 39 41 import { WatchDatePickerModal } from "@/components/WatchDatePickerModal"; ··· 63 65 64 66 const [showListModal, setShowListModal] = useState(false); 65 67 const [showDateModal, setShowDateModal] = useState(false); 68 + const [activeTrailer, setActiveTrailer] = useState< 69 + TmdbShowDetailDto["trailer"] | null 70 + >(null); 66 71 const { showCompactHeader, onScroll } = useScrollRevealHeader(); 67 72 68 73 const { data: user, refetch: refetchUser } = useQuery({ ··· 354 359 titleColor={showColors.primary} 355 360 content={show?.overview || ""} 356 361 /> 362 + <TrailerSection 363 + mediaType="show" 364 + detailTrailer={show?.trailer} 365 + titleColor={showColors.primary} 366 + onPress={setActiveTrailer} 367 + /> 357 368 <GenresSection 358 369 titleColor={showColors.primary} 359 370 textColor={showColors.primary} ··· 431 442 visible={showCompactHeader} 432 443 onBack={() => router.back()} 433 444 title={show?.name || "Show"} 445 + /> 446 + 447 + <TrailerPlayerModal 448 + visible={!!activeTrailer} 449 + trailer={activeTrailer ?? null} 450 + onClose={() => setActiveTrailer(null)} 434 451 /> 435 452 </SafeAreaView> 436 453 );
+19 -1
apps/mobile/app/show/[id]/season/[seasonNumber]/episode/[episodeNumber]/index.tsx
··· 1 1 import { Ionicons } from "@expo/vector-icons"; 2 - import type { TmdbShowDetailDto } from "@opnshelf/api"; 2 + import type { TmdbShowDetailDto, TmdbTrailerDto } from "@opnshelf/api"; 3 3 import { 4 4 authControllerMeOptions, 5 5 type EpisodeHistoryItemDto, ··· 38 38 type EpisodeSummary, 39 39 MetadataPills, 40 40 OverviewSection, 41 + TrailerPlayerModal, 42 + TrailerSection, 41 43 WatchHistoryModal, 42 44 } from "@/components/detail"; 43 45 import { ScrollRevealHeader } from "@/components/ScrollRevealHeader"; ··· 93 95 const [showDateModal, setShowDateModal] = useState(false); 94 96 const [showAddToListModal, setShowAddToListModal] = useState(false); 95 97 const [showHistoryModal, setShowHistoryModal] = useState(false); 98 + const [activeTrailer, setActiveTrailer] = useState<TmdbTrailerDto | null>( 99 + null, 100 + ); 96 101 const { showCompactHeader, onScroll } = useScrollRevealHeader(); 97 102 const scopedEpisodeMediaId = buildScopedShowMediaId( 98 103 id, ··· 536 541 titleColor={showColors.primary} 537 542 content={(episode as TmdbEpisodeDto)?.overview || ""} 538 543 /> 544 + <TrailerSection 545 + mediaType="episode" 546 + detailTrailer={(episode as TmdbEpisodeDto)?.trailer} 547 + showTrailer={show?.trailer} 548 + titleColor={showColors.primary} 549 + onPress={setActiveTrailer} 550 + /> 539 551 <CastSection 540 552 titleColor={showColors.primary} 541 553 cast={show?.credits?.cast} ··· 583 595 mediaType="show" 584 596 mediaId={scopedEpisodeMediaId} 585 597 mediaTitle={show?.name || title || "Show"} 598 + /> 599 + 600 + <TrailerPlayerModal 601 + visible={!!activeTrailer} 602 + trailer={activeTrailer} 603 + onClose={() => setActiveTrailer(null)} 586 604 /> 587 605 </> 588 606 );
+19
apps/mobile/app/show/[id]/season/[seasonNumber]/index.tsx
··· 11 11 showsControllerUnmarkWatchedMutation, 12 12 type TmdbSeasonDetailDto, 13 13 type TmdbShowDetailDto, 14 + type TmdbTrailerDto, 14 15 usersControllerGetMySettingsOptions, 15 16 } from "@opnshelf/api"; 16 17 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; ··· 37 38 MetadataPills, 38 39 OverviewSection, 39 40 SeasonNav, 41 + TrailerPlayerModal, 42 + TrailerSection, 40 43 } from "@/components/detail"; 41 44 import { ScrollRevealHeader } from "@/components/ScrollRevealHeader"; 42 45 import { WatchDatePickerModal } from "@/components/WatchDatePickerModal"; ··· 73 76 74 77 const [showListModal, setShowListModal] = useState(false); 75 78 const [showDateModal, setShowDateModal] = useState(false); 79 + const [activeTrailer, setActiveTrailer] = useState<TmdbTrailerDto | null>( 80 + null, 81 + ); 76 82 const { showCompactHeader, onScroll } = useScrollRevealHeader(); 77 83 78 84 const { data: user, refetch: refetchUser } = useQuery({ ··· 395 401 titleColor={showColors.primary} 396 402 content={season?.overview || ""} 397 403 /> 404 + <TrailerSection 405 + mediaType="season" 406 + detailTrailer={season?.trailer} 407 + showTrailer={show?.trailer} 408 + titleColor={showColors.primary} 409 + onPress={setActiveTrailer} 410 + /> 398 411 <GenresSection 399 412 titleColor={showColors.primary} 400 413 textColor={showColors.primary} ··· 469 482 visible={showCompactHeader} 470 483 onBack={() => router.back()} 471 484 title={compactHeaderTitle} 485 + /> 486 + 487 + <TrailerPlayerModal 488 + visible={!!activeTrailer} 489 + trailer={activeTrailer} 490 + onClose={() => setActiveTrailer(null)} 472 491 /> 473 492 </SafeAreaView> 474 493 );
+2
apps/mobile/components/detail/index.ts
··· 10 10 export { GenresSection } from "./sections/GenresSection"; 11 11 export { CastSection } from "./sections/CastSection"; 12 12 export { CrewSection } from "./sections/CrewSection"; 13 + export { TrailerSection } from "./sections/TrailerSection"; 14 + export { TrailerPlayerModal } from "./modals/TrailerPlayerModal"; 13 15 export { WatchHistoryModal } from "./modals/WatchHistoryModal"; 14 16 export type { 15 17 ColorTheme,
+116
apps/mobile/components/detail/modals/TrailerPlayerModal.tsx
··· 1 + import { getYouTubeEmbedUrl, type TmdbTrailerDto } from "@opnshelf/api"; 2 + import { Ionicons } from "@expo/vector-icons"; 3 + import { Modal, Pressable, StyleSheet, Text, View } from "react-native"; 4 + import { SafeAreaView } from "react-native-safe-area-context"; 5 + import { WebView } from "react-native-webview"; 6 + import { borderRadius, spacing } from "@/constants/spacing"; 7 + import { useTheme } from "@/contexts/theme"; 8 + 9 + type TrailerPlayerModalProps = { 10 + visible: boolean; 11 + trailer: TmdbTrailerDto | null; 12 + onClose: () => void; 13 + }; 14 + 15 + export function TrailerPlayerModal({ 16 + visible, 17 + trailer, 18 + onClose, 19 + }: TrailerPlayerModalProps) { 20 + const { colors } = useTheme(); 21 + 22 + return ( 23 + <Modal 24 + visible={visible} 25 + animationType="slide" 26 + presentationStyle="fullScreen" 27 + onRequestClose={onClose} 28 + > 29 + <SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}> 30 + <View style={styles.header}> 31 + <View style={styles.headerCopy}> 32 + <Text style={[styles.eyebrow, { color: colors.onSurfaceVariant }]}> 33 + Trailer 34 + </Text> 35 + <Text style={[styles.title, { color: colors.onSurface }]} numberOfLines={2}> 36 + {trailer?.name || "Trailer"} 37 + </Text> 38 + </View> 39 + <Pressable 40 + onPress={onClose} 41 + style={[ 42 + styles.closeButton, 43 + { backgroundColor: colors.surfaceContainerHigh }, 44 + ]} 45 + > 46 + <Ionicons name="close" size={22} color={colors.onSurface} /> 47 + </Pressable> 48 + </View> 49 + 50 + <View 51 + style={[ 52 + styles.playerFrame, 53 + { backgroundColor: colors.surfaceContainerHigh }, 54 + ]} 55 + > 56 + {trailer ? ( 57 + <WebView 58 + source={{ 59 + uri: getYouTubeEmbedUrl(trailer.key, { autoplay: true }), 60 + }} 61 + style={styles.webview} 62 + allowsFullscreenVideo 63 + javaScriptEnabled 64 + domStorageEnabled 65 + mediaPlaybackRequiresUserAction={false} 66 + /> 67 + ) : null} 68 + </View> 69 + </SafeAreaView> 70 + </Modal> 71 + ); 72 + } 73 + 74 + const styles = StyleSheet.create({ 75 + container: { 76 + flex: 1, 77 + padding: spacing.md, 78 + }, 79 + header: { 80 + alignItems: "flex-start", 81 + flexDirection: "row", 82 + gap: spacing.md, 83 + justifyContent: "space-between", 84 + marginBottom: spacing.lg, 85 + }, 86 + headerCopy: { 87 + flex: 1, 88 + gap: spacing.xs, 89 + paddingTop: spacing.xs, 90 + }, 91 + eyebrow: { 92 + fontSize: 11, 93 + fontWeight: "700", 94 + letterSpacing: 1.2, 95 + textTransform: "uppercase", 96 + }, 97 + title: { 98 + fontSize: 20, 99 + fontWeight: "700", 100 + }, 101 + closeButton: { 102 + alignItems: "center", 103 + borderRadius: borderRadius.full, 104 + height: 40, 105 + justifyContent: "center", 106 + width: 40, 107 + }, 108 + playerFrame: { 109 + aspectRatio: 16 / 9, 110 + borderRadius: borderRadius.xl, 111 + overflow: "hidden", 112 + }, 113 + webview: { 114 + flex: 1, 115 + }, 116 + });
+174
apps/mobile/components/detail/sections/TrailerSection.tsx
··· 1 + import { 2 + getYouTubeThumbnailUrl, 3 + resolveDetailTrailer, 4 + type TmdbTrailerDto, 5 + } from "@opnshelf/api"; 6 + import { Ionicons } from "@expo/vector-icons"; 7 + import { Image } from "expo-image"; 8 + import { Pressable, StyleSheet, Text, View } from "react-native"; 9 + import { borderRadius, spacing } from "@/constants/spacing"; 10 + import { useTheme } from "@/contexts/theme"; 11 + 12 + type TrailerSectionProps = { 13 + mediaType: "movie" | "show" | "season" | "episode"; 14 + detailTrailer?: TmdbTrailerDto; 15 + showTrailer?: TmdbTrailerDto; 16 + titleColor?: string; 17 + onPress: (trailer: TmdbTrailerDto) => void; 18 + }; 19 + 20 + export function TrailerSection({ 21 + mediaType, 22 + detailTrailer, 23 + showTrailer, 24 + titleColor, 25 + onPress, 26 + }: TrailerSectionProps) { 27 + const { colors } = useTheme(); 28 + const resolvedTrailer = resolveDetailTrailer({ 29 + mediaType, 30 + detailTrailer, 31 + showTrailer, 32 + }); 33 + 34 + if (!resolvedTrailer) { 35 + return null; 36 + } 37 + 38 + const { trailer, isFallback } = resolvedTrailer; 39 + 40 + return ( 41 + <View style={styles.section}> 42 + <View style={styles.header}> 43 + <Text style={[styles.sectionTitle, { color: titleColor ?? colors.primary }]}> 44 + Trailer 45 + </Text> 46 + {isFallback ? ( 47 + <View 48 + style={[ 49 + styles.badge, 50 + { 51 + backgroundColor: colors.surfaceContainer, 52 + borderColor: colors.outlineVariant, 53 + }, 54 + ]} 55 + > 56 + <Text style={[styles.badgeText, { color: colors.onSurfaceVariant }]}> 57 + From show 58 + </Text> 59 + </View> 60 + ) : null} 61 + </View> 62 + 63 + <Pressable 64 + onPress={() => onPress(trailer)} 65 + style={({ pressed }) => [ 66 + styles.card, 67 + { 68 + backgroundColor: colors.surfaceContainerLow, 69 + borderColor: colors.outlineVariant, 70 + opacity: pressed ? 0.92 : 1, 71 + }, 72 + ]} 73 + > 74 + <Image 75 + source={{ uri: getYouTubeThumbnailUrl(trailer.key) }} 76 + style={styles.thumbnail} 77 + contentFit="cover" 78 + /> 79 + <View style={styles.overlay} /> 80 + <View style={styles.content}> 81 + <View style={styles.copy}> 82 + <Text style={[styles.eyebrow, { color: colors.onSurfaceVariant }]}> 83 + Watch trailer 84 + </Text> 85 + <Text style={[styles.title, { color: colors.onSurface }]} numberOfLines={2}> 86 + {trailer.name} 87 + </Text> 88 + </View> 89 + <View 90 + style={[ 91 + styles.playButton, 92 + { backgroundColor: "rgba(0,0,0,0.55)", borderColor: colors.outline }, 93 + ]} 94 + > 95 + <Ionicons name="play" size={26} color="#fff" style={styles.playIcon} /> 96 + </View> 97 + </View> 98 + </Pressable> 99 + </View> 100 + ); 101 + } 102 + 103 + const styles = StyleSheet.create({ 104 + section: { 105 + gap: spacing.sm, 106 + }, 107 + header: { 108 + alignItems: "center", 109 + flexDirection: "row", 110 + justifyContent: "space-between", 111 + gap: spacing.sm, 112 + }, 113 + sectionTitle: { 114 + fontSize: 16, 115 + fontWeight: "600", 116 + }, 117 + badge: { 118 + borderRadius: borderRadius.full, 119 + borderWidth: 1, 120 + paddingHorizontal: spacing.md, 121 + paddingVertical: spacing.xs, 122 + }, 123 + badgeText: { 124 + fontSize: 12, 125 + fontWeight: "600", 126 + }, 127 + card: { 128 + aspectRatio: 16 / 9, 129 + borderRadius: borderRadius.lg, 130 + borderWidth: 1, 131 + overflow: "hidden", 132 + position: "relative", 133 + }, 134 + thumbnail: { 135 + ...StyleSheet.absoluteFillObject, 136 + }, 137 + overlay: { 138 + ...StyleSheet.absoluteFillObject, 139 + backgroundColor: "rgba(0, 0, 0, 0.34)", 140 + }, 141 + content: { 142 + ...StyleSheet.absoluteFillObject, 143 + alignItems: "flex-end", 144 + flexDirection: "row", 145 + justifyContent: "space-between", 146 + padding: spacing.md, 147 + }, 148 + copy: { 149 + flex: 1, 150 + gap: spacing.xs, 151 + paddingRight: spacing.sm, 152 + }, 153 + eyebrow: { 154 + fontSize: 10, 155 + fontWeight: "600", 156 + letterSpacing: 0.8, 157 + textTransform: "uppercase", 158 + }, 159 + title: { 160 + fontSize: 16, 161 + fontWeight: "600", 162 + }, 163 + playButton: { 164 + alignItems: "center", 165 + borderRadius: borderRadius.full, 166 + borderWidth: 1, 167 + height: 44, 168 + justifyContent: "center", 169 + width: 44, 170 + }, 171 + playIcon: { 172 + marginLeft: 2, 173 + }, 174 + });
+1
apps/mobile/package.json
··· 56 56 "react-native-paper-dates": "^0.23.3", 57 57 "react-native-react-query-devtools": "^1.5.1", 58 58 "react-native-reanimated": "~4.1.1", 59 + "react-native-webview": "^13.16.0", 59 60 "react-native-safe-area-context": "~5.6.0", 60 61 "react-native-screens": "~4.16.0", 61 62 "react-native-svg": "^15.15.3",
-6
apps/web/src/components/Header.tsx
··· 164 164 165 165 <div className="min-w-0"> 166 166 <div className="md-title-large leading-none">OpnShelf</div> 167 - <p 168 - className="hidden text-[11px] font-semibold uppercase tracking-[0.24em] md:block" 169 - style={{ color: "var(--md-sys-color-on-surface-variant)" }} 170 - > 171 - Track your cinema 172 - </p> 173 167 </div> 174 168 </Link> 175 169 );
+99
apps/web/src/components/detail/TrailerSection.test.tsx
··· 1 + // @vitest-environment jsdom 2 + 3 + import { act } from "react"; 4 + import { createRoot, type Root } from "react-dom/client"; 5 + import { afterEach, describe, expect, it } from "vitest"; 6 + import { TrailerSection } from "./TrailerSection"; 7 + 8 + globalThis.IS_REACT_ACT_ENVIRONMENT = true; 9 + 10 + describe("TrailerSection", () => { 11 + let container: HTMLDivElement | null = null; 12 + let root: Root | null = null; 13 + 14 + afterEach(() => { 15 + if (root && container) { 16 + act(() => { 17 + root?.unmount(); 18 + }); 19 + } 20 + container?.remove(); 21 + container = null; 22 + root = null; 23 + }); 24 + 25 + it("renders nothing without a trailer", () => { 26 + container = document.createElement("div"); 27 + document.body.appendChild(container); 28 + root = createRoot(container); 29 + 30 + act(() => { 31 + root?.render(<TrailerSection mediaType="movie" />); 32 + }); 33 + 34 + expect(container.innerHTML).toBe(""); 35 + }); 36 + 37 + it("shows a fallback badge when using the parent show trailer", () => { 38 + container = document.createElement("div"); 39 + document.body.appendChild(container); 40 + root = createRoot(container); 41 + 42 + act(() => { 43 + root?.render( 44 + <TrailerSection 45 + mediaType="season" 46 + showTrailer={{ 47 + id: "show-trailer", 48 + key: "abc123", 49 + name: "Show Trailer", 50 + site: "YouTube", 51 + type: "Trailer", 52 + sourceMediaType: "show", 53 + }} 54 + />, 55 + ); 56 + }); 57 + 58 + expect(container.textContent).toContain("From show"); 59 + expect(container.textContent).toContain("Show Trailer"); 60 + }); 61 + 62 + it("swaps the preview for an iframe when clicked", () => { 63 + container = document.createElement("div"); 64 + document.body.appendChild(container); 65 + root = createRoot(container); 66 + 67 + act(() => { 68 + root?.render( 69 + <TrailerSection 70 + mediaType="movie" 71 + detailTrailer={{ 72 + id: "movie-trailer", 73 + key: "xyz789", 74 + name: "Movie Trailer", 75 + site: "YouTube", 76 + type: "Trailer", 77 + sourceMediaType: "movie", 78 + }} 79 + />, 80 + ); 81 + }); 82 + 83 + const previewButton = container.querySelector("button"); 84 + expect(previewButton).not.toBeNull(); 85 + 86 + act(() => { 87 + previewButton?.dispatchEvent( 88 + new MouseEvent("click", { bubbles: true, cancelable: true }), 89 + ); 90 + }); 91 + 92 + const iframe = container.querySelector("iframe"); 93 + expect(iframe).not.toBeNull(); 94 + expect(iframe?.getAttribute("src")).toContain( 95 + "youtube-nocookie.com/embed/xyz789", 96 + ); 97 + expect(iframe?.getAttribute("src")).toContain("autoplay=1"); 98 + }); 99 + });
+87
apps/web/src/components/detail/TrailerSection.tsx
··· 1 + import { 2 + getYouTubeEmbedUrl, 3 + getYouTubeThumbnailUrl, 4 + resolveDetailTrailer, 5 + type TmdbTrailerDto, 6 + } from "@opnshelf/api"; 7 + import { Play } from "lucide-react"; 8 + import { useState } from "react"; 9 + import { Badge } from "@/components/ui/badge"; 10 + 11 + type TrailerSectionProps = { 12 + mediaType: "movie" | "show" | "season" | "episode"; 13 + detailTrailer?: TmdbTrailerDto; 14 + showTrailer?: TmdbTrailerDto; 15 + titleColor?: string; 16 + }; 17 + 18 + export function TrailerSection({ 19 + mediaType, 20 + detailTrailer, 21 + showTrailer, 22 + titleColor, 23 + }: TrailerSectionProps) { 24 + const [isPlaying, setIsPlaying] = useState(false); 25 + const resolvedTrailer = resolveDetailTrailer({ 26 + mediaType, 27 + detailTrailer, 28 + showTrailer, 29 + }); 30 + 31 + if (!resolvedTrailer) { 32 + return null; 33 + } 34 + 35 + const { trailer, isFallback } = resolvedTrailer; 36 + 37 + return ( 38 + <section className="space-y-2"> 39 + <div className="flex items-center justify-between gap-3"> 40 + <h2 className="text-base font-semibold" style={{ color: titleColor }}> 41 + Trailer 42 + </h2> 43 + {isFallback ? <Badge variant="outline">From show</Badge> : null} 44 + </div> 45 + 46 + <div className="max-w-3xl overflow-hidden rounded-[1.25rem] border border-white/8 bg-black/20"> 47 + <div className="aspect-video w-full bg-black"> 48 + {isPlaying ? ( 49 + <iframe 50 + className="h-full w-full" 51 + src={getYouTubeEmbedUrl(trailer.key, { autoplay: true })} 52 + title={trailer.name} 53 + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 54 + allowFullScreen 55 + /> 56 + ) : ( 57 + <button 58 + type="button" 59 + className="group relative h-full w-full cursor-pointer overflow-hidden text-left" 60 + onClick={() => setIsPlaying(true)} 61 + > 62 + <img 63 + src={getYouTubeThumbnailUrl(trailer.key)} 64 + alt={trailer.name} 65 + className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.01]" 66 + /> 67 + <div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/35 to-black/5" /> 68 + <div className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 p-4"> 69 + <div className="space-y-1"> 70 + <p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/55"> 71 + Watch trailer 72 + </p> 73 + <p className="text-base font-medium text-white"> 74 + {trailer.name} 75 + </p> 76 + </div> 77 + <span className="flex size-11 shrink-0 items-center justify-center rounded-full border border-white/15 bg-black/40 text-white transition group-hover:bg-black/55"> 78 + <Play className="ml-0.5 size-4 fill-current" /> 79 + </span> 80 + </div> 81 + </button> 82 + )} 83 + </div> 84 + </div> 85 + </section> 86 + ); 87 + }
+1
apps/web/src/components/detail/index.ts
··· 7 7 export { SeasonCard } from "./SeasonCard"; 8 8 export { SeasonNav } from "./SeasonNav"; 9 9 export { TrackedStatusCard } from "./TrackedStatusCard"; 10 + export { TrailerSection } from "./TrailerSection"; 10 11 export type { 11 12 BreadcrumbItem, 12 13 ColorTheme,
+10 -1
apps/web/src/components/movie-detail/MovieDetailContent.tsx
··· 3 3 import { useMemo } from "react"; 4 4 import { CastSection } from "@/components/CastSection"; 5 5 import { CrewSection } from "@/components/CrewSection"; 6 - import { type ColorTheme, MetadataPills } from "@/components/detail"; 6 + import { 7 + type ColorTheme, 8 + MetadataPills, 9 + TrailerSection, 10 + } from "@/components/detail"; 7 11 import { GenresSection } from "@/components/GenresSection"; 8 12 import { formatDateOnly, formatRuntime } from "@/lib/utils"; 9 13 ··· 52 56 </p> 53 57 </section> 54 58 59 + <TrailerSection 60 + mediaType="movie" 61 + detailTrailer={movie?.trailer} 62 + titleColor={colors.primary} 63 + /> 55 64 <GenresSection genres={movie?.genres} colors={colors} /> 56 65 <CastSection cast={movie?.credits?.cast} colors={colors} /> 57 66 <CrewSection crew={movie?.credits?.crew} colors={colors} />
+11 -1
apps/web/src/components/show-episode-detail/ShowEpisodeContent.tsx
··· 4 4 import { useMemo } from "react"; 5 5 import { CastSection } from "@/components/CastSection"; 6 6 import { CrewSection } from "@/components/CrewSection"; 7 - import { type ColorTheme, MetadataPills } from "@/components/detail"; 7 + import { 8 + type ColorTheme, 9 + MetadataPills, 10 + TrailerSection, 11 + } from "@/components/detail"; 8 12 import { formatDateOnly, formatRuntime } from "@/lib/utils"; 9 13 10 14 type ShowEpisodeContentProps = { ··· 82 86 </p> 83 87 </section> 84 88 89 + <TrailerSection 90 + mediaType="episode" 91 + detailTrailer={episode?.trailer} 92 + showTrailer={show?.trailer} 93 + titleColor={colors.primary} 94 + /> 85 95 <CastSection 86 96 cast={show?.credits?.cast} 87 97 guestStars={episode?.guest_stars}
+7
apps/web/src/routes/shows.$showId.$title.seasons.$seasonNumber.tsx
··· 32 32 EpisodeCard, 33 33 MetadataPills, 34 34 SeasonNav, 35 + TrailerSection, 35 36 } from "@/components/detail"; 36 37 import { GenresSection } from "@/components/GenresSection"; 37 38 import { useTheme } from "@/components/theme-provider"; ··· 354 355 </p> 355 356 </section> 356 357 358 + <TrailerSection 359 + mediaType="season" 360 + detailTrailer={season?.trailer} 361 + showTrailer={show?.trailer} 362 + titleColor={colors.primary} 363 + /> 357 364 <GenresSection genres={show?.genres} colors={colors} /> 358 365 359 366 {seasonEpisodes.length > 0 && (
+6
apps/web/src/routes/shows.$showId.$title.tsx
··· 30 30 DetailHero, 31 31 MetadataPills, 32 32 SeasonCard, 33 + TrailerSection, 33 34 } from "@/components/detail"; 34 35 import { GenresSection } from "@/components/GenresSection"; 35 36 import { useTheme } from "@/components/theme-provider"; ··· 298 299 </p> 299 300 </section> 300 301 302 + <TrailerSection 303 + mediaType="show" 304 + detailTrailer={show?.trailer} 305 + titleColor={colors.primary} 306 + /> 301 307 <GenresSection genres={show?.genres} colors={colors} /> 302 308 303 309 {seasonCount > 0 && (
+29
backend/src/movies/dto/movie.dto.ts
··· 223 223 crew: TMDBCrewDto[]; 224 224 } 225 225 226 + export class TMDBTrailerDto { 227 + @ApiProperty() 228 + id: string; 229 + 230 + @ApiProperty() 231 + key: string; 232 + 233 + @ApiProperty() 234 + name: string; 235 + 236 + @ApiProperty() 237 + site: string; 238 + 239 + @ApiProperty() 240 + type: string; 241 + 242 + @ApiPropertyOptional() 243 + official?: boolean; 244 + 245 + @ApiPropertyOptional() 246 + published_at?: string; 247 + 248 + @ApiProperty({ enum: ["movie", "show", "season", "episode"] }) 249 + sourceMediaType: "movie" | "show" | "season" | "episode"; 250 + } 251 + 226 252 export class TMDBMovieDetailDto extends TMDBMovieResultDto { 227 253 @ApiPropertyOptional() 228 254 @IsOptional() ··· 244 270 245 271 @ApiPropertyOptional({ type: TMDBCreditsDto }) 246 272 credits?: TMDBCreditsDto; 273 + 274 + @ApiPropertyOptional({ type: TMDBTrailerDto }) 275 + trailer?: TMDBTrailerDto; 247 276 } 248 277 249 278 export class SearchResultsDto {
+26 -5
backend/src/movies/movies-tmdb.service.ts
··· 1 1 import { Injectable, Logger } from "@nestjs/common"; 2 2 import { ConfigService } from "@nestjs/config"; 3 + import { 4 + selectBestTMDBTrailer, 5 + type TMDBTrailer, 6 + type TMDBVideo, 7 + } from "../tmdb/tmdb-trailer.util"; 3 8 4 9 export interface TMDBMovie { 5 10 id: number; ··· 11 16 popularity: number; 12 17 vote_average: number; 13 18 vote_count: number; 19 + trailer?: TMDBTrailer; 14 20 } 15 21 16 22 export interface TMDBSearchResponse { ··· 37 43 }[]; 38 44 } 39 45 46 + type TMDBVideosResponse = { 47 + results?: TMDBVideo[]; 48 + }; 49 + 40 50 @Injectable() 41 51 export class MoviesTmdbService { 42 52 private readonly logger = new Logger(MoviesTmdbService.name); ··· 83 93 } 84 94 85 95 async getMovieDetails(movieId: string): Promise<TMDBMovie> { 86 - const response = await fetch( 87 - `${this.tmdbBaseUrl}/movie/${movieId}?api_key=${this.tmdbApiKey}`, 88 - ); 96 + const [detailResponse, videosResponse] = await Promise.all([ 97 + fetch(`${this.tmdbBaseUrl}/movie/${movieId}?api_key=${this.tmdbApiKey}`), 98 + fetch( 99 + `${this.tmdbBaseUrl}/movie/${movieId}/videos?api_key=${this.tmdbApiKey}`, 100 + ), 101 + ]); 89 102 90 - if (!response.ok) { 103 + if (!detailResponse.ok) { 91 104 throw new Error("Movie not found"); 92 105 } 93 106 94 - return response.json() as Promise<TMDBMovie>; 107 + const movie = (await detailResponse.json()) as TMDBMovie; 108 + const videosData = videosResponse.ok 109 + ? ((await videosResponse.json()) as TMDBVideosResponse) 110 + : undefined; 111 + 112 + return { 113 + ...movie, 114 + trailer: selectBestTMDBTrailer(videosData?.results, "movie"), 115 + }; 95 116 } 96 117 97 118 async getMovieCredits(movieId: string): Promise<TMDBCredits | null> {
+9 -1
backend/src/movies/movies.controller.spec.ts
··· 101 101 }); 102 102 103 103 describe("getMovieDetails", () => { 104 - it("should return movie details from TMDB with colors", async () => { 104 + it("should return movie details from TMDB with colors and trailer", async () => { 105 105 const mockMovie = { 106 106 id: 123, 107 107 title: "Test Movie", ··· 111 111 backdrop_path: "/backdrop.jpg", 112 112 runtime: 120, 113 113 vote_average: 7.5, 114 + trailer: { 115 + id: "trailer-1", 116 + key: "abc123", 117 + name: "Official Trailer", 118 + site: "YouTube", 119 + type: "Trailer", 120 + sourceMediaType: "movie", 121 + }, 114 122 }; 115 123 const mockUpsertedMovie = { 116 124 movieId: "123",
+87 -10
backend/src/movies/movies.service.spec.ts
··· 158 158 }); 159 159 160 160 describe("getMovieDetails", () => { 161 - it("should get movie details from TMDB API", async () => { 161 + it("should get movie details from TMDB API with ranked trailer", async () => { 162 162 const mockMovie = { 163 163 id: 123, 164 164 title: "Test Movie", ··· 166 166 release_date: "2024-01-01", 167 167 poster_path: "/poster.jpg", 168 168 }; 169 - mockFetch.mockResolvedValue({ 170 - ok: true, 171 - json: () => Promise.resolve(mockMovie), 172 - }); 169 + mockFetch 170 + .mockResolvedValueOnce({ 171 + ok: true, 172 + json: () => Promise.resolve(mockMovie), 173 + }) 174 + .mockResolvedValueOnce({ 175 + ok: true, 176 + json: () => 177 + Promise.resolve({ 178 + results: [ 179 + { 180 + id: "teaser-1", 181 + key: "teaser-key", 182 + name: "Teaser", 183 + site: "YouTube", 184 + type: "Teaser", 185 + official: true, 186 + }, 187 + { 188 + id: "trailer-1", 189 + key: "trailer-key", 190 + name: "Official Trailer", 191 + site: "YouTube", 192 + type: "Trailer", 193 + official: true, 194 + published_at: "2024-01-02T00:00:00.000Z", 195 + }, 196 + ], 197 + }), 198 + }); 173 199 174 200 const result = await service.getMovieDetails("123"); 175 201 176 202 expect(mockFetch).toHaveBeenCalledWith( 177 203 expect.stringContaining("/movie/123?api_key=test-api-key"), 178 204 ); 179 - expect(result).toEqual(mockMovie); 205 + expect(mockFetch).toHaveBeenCalledWith( 206 + expect.stringContaining("/movie/123/videos?api_key=test-api-key"), 207 + ); 208 + expect(result).toEqual({ 209 + ...mockMovie, 210 + trailer: { 211 + id: "trailer-1", 212 + key: "trailer-key", 213 + name: "Official Trailer", 214 + site: "YouTube", 215 + type: "Trailer", 216 + official: true, 217 + published_at: "2024-01-02T00:00:00.000Z", 218 + sourceMediaType: "movie", 219 + }, 220 + }); 180 221 }); 181 222 182 223 it("should throw error when movie not found", async () => { 183 - mockFetch.mockResolvedValue({ 184 - ok: false, 185 - status: 404, 186 - }); 224 + mockFetch 225 + .mockResolvedValueOnce({ 226 + ok: false, 227 + status: 404, 228 + }) 229 + .mockResolvedValueOnce({ 230 + ok: true, 231 + json: () => Promise.resolve({ results: [] }), 232 + }); 187 233 188 234 await expect(service.getMovieDetails("999999")).rejects.toThrow( 189 235 "Movie not found", 190 236 ); 237 + }); 238 + 239 + it("should ignore non-youtube videos", async () => { 240 + mockFetch 241 + .mockResolvedValueOnce({ 242 + ok: true, 243 + json: () => 244 + Promise.resolve({ 245 + id: 123, 246 + title: "Test Movie", 247 + }), 248 + }) 249 + .mockResolvedValueOnce({ 250 + ok: true, 251 + json: () => 252 + Promise.resolve({ 253 + results: [ 254 + { 255 + id: "vimeo-1", 256 + key: "vimeo-key", 257 + name: "Vimeo Trailer", 258 + site: "Vimeo", 259 + type: "Trailer", 260 + }, 261 + ], 262 + }), 263 + }); 264 + 265 + const result = await service.getMovieDetails("123"); 266 + 267 + expect(result.trailer).toBeUndefined(); 191 268 }); 192 269 }); 193 270
+10
backend/src/shows/dto/show.dto.ts
··· 15 15 TMDBCreditsDto, 16 16 TMDBGenreDto, 17 17 TMDBNetworkDto, 18 + TMDBTrailerDto, 18 19 } from "../../movies/dto/movie.dto"; 19 20 20 21 export class ShowDto { ··· 276 277 277 278 @ApiPropertyOptional({ type: EpisodeContextDto }) 278 279 _context?: EpisodeContextDto; 280 + 281 + @ApiPropertyOptional({ type: TMDBTrailerDto }) 282 + trailer?: TMDBTrailerDto; 279 283 } 280 284 281 285 export class TMDBSeasonDetailDto { ··· 308 312 309 313 @ApiProperty({ type: [TMDBEpisodeDto] }) 310 314 episodes: TMDBEpisodeDto[]; 315 + 316 + @ApiPropertyOptional({ type: TMDBTrailerDto }) 317 + trailer?: TMDBTrailerDto; 311 318 } 312 319 313 320 export class TMDBSeasonSummaryDto { ··· 358 365 cast: TMDBCastDto[]; 359 366 crew: TMDBCrewDto[]; 360 367 }; 368 + 369 + @ApiPropertyOptional({ type: TMDBTrailerDto }) 370 + trailer?: TMDBTrailerDto; 361 371 } 362 372 363 373 export class SearchShowsResultsDto {
+62 -15
backend/src/shows/shows-tmdb.service.ts
··· 1 1 import { Injectable, Logger } from "@nestjs/common"; 2 2 import { ConfigService } from "@nestjs/config"; 3 + import { 4 + selectBestTMDBTrailer, 5 + type TMDBTrailer, 6 + type TMDBVideo, 7 + } from "../tmdb/tmdb-trailer.util"; 3 8 4 9 export interface TMDBShow { 5 10 id: number; ··· 15 20 vote_average: number; 16 21 vote_count: number; 17 22 next_episode_to_air?: TMDBEpisode | null; 23 + trailer?: TMDBTrailer; 18 24 } 19 25 20 26 export interface TMDBSearchResponse { ··· 50 56 overview?: string; 51 57 still_path?: string; 52 58 vote_average?: number; 59 + trailer?: TMDBTrailer; 53 60 } 54 61 55 62 export interface TMDBSeason { ··· 60 67 poster_path?: string; 61 68 air_date?: string; 62 69 episodes: TMDBEpisode[]; 70 + trailer?: TMDBTrailer; 63 71 } 64 72 73 + type TMDBVideosResponse = { 74 + results?: TMDBVideo[]; 75 + }; 76 + 65 77 @Injectable() 66 78 export class ShowsTmdbService { 67 79 private readonly logger = new Logger(ShowsTmdbService.name); ··· 108 120 } 109 121 110 122 async getShowDetails(showId: string): Promise<TMDBShow> { 111 - const response = await fetch( 112 - `${this.tmdbBaseUrl}/tv/${showId}?api_key=${this.tmdbApiKey}`, 113 - ); 123 + const [detailResponse, videosResponse] = await Promise.all([ 124 + fetch(`${this.tmdbBaseUrl}/tv/${showId}?api_key=${this.tmdbApiKey}`), 125 + fetch( 126 + `${this.tmdbBaseUrl}/tv/${showId}/videos?api_key=${this.tmdbApiKey}`, 127 + ), 128 + ]); 114 129 115 - if (!response.ok) { 130 + if (!detailResponse.ok) { 116 131 throw new Error("Show not found"); 117 132 } 118 133 119 - return response.json() as Promise<TMDBShow>; 134 + const show = (await detailResponse.json()) as TMDBShow; 135 + const videosData = videosResponse.ok 136 + ? ((await videosResponse.json()) as TMDBVideosResponse) 137 + : undefined; 138 + 139 + return { 140 + ...show, 141 + trailer: selectBestTMDBTrailer(videosData?.results, "show"), 142 + }; 120 143 } 121 144 122 145 async getShowCredits(showId: string): Promise<TMDBCredits | null> { ··· 157 180 showId: string, 158 181 seasonNumber: number, 159 182 ): Promise<TMDBSeason> { 160 - const response = await fetch( 161 - `${this.tmdbBaseUrl}/tv/${showId}/season/${seasonNumber}?api_key=${this.tmdbApiKey}`, 162 - ); 163 - if (!response.ok) { 183 + const [detailResponse, videosResponse] = await Promise.all([ 184 + fetch( 185 + `${this.tmdbBaseUrl}/tv/${showId}/season/${seasonNumber}?api_key=${this.tmdbApiKey}`, 186 + ), 187 + fetch( 188 + `${this.tmdbBaseUrl}/tv/${showId}/season/${seasonNumber}/videos?api_key=${this.tmdbApiKey}`, 189 + ), 190 + ]); 191 + if (!detailResponse.ok) { 164 192 throw new Error("Season not found"); 165 193 } 166 - return response.json() as Promise<TMDBSeason>; 194 + const season = (await detailResponse.json()) as TMDBSeason; 195 + const videosData = videosResponse.ok 196 + ? ((await videosResponse.json()) as TMDBVideosResponse) 197 + : undefined; 198 + return { 199 + ...season, 200 + trailer: selectBestTMDBTrailer(videosData?.results, "season"), 201 + }; 167 202 } 168 203 169 204 async getEpisodeDetails( ··· 171 206 seasonNumber: number, 172 207 episodeNumber: number, 173 208 ): Promise<TMDBEpisode> { 174 - const response = await fetch( 175 - `${this.tmdbBaseUrl}/tv/${showId}/season/${seasonNumber}/episode/${episodeNumber}?api_key=${this.tmdbApiKey}`, 176 - ); 177 - if (!response.ok) { 209 + const [detailResponse, videosResponse] = await Promise.all([ 210 + fetch( 211 + `${this.tmdbBaseUrl}/tv/${showId}/season/${seasonNumber}/episode/${episodeNumber}?api_key=${this.tmdbApiKey}`, 212 + ), 213 + fetch( 214 + `${this.tmdbBaseUrl}/tv/${showId}/season/${seasonNumber}/episode/${episodeNumber}/videos?api_key=${this.tmdbApiKey}`, 215 + ), 216 + ]); 217 + if (!detailResponse.ok) { 178 218 throw new Error("Episode not found"); 179 219 } 180 - return response.json() as Promise<TMDBEpisode>; 220 + const episode = (await detailResponse.json()) as TMDBEpisode; 221 + const videosData = videosResponse.ok 222 + ? ((await videosResponse.json()) as TMDBVideosResponse) 223 + : undefined; 224 + return { 225 + ...episode, 226 + trailer: selectBestTMDBTrailer(videosData?.results, "episode"), 227 + }; 181 228 } 182 229 183 230 async getEpisodeContext(
+45
backend/src/shows/shows.controller.spec.ts
··· 77 77 expect(mockShowsService.searchShows).toHaveBeenCalledWith("show"); 78 78 }); 79 79 80 + it("should get show details with trailer and colors", async () => { 81 + const mockShow = { 82 + id: 123, 83 + name: "Test Show", 84 + trailer: { 85 + id: "trailer-1", 86 + key: "show-key", 87 + name: "Main Trailer", 88 + site: "YouTube", 89 + type: "Trailer", 90 + sourceMediaType: "show", 91 + }, 92 + }; 93 + mockShowsService.getShowDetails.mockResolvedValue(mockShow); 94 + mockShowsService.upsertShow.mockResolvedValue({ 95 + showId: "123", 96 + colors: { 97 + primary: "#111111", 98 + secondary: "#222222", 99 + accent: "#333333", 100 + muted: "#444444", 101 + }, 102 + }); 103 + mockShowsService.getShowCredits.mockResolvedValue({ 104 + cast: [], 105 + crew: [], 106 + }); 107 + 108 + const result = await controller.getShowDetails("123"); 109 + 110 + expect(result).toEqual({ 111 + ...mockShow, 112 + colors: { 113 + primary: "#111111", 114 + secondary: "#222222", 115 + accent: "#333333", 116 + muted: "#444444", 117 + }, 118 + credits: { 119 + cast: [], 120 + crew: [], 121 + }, 122 + }); 123 + }); 124 + 80 125 it("should get season details", async () => { 81 126 const mockSeason = { id: 1, season_number: 1, episodes: [] }; 82 127 mockShowsService.getSeasonDetails.mockResolvedValue(mockSeason);
+131 -6
backend/src/shows/shows.service.spec.ts
··· 117 117 }); 118 118 119 119 describe("getShowDetails", () => { 120 - it("should get show details from TMDB", async () => { 120 + it("should get show details from TMDB with ranked trailer", async () => { 121 121 const mockShow = { 122 122 id: 123, 123 123 name: "Test Show", 124 124 overview: "A test show", 125 125 first_air_date: "2024-01-01", 126 126 }; 127 - mockFetch.mockResolvedValue({ 128 - ok: true, 129 - json: () => Promise.resolve(mockShow), 130 - }); 127 + mockFetch 128 + .mockResolvedValueOnce({ 129 + ok: true, 130 + json: () => Promise.resolve(mockShow), 131 + }) 132 + .mockResolvedValueOnce({ 133 + ok: true, 134 + json: () => 135 + Promise.resolve({ 136 + results: [ 137 + { 138 + id: "clip-1", 139 + key: "clip-key", 140 + name: "Clip", 141 + site: "YouTube", 142 + type: "Clip", 143 + }, 144 + { 145 + id: "trailer-1", 146 + key: "show-trailer", 147 + name: "Main Trailer", 148 + site: "YouTube", 149 + type: "Trailer", 150 + official: false, 151 + }, 152 + ], 153 + }), 154 + }); 131 155 132 156 const result = await service.getShowDetails("123"); 133 157 134 158 expect(mockFetch).toHaveBeenCalledWith( 135 159 expect.stringContaining("/tv/123?api_key=test-api-key"), 136 160 ); 137 - expect(result).toEqual(mockShow); 161 + expect(mockFetch).toHaveBeenCalledWith( 162 + expect.stringContaining("/tv/123/videos?api_key=test-api-key"), 163 + ); 164 + expect(result).toEqual({ 165 + ...mockShow, 166 + trailer: { 167 + id: "trailer-1", 168 + key: "show-trailer", 169 + name: "Main Trailer", 170 + site: "YouTube", 171 + type: "Trailer", 172 + official: false, 173 + published_at: undefined, 174 + sourceMediaType: "show", 175 + }, 176 + }); 177 + }); 178 + 179 + it("should get season details with trailer", async () => { 180 + mockFetch 181 + .mockResolvedValueOnce({ 182 + ok: true, 183 + json: () => 184 + Promise.resolve({ 185 + id: 10, 186 + name: "Season 1", 187 + season_number: 1, 188 + episodes: [], 189 + }), 190 + }) 191 + .mockResolvedValueOnce({ 192 + ok: true, 193 + json: () => 194 + Promise.resolve({ 195 + results: [ 196 + { 197 + id: "season-trailer", 198 + key: "season-key", 199 + name: "Season Trailer", 200 + site: "YouTube", 201 + type: "Trailer", 202 + official: true, 203 + }, 204 + ], 205 + }), 206 + }); 207 + 208 + const result = await service.getSeasonDetails("123", 1); 209 + 210 + expect(result.trailer?.key).toBe("season-key"); 211 + expect(result.trailer?.sourceMediaType).toBe("season"); 212 + }); 213 + 214 + it("should get episode details with trailer", async () => { 215 + mockFetch 216 + .mockResolvedValueOnce({ 217 + ok: true, 218 + json: () => 219 + Promise.resolve({ 220 + id: 25, 221 + name: "Episode 2", 222 + episode_number: 2, 223 + season_number: 1, 224 + }), 225 + }) 226 + .mockResolvedValueOnce({ 227 + ok: true, 228 + json: () => 229 + Promise.resolve({ 230 + results: [ 231 + { 232 + id: "episode-teaser", 233 + key: "episode-key", 234 + name: "Episode Teaser", 235 + site: "YouTube", 236 + type: "Teaser", 237 + official: true, 238 + }, 239 + ], 240 + }), 241 + }); 242 + 243 + const result = await service.getEpisodeDetails("123", 1, 2); 244 + 245 + expect(result.trailer?.key).toBe("episode-key"); 246 + expect(result.trailer?.sourceMediaType).toBe("episode"); 138 247 }); 139 248 }); 140 249 ··· 144 253 .mockResolvedValueOnce({ 145 254 ok: true, 146 255 json: () => Promise.resolve({ number_of_seasons: 3 }), 256 + }) 257 + .mockResolvedValueOnce({ 258 + ok: true, 259 + json: () => Promise.resolve({ results: [] }), 147 260 }) 148 261 .mockResolvedValueOnce({ 149 262 ok: true, ··· 151 264 Promise.resolve({ 152 265 episodes: [{ episode_number: 10, season_number: 1 }], 153 266 }), 267 + }) 268 + .mockResolvedValueOnce({ 269 + ok: true, 270 + json: () => Promise.resolve({ results: [] }), 154 271 }) 155 272 .mockResolvedValueOnce({ 156 273 ok: true, ··· 158 275 }) 159 276 .mockResolvedValueOnce({ 160 277 ok: true, 278 + json: () => Promise.resolve({ results: [] }), 279 + }) 280 + .mockResolvedValueOnce({ 281 + ok: true, 161 282 json: () => 162 283 Promise.resolve({ 163 284 episodes: [ ··· 168 289 }, 169 290 ], 170 291 }), 292 + }) 293 + .mockResolvedValueOnce({ 294 + ok: true, 295 + json: () => Promise.resolve({ results: [] }), 171 296 }); 172 297 173 298 const result = await service.getEpisodeContext("123", 1, 10);
+88
backend/src/tmdb/tmdb-trailer.util.ts
··· 1 + export type TMDBTrailerSourceMediaType = 2 + | "movie" 3 + | "show" 4 + | "season" 5 + | "episode"; 6 + 7 + export type TMDBVideo = { 8 + id: string; 9 + key: string; 10 + name: string; 11 + site: string; 12 + type: string; 13 + official?: boolean; 14 + published_at?: string; 15 + }; 16 + 17 + export type TMDBTrailer = { 18 + id: string; 19 + key: string; 20 + name: string; 21 + site: string; 22 + type: string; 23 + official?: boolean; 24 + published_at?: string; 25 + sourceMediaType: TMDBTrailerSourceMediaType; 26 + }; 27 + 28 + type TrailerCandidate = TMDBVideo & { 29 + score: number; 30 + }; 31 + 32 + export function selectBestTMDBTrailer( 33 + videos: TMDBVideo[] | undefined, 34 + sourceMediaType: TMDBTrailerSourceMediaType, 35 + ): TMDBTrailer | undefined { 36 + if (!videos?.length) { 37 + return undefined; 38 + } 39 + 40 + const rankedCandidates: TrailerCandidate[] = videos 41 + .filter((video) => video.site === "YouTube" && video.key) 42 + .map((video, index) => ({ 43 + ...video, 44 + score: getVideoScore(video, index), 45 + })) 46 + .sort((a, b) => a.score - b.score); 47 + 48 + const best = rankedCandidates[0]; 49 + 50 + if (!best) { 51 + return undefined; 52 + } 53 + 54 + return { 55 + id: best.id, 56 + key: best.key, 57 + name: best.name, 58 + site: best.site, 59 + type: best.type, 60 + official: best.official, 61 + published_at: best.published_at, 62 + sourceMediaType, 63 + }; 64 + } 65 + 66 + function getVideoScore(video: TMDBVideo, index: number): number { 67 + const isTrailer = video.type === "Trailer"; 68 + const isTeaser = video.type === "Teaser"; 69 + const isOfficial = Boolean(video.official); 70 + 71 + if (isTrailer && isOfficial) { 72 + return index; 73 + } 74 + 75 + if (isTrailer) { 76 + return 100 + index; 77 + } 78 + 79 + if (isTeaser && isOfficial) { 80 + return 200 + index; 81 + } 82 + 83 + if (isTeaser) { 84 + return 300 + index; 85 + } 86 + 87 + return 400 + index; 88 + }
+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 { authControllerCallback, authControllerGetClientMetadata, authControllerLogin, authControllerLogout, authControllerMe, authControllerSignup, authControllerSuggestions, listsControllerAddItemToList, listsControllerAddToList, listsControllerCreateList, listsControllerDeleteList, listsControllerGetList, listsControllerGetListsForItem, listsControllerGetListsForMovie, listsControllerGetPublicUserList, listsControllerGetPublicUserLists, listsControllerGetUserLists, listsControllerInitDefaultLists, listsControllerRemoveFromList, listsControllerRemoveItemFromList, listsControllerUpdateList, moviesControllerDeleteWatchHistoryEntry, moviesControllerDiscoverMovies, moviesControllerGetMovie, moviesControllerGetMovieDetails, moviesControllerGetMovieWatchHistory, moviesControllerGetUserMovies, moviesControllerGetUserMoviesPaginated, moviesControllerMarkWatched, moviesControllerSearchMovies, moviesControllerUnmarkWatched, type Options, searchControllerDiscoverAll, searchControllerSearchAll, shelfControllerGetUserActivitySummary, shelfControllerGetUserShelf, showsControllerDeleteEpisodeWatchHistoryEntry, showsControllerDiscoverShows, showsControllerGetEpisodeDetails, showsControllerGetSeasonDetails, showsControllerGetShow, showsControllerGetShowDetails, showsControllerGetShowWatchHistory, showsControllerGetUserEpisodesPaginated, showsControllerGetUserReleaseCalendar, showsControllerGetUserShows, showsControllerGetUserUpNext, showsControllerMarkSeasonWatched, showsControllerMarkShowWatched, showsControllerMarkWatched, showsControllerSearchShows, showsControllerUnmarkWatched, usersControllerCompleteOnboarding, usersControllerDeleteMyAccount, usersControllerFetchMyTraktPublicHistory, usersControllerGetMySettings, usersControllerGetPublicProfile, usersControllerImportMyHistory, usersControllerUpdateMyProfile, usersControllerUpdateMySettings } from './sdk.gen'; 4 - export type { AddToListDto, AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponse, AuthControllerMeResponses, AuthControllerSignupData, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ClientOptions, CompleteOnboardingResponseDto, CreateListDto, DeleteUserAccountDto, EpisodeContextDto, EpisodeHistoryItemDto, EpisodeReferenceDto, FetchTraktPublicHistoryDto, FetchTraktPublicHistoryResponseDto, ImportErrorDto, ImportHistoryDto, ImportHistoryResponseDto, ImportSkipDto, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponse, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponse, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponse, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetPublicUserListData, ListsControllerGetPublicUserListErrors, ListsControllerGetPublicUserListResponse, ListsControllerGetPublicUserListResponses, ListsControllerGetPublicUserListsData, ListsControllerGetPublicUserListsResponse, ListsControllerGetPublicUserListsResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponse, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponse, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponse, ListsControllerUpdateListResponses, MarkedEpisodesResponseDto, MarkEpisodeWatchedDto, MarkSeasonWatchedDto, MarkShowWatchedDto, MediaInListDto, MovieColorsDto, MovieDto, MovieListDto, MovieListsForItemDto, MovieListSummaryDto, MovieListWithMoviesDto, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponse, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponse, MoviesControllerGetUserMoviesPaginatedResponses, MoviesControllerGetUserMoviesResponse, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponse, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponse, MoviesControllerUnmarkWatchedResponses, NormalizedImportItemDto, PaginatedEpisodesResponseDto, PaginatedMoviesResponseDto, PaginatedUpNextResponseDto, 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, 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, TmdbCastDto, TmdbCreditsDto, TmdbCrewDto, TmdbEpisodeDto, TmdbGenreDto, TmdbMovieDetailDto, TmdbMovieResultDto, TmdbNetworkDto, TmdbSeasonDetailDto, TmdbSeasonSummaryDto, TmdbShowDetailDto, TmdbShowResultDto, TrackedEpisodeDto, TrackedMovieDto, TrackedShowSummaryDto, TraktHistoryPreviewItemDto, TraktPublicProfileDto, UnifiedDiscoverResponseDto, UnifiedSearchResponseDto, UnifiedSearchResultDto, UpdateListDto, UpdateUserProfileDto, UpdateUserSettingsDto, UpNextEpisodeDto, UpNextShowDto, UserDto, UserProfileDto, UsersControllerCompleteOnboardingData, UsersControllerCompleteOnboardingErrors, UsersControllerCompleteOnboardingResponse, UsersControllerCompleteOnboardingResponses, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponse, UsersControllerDeleteMyAccountResponses, UsersControllerFetchMyTraktPublicHistoryData, UsersControllerFetchMyTraktPublicHistoryErrors, UsersControllerFetchMyTraktPublicHistoryResponse, UsersControllerFetchMyTraktPublicHistoryResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponse, UsersControllerGetMySettingsResponses, UsersControllerGetPublicProfileData, UsersControllerGetPublicProfileErrors, UsersControllerGetPublicProfileResponse, UsersControllerGetPublicProfileResponses, UsersControllerImportMyHistoryData, UsersControllerImportMyHistoryErrors, UsersControllerImportMyHistoryResponse, UsersControllerImportMyHistoryResponses, UsersControllerUpdateMyProfileData, UsersControllerUpdateMyProfileErrors, UsersControllerUpdateMyProfileResponse, UsersControllerUpdateMyProfileResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponse, UsersControllerUpdateMySettingsResponses, UserSettingsDto, WatchHistoryItemDto } from './types.gen'; 4 + export type { AddToListDto, AuthControllerCallbackData, AuthControllerGetClientMetadataData, AuthControllerGetClientMetadataResponses, AuthControllerLoginData, AuthControllerLogoutData, AuthControllerLogoutResponses, AuthControllerMeData, AuthControllerMeErrors, AuthControllerMeResponse, AuthControllerMeResponses, AuthControllerSignupData, AuthControllerSuggestionsData, AuthControllerSuggestionsResponses, ClientOptions, CompleteOnboardingResponseDto, CreateListDto, DeleteUserAccountDto, EpisodeContextDto, EpisodeHistoryItemDto, EpisodeReferenceDto, FetchTraktPublicHistoryDto, FetchTraktPublicHistoryResponseDto, ImportErrorDto, ImportHistoryDto, ImportHistoryResponseDto, ImportSkipDto, ListsControllerAddItemToListData, ListsControllerAddItemToListErrors, ListsControllerAddItemToListResponses, ListsControllerAddToListData, ListsControllerAddToListResponses, ListsControllerCreateListData, ListsControllerCreateListErrors, ListsControllerCreateListResponse, ListsControllerCreateListResponses, ListsControllerDeleteListData, ListsControllerDeleteListErrors, ListsControllerDeleteListResponses, ListsControllerGetListData, ListsControllerGetListErrors, ListsControllerGetListResponse, ListsControllerGetListResponses, ListsControllerGetListsForItemData, ListsControllerGetListsForItemErrors, ListsControllerGetListsForItemResponse, ListsControllerGetListsForItemResponses, ListsControllerGetListsForMovieData, ListsControllerGetListsForMovieResponses, ListsControllerGetPublicUserListData, ListsControllerGetPublicUserListErrors, ListsControllerGetPublicUserListResponse, ListsControllerGetPublicUserListResponses, ListsControllerGetPublicUserListsData, ListsControllerGetPublicUserListsResponse, ListsControllerGetPublicUserListsResponses, ListsControllerGetUserListsData, ListsControllerGetUserListsErrors, ListsControllerGetUserListsResponse, ListsControllerGetUserListsResponses, ListsControllerInitDefaultListsData, ListsControllerInitDefaultListsErrors, ListsControllerInitDefaultListsResponse, ListsControllerInitDefaultListsResponses, ListsControllerRemoveFromListData, ListsControllerRemoveFromListResponses, ListsControllerRemoveItemFromListData, ListsControllerRemoveItemFromListErrors, ListsControllerRemoveItemFromListResponses, ListsControllerUpdateListData, ListsControllerUpdateListErrors, ListsControllerUpdateListResponse, ListsControllerUpdateListResponses, MarkedEpisodesResponseDto, MarkEpisodeWatchedDto, MarkSeasonWatchedDto, MarkShowWatchedDto, MediaInListDto, MovieColorsDto, MovieDto, MovieListDto, MovieListsForItemDto, MovieListSummaryDto, MovieListWithMoviesDto, MoviesControllerDeleteWatchHistoryEntryData, MoviesControllerDeleteWatchHistoryEntryErrors, MoviesControllerDeleteWatchHistoryEntryResponse, MoviesControllerDeleteWatchHistoryEntryResponses, MoviesControllerDiscoverMoviesData, MoviesControllerDiscoverMoviesResponse, MoviesControllerDiscoverMoviesResponses, MoviesControllerGetMovieData, MoviesControllerGetMovieDetailsData, MoviesControllerGetMovieDetailsResponse, MoviesControllerGetMovieDetailsResponses, MoviesControllerGetMovieResponse, MoviesControllerGetMovieResponses, MoviesControllerGetMovieWatchHistoryData, MoviesControllerGetMovieWatchHistoryErrors, MoviesControllerGetMovieWatchHistoryResponse, MoviesControllerGetMovieWatchHistoryResponses, MoviesControllerGetUserMoviesData, MoviesControllerGetUserMoviesPaginatedData, MoviesControllerGetUserMoviesPaginatedResponse, MoviesControllerGetUserMoviesPaginatedResponses, MoviesControllerGetUserMoviesResponse, MoviesControllerGetUserMoviesResponses, MoviesControllerMarkWatchedData, MoviesControllerMarkWatchedErrors, MoviesControllerMarkWatchedResponse, MoviesControllerMarkWatchedResponses, MoviesControllerSearchMoviesData, MoviesControllerSearchMoviesResponse, MoviesControllerSearchMoviesResponses, MoviesControllerUnmarkWatchedData, MoviesControllerUnmarkWatchedErrors, MoviesControllerUnmarkWatchedResponse, MoviesControllerUnmarkWatchedResponses, NormalizedImportItemDto, PaginatedEpisodesResponseDto, PaginatedMoviesResponseDto, PaginatedUpNextResponseDto, 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, 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, TmdbCastDto, TmdbCreditsDto, TmdbCrewDto, TmdbEpisodeDto, TmdbGenreDto, TmdbMovieDetailDto, TmdbMovieResultDto, TmdbNetworkDto, TmdbSeasonDetailDto, TmdbSeasonSummaryDto, TmdbShowDetailDto, TmdbShowResultDto, TmdbTrailerDto, TrackedEpisodeDto, TrackedMovieDto, TrackedShowSummaryDto, TraktHistoryPreviewItemDto, TraktPublicProfileDto, UnifiedDiscoverResponseDto, UnifiedSearchResponseDto, UnifiedSearchResultDto, UpdateListDto, UpdateUserProfileDto, UpdateUserSettingsDto, UpNextEpisodeDto, UpNextShowDto, UserDto, UserProfileDto, UsersControllerCompleteOnboardingData, UsersControllerCompleteOnboardingErrors, UsersControllerCompleteOnboardingResponse, UsersControllerCompleteOnboardingResponses, UsersControllerDeleteMyAccountData, UsersControllerDeleteMyAccountErrors, UsersControllerDeleteMyAccountResponse, UsersControllerDeleteMyAccountResponses, UsersControllerFetchMyTraktPublicHistoryData, UsersControllerFetchMyTraktPublicHistoryErrors, UsersControllerFetchMyTraktPublicHistoryResponse, UsersControllerFetchMyTraktPublicHistoryResponses, UsersControllerGetMySettingsData, UsersControllerGetMySettingsErrors, UsersControllerGetMySettingsResponse, UsersControllerGetMySettingsResponses, UsersControllerGetPublicProfileData, UsersControllerGetPublicProfileErrors, UsersControllerGetPublicProfileResponse, UsersControllerGetPublicProfileResponses, UsersControllerImportMyHistoryData, UsersControllerImportMyHistoryErrors, UsersControllerImportMyHistoryResponse, UsersControllerImportMyHistoryResponses, UsersControllerUpdateMyProfileData, UsersControllerUpdateMyProfileErrors, UsersControllerUpdateMyProfileResponse, UsersControllerUpdateMyProfileResponses, UsersControllerUpdateMySettingsData, UsersControllerUpdateMySettingsErrors, UsersControllerUpdateMySettingsResponse, UsersControllerUpdateMySettingsResponses, UserSettingsDto, WatchHistoryItemDto } from './types.gen';
+15
packages/api/src/generated/types.gen.ts
··· 52 52 crew: Array<TmdbCrewDto>; 53 53 }; 54 54 55 + export type TmdbTrailerDto = { 56 + id: string; 57 + key: string; 58 + name: string; 59 + site: string; 60 + type: string; 61 + official?: boolean; 62 + published_at?: string; 63 + sourceMediaType: 'movie' | 'show' | 'season' | 'episode'; 64 + }; 65 + 55 66 export type TmdbMovieDetailDto = { 56 67 id: number; 57 68 title: string; ··· 65 76 genres?: Array<TmdbGenreDto>; 66 77 colors?: MovieColorsDto; 67 78 credits?: TmdbCreditsDto; 79 + trailer?: TmdbTrailerDto; 68 80 }; 69 81 70 82 export type MovieDto = { ··· 183 195 seasons?: Array<TmdbSeasonSummaryDto>; 184 196 colors?: MovieColorsDto; 185 197 credits?: TmdbCreditsDto; 198 + trailer?: TmdbTrailerDto; 186 199 }; 187 200 188 201 export type TmdbNetworkDto = { ··· 219 232 crew?: Array<TmdbCrewDto>; 220 233 guest_stars?: Array<TmdbCastDto>; 221 234 _context?: EpisodeContextDto; 235 + trailer?: TmdbTrailerDto; 222 236 }; 223 237 224 238 export type TmdbSeasonDetailDto = { ··· 232 246 vote_average?: number; 233 247 networks?: Array<TmdbNetworkDto>; 234 248 episodes: Array<TmdbEpisodeDto>; 249 + trailer?: TmdbTrailerDto; 235 250 }; 236 251 237 252 export type ShowDto = {
+5
packages/api/src/index.ts
··· 8 8 export { client } from './generated/client.gen'; 9 9 export { createClient, createConfig } from './generated/client/index'; 10 10 export type { Client, ClientOptions, Config, Options } from './generated/client/index'; 11 + export { 12 + getYouTubeEmbedUrl, 13 + getYouTubeThumbnailUrl, 14 + resolveDetailTrailer, 15 + } from './trailer'; 11 16 12 17 // Re-export auth utilities from custom client wrapper 13 18 export {
+66
packages/api/src/trailer.ts
··· 1 + import type { 2 + TmdbEpisodeDto, 3 + TmdbMovieDetailDto, 4 + TmdbSeasonDetailDto, 5 + TmdbShowDetailDto, 6 + TmdbTrailerDto, 7 + } from "./generated/types.gen"; 8 + 9 + type DetailMediaType = "movie" | "show" | "season" | "episode"; 10 + 11 + type DetailTrailerInput = { 12 + mediaType: DetailMediaType; 13 + detailTrailer?: 14 + | TmdbTrailerDto 15 + | TmdbMovieDetailDto["trailer"] 16 + | TmdbShowDetailDto["trailer"] 17 + | TmdbSeasonDetailDto["trailer"] 18 + | TmdbEpisodeDto["trailer"]; 19 + showTrailer?: TmdbTrailerDto | TmdbShowDetailDto["trailer"]; 20 + }; 21 + 22 + type ResolvedTrailer = { 23 + trailer: TmdbTrailerDto; 24 + isFallback: boolean; 25 + }; 26 + 27 + export function resolveDetailTrailer({ 28 + mediaType, 29 + detailTrailer, 30 + showTrailer, 31 + }: DetailTrailerInput): ResolvedTrailer | null { 32 + if (mediaType === "movie" || mediaType === "show") { 33 + return detailTrailer ? { trailer: detailTrailer, isFallback: false } : null; 34 + } 35 + 36 + if (detailTrailer) { 37 + return { trailer: detailTrailer, isFallback: false }; 38 + } 39 + 40 + if (showTrailer) { 41 + return { trailer: showTrailer, isFallback: true }; 42 + } 43 + 44 + return null; 45 + } 46 + 47 + export function getYouTubeEmbedUrl( 48 + key: string, 49 + options: { autoplay?: boolean } = {}, 50 + ): string { 51 + const params = new URLSearchParams({ 52 + rel: "0", 53 + modestbranding: "1", 54 + playsinline: "1", 55 + }); 56 + 57 + if (options.autoplay) { 58 + params.set("autoplay", "1"); 59 + } 60 + 61 + return `https://www.youtube-nocookie.com/embed/${key}?${params.toString()}`; 62 + } 63 + 64 + export function getYouTubeThumbnailUrl(key: string): string { 65 + return `https://img.youtube.com/vi/${key}/hqdefault.jpg`; 66 + }
+49 -32
pnpm-lock.yaml
··· 52 52 version: 4.1.0 53 53 expo: 54 54 specifier: ~54.0.33 55 - version: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 55 + version: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 56 56 expo-application: 57 57 specifier: ~7.0.8 58 58 version: 7.0.8(expo@54.0.33) ··· 155 155 react-native-web: 156 156 specifier: ~0.21.0 157 157 version: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) 158 + react-native-webview: 159 + specifier: ^13.16.0 160 + version: 13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 158 161 react-native-worklets: 159 162 specifier: 0.5.1 160 163 version: 0.5.1(@babel/core@7.28.6)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) ··· 7555 7558 react: ^18.0.0 || ^19.0.0 7556 7559 react-dom: ^18.0.0 || ^19.0.0 7557 7560 7561 + react-native-webview@13.16.1: 7562 + resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==} 7563 + peerDependencies: 7564 + react: '*' 7565 + react-native: '*' 7566 + 7558 7567 react-native-worklets@0.5.1: 7559 7568 resolution: {integrity: sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==} 7560 7569 peerDependencies: ··· 10406 10415 connect: 3.7.0 10407 10416 debug: 4.4.3 10408 10417 env-editor: 0.4.2 10409 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 10418 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 10410 10419 expo-server: 1.0.5 10411 10420 freeport-async: 2.0.0 10412 10421 getenv: 2.0.0 ··· 10572 10581 postcss: 8.4.49 10573 10582 resolve-from: 5.0.0 10574 10583 optionalDependencies: 10575 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 10584 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 10576 10585 transitivePeerDependencies: 10577 10586 - bufferutil 10578 10587 - supports-color ··· 10581 10590 '@expo/metro-runtime@6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)': 10582 10591 dependencies: 10583 10592 anser: 1.4.10 10584 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 10593 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 10585 10594 pretty-format: 29.7.0 10586 10595 react: 19.1.0 10587 10596 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) ··· 10640 10649 '@expo/json-file': 10.0.8 10641 10650 '@react-native/normalize-colors': 0.81.5 10642 10651 debug: 4.4.3 10643 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 10652 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 10644 10653 resolve-from: 5.0.0 10645 10654 semver: 7.7.3 10646 10655 xml2js: 0.6.0 ··· 12702 12711 react: 19.1.0 12703 12712 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 12704 12713 optionalDependencies: 12705 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 12714 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 12706 12715 12707 12716 '@react-native/assets-registry@0.81.5': {} 12708 12717 ··· 14130 14139 resolve-from: 5.0.0 14131 14140 optionalDependencies: 14132 14141 '@babel/runtime': 7.28.6 14133 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 14142 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 14134 14143 transitivePeerDependencies: 14135 14144 - '@babel/core' 14136 14145 - supports-color ··· 15005 15014 15006 15015 expo-application@7.0.8(expo@54.0.33): 15007 15016 dependencies: 15008 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15017 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15009 15018 15010 15019 expo-asset@12.0.12(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 15011 15020 dependencies: 15012 15021 '@expo/image-utils': 0.8.8 15013 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15022 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15014 15023 expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) 15015 15024 react: 19.1.0 15016 15025 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) ··· 15021 15030 dependencies: 15022 15031 '@expo/config': 12.0.13 15023 15032 '@expo/env': 2.0.8 15024 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15033 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15025 15034 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 15026 15035 transitivePeerDependencies: 15027 15036 - supports-color 15028 15037 15029 15038 expo-dev-client@6.0.20(expo@54.0.33): 15030 15039 dependencies: 15031 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15040 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15032 15041 expo-dev-launcher: 6.0.20(expo@54.0.33) 15033 15042 expo-dev-menu: 7.0.18(expo@54.0.33) 15034 15043 expo-dev-menu-interface: 2.0.0(expo@54.0.33) ··· 15040 15049 expo-dev-launcher@6.0.20(expo@54.0.33): 15041 15050 dependencies: 15042 15051 ajv: 8.17.1 15043 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15052 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15044 15053 expo-dev-menu: 7.0.18(expo@54.0.33) 15045 15054 expo-manifests: 1.0.10(expo@54.0.33) 15046 15055 transitivePeerDependencies: ··· 15048 15057 15049 15058 expo-dev-menu-interface@2.0.0(expo@54.0.33): 15050 15059 dependencies: 15051 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15060 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15052 15061 15053 15062 expo-dev-menu@7.0.18(expo@54.0.33): 15054 15063 dependencies: 15055 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15064 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15056 15065 expo-dev-menu-interface: 2.0.0(expo@54.0.33) 15057 15066 15058 15067 expo-device@8.0.10(expo@54.0.33): 15059 15068 dependencies: 15060 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15069 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15061 15070 ua-parser-js: 0.7.41 15062 15071 15063 15072 expo-document-picker@55.0.8(expo@54.0.33): 15064 15073 dependencies: 15065 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15074 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15066 15075 15067 15076 expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)): 15068 15077 dependencies: 15069 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15078 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15070 15079 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 15071 15080 15072 15081 expo-font@14.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 15073 15082 dependencies: 15074 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15083 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15075 15084 fontfaceobserver: 2.3.0 15076 15085 react: 19.1.0 15077 15086 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 15078 15087 15079 15088 expo-haptics@15.0.8(expo@54.0.33): 15080 15089 dependencies: 15081 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15090 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15082 15091 15083 15092 expo-image@3.0.11(expo@54.0.33)(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 15084 15093 dependencies: 15085 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15094 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15086 15095 react: 19.1.0 15087 15096 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 15088 15097 optionalDependencies: ··· 15092 15101 15093 15102 expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0): 15094 15103 dependencies: 15095 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15104 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15096 15105 react: 19.1.0 15097 15106 15098 15107 expo-linear-gradient@15.0.8(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 15099 15108 dependencies: 15100 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15109 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15101 15110 react: 19.1.0 15102 15111 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 15103 15112 ··· 15113 15122 15114 15123 expo-localization@17.0.8(expo@54.0.33)(react@19.1.0): 15115 15124 dependencies: 15116 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15125 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15117 15126 react: 19.1.0 15118 15127 rtl-detect: 1.1.2 15119 15128 15120 15129 expo-manifests@1.0.10(expo@54.0.33): 15121 15130 dependencies: 15122 15131 '@expo/config': 12.0.13 15123 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15132 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15124 15133 expo-json-utils: 0.15.0 15125 15134 transitivePeerDependencies: 15126 15135 - supports-color ··· 15151 15160 client-only: 0.0.1 15152 15161 debug: 4.4.3 15153 15162 escape-string-regexp: 4.0.0 15154 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15163 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15155 15164 expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) 15156 15165 expo-linking: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15157 15166 expo-server: 1.0.5 ··· 15184 15193 15185 15194 expo-secure-store@15.0.8(expo@54.0.33): 15186 15195 dependencies: 15187 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15196 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15188 15197 15189 15198 expo-server@1.0.5: {} 15190 15199 15191 15200 expo-splash-screen@31.0.13(expo@54.0.33): 15192 15201 dependencies: 15193 15202 '@expo/prebuild-config': 54.0.8(expo@54.0.33) 15194 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15203 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15195 15204 transitivePeerDependencies: 15196 15205 - supports-color 15197 15206 ··· 15203 15212 15204 15213 expo-symbols@1.0.8(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)): 15205 15214 dependencies: 15206 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15215 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15207 15216 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 15208 15217 sf-symbols-typescript: 2.2.0 15209 15218 ··· 15211 15220 dependencies: 15212 15221 '@react-native/normalize-colors': 0.81.5 15213 15222 debug: 4.4.3 15214 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15223 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15215 15224 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 15216 15225 optionalDependencies: 15217 15226 react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ··· 15220 15229 15221 15230 expo-updates-interface@2.0.0(expo@54.0.33): 15222 15231 dependencies: 15223 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15232 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15224 15233 15225 15234 expo-web-browser@15.0.10(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)): 15226 15235 dependencies: 15227 - expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15236 + expo: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15228 15237 react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 15229 15238 15230 - expo@54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 15239 + expo@54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 15231 15240 dependencies: 15232 15241 '@babel/runtime': 7.28.6 15233 15242 '@expo/cli': 54.0.23(expo-router@6.0.23)(expo@54.0.33)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)) ··· 15254 15263 whatwg-url-without-unicode: 8.0.0-3 15255 15264 optionalDependencies: 15256 15265 '@expo/metro-runtime': 6.1.2(expo@54.0.33)(react-dom@19.1.0(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15266 + react-native-webview: 13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) 15257 15267 transitivePeerDependencies: 15258 15268 - '@babel/core' 15259 15269 - bufferutil ··· 17672 17682 styleq: 0.1.3 17673 17683 transitivePeerDependencies: 17674 17684 - encoding 17685 + 17686 + react-native-webview@13.16.1(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 17687 + dependencies: 17688 + escape-string-regexp: 4.0.0 17689 + invariant: 2.2.4 17690 + react: 19.1.0 17691 + react-native: 0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0) 17675 17692 17676 17693 react-native-worklets@0.5.1(@babel/core@7.28.6)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): 17677 17694 dependencies: