Skip to content

Commit 9b5f7a2

Browse files
dannyvfilmsclaude
andcommitted
Add media details carousel with trailer/photo gallery and lightbox
- New TMDB (movie/tv/season) and IGDB (game) carousel data fetching, lazily loaded via an htmx fragment separate from the main page render. - Details page top section splits into a CSS grid (poster+carousel, title+actions, chips, synopsis) for carousel-supported media types; score chips and action buttons extracted into standalone partials so the non-carousel layout can keep composing them exactly as before. - Carousel: prev/next navigation, inline YouTube trailer playback with a custom thumbnail (falls back from maxresdefault to hqdefault when no high-res thumbnail exists), and a full-screen lightbox preserving original image aspect ratio. - IGDB images use t_1080p instead of t_screenshot_big_2x for the main pane/lightbox, since the latter crops to a fixed 16:9 canvas rather than just scaling. - Carousel is temporarily disabled below the md breakpoint while the mobile layout is still being polished; mobile keeps today's original stacked layout unchanged. Fixes #850 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent cb94073 commit 9b5f7a2

18 files changed

Lines changed: 1443 additions & 744 deletions

src/app/carousel.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Media-details carousel (trailer + photos) resolution.
2+
3+
Distinct from ``backdrops.py``: that module returns a single horizontal image
4+
(or ``None``) for card art, eagerly consulted on hot paths. This module
5+
returns a multi-item {"video", "photos"} payload for the details page's
6+
carousel, fetched lazily (only when the page's carousel fragment is
7+
requested) and only for the media types/sources that actually expose a
8+
trailer or photo gallery.
9+
"""
10+
11+
from app.models import MediaTypes, Sources
12+
from app.providers import tmdb
13+
14+
_TMDB_CAROUSEL_TYPES = (MediaTypes.MOVIE.value, MediaTypes.TV.value, MediaTypes.SEASON.value)
15+
16+
17+
def carousel_supported(media_type, source):
18+
"""Return whether the given media_type/source combination can have a carousel."""
19+
if source == Sources.TMDB.value and media_type in _TMDB_CAROUSEL_TYPES:
20+
return True
21+
return bool(source == Sources.IGDB.value and media_type == MediaTypes.GAME.value)
22+
23+
24+
def resolve_carousel_media(media_type, source, media_id, *, season_number=None) -> dict | None:
25+
"""Return {"video": {...}|None, "photos": [{"url", "thumb_url"}, ...]} or None."""
26+
if source == Sources.TMDB.value and media_type in _TMDB_CAROUSEL_TYPES:
27+
data = tmdb.carousel_media(media_type, media_id, season_number=season_number)
28+
photos = [
29+
{
30+
"url": tmdb.get_carousel_image_url(photo["file_path"], size="w1280"),
31+
"thumb_url": tmdb.get_carousel_image_url(photo["file_path"], size="w300"),
32+
}
33+
for photo in data["photos"]
34+
]
35+
if not data["video"] and not photos:
36+
return None
37+
return {"video": data["video"], "photos": photos}
38+
39+
if source == Sources.IGDB.value and media_type == MediaTypes.GAME.value:
40+
from lists.models import CustomList
41+
42+
data = CustomList()._get_igdb_carousel_media(media_id)
43+
photos = [
44+
{
45+
# t_screenshot_big_2x and similar named IGDB transforms crop to a
46+
# fixed canvas; t_1080p only caps resolution, so it keeps the
47+
# source image's real aspect ratio for the main pane/lightbox.
48+
"url": f"https://images.igdb.com/igdb/image/upload/t_1080p/{image_id}.jpg",
49+
"thumb_url": (
50+
f"https://images.igdb.com/igdb/image/upload/"
51+
f"t_screenshot_big_2x/{image_id}.jpg"
52+
),
53+
}
54+
for image_id in data["photos"]
55+
]
56+
if not data["video"] and not photos:
57+
return None
58+
return {"video": data["video"], "photos": photos}
59+
60+
return None

src/app/media_details_views.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
from django.utils import timezone
1212
from django.views.decorators.http import require_GET
1313

14+
from app import (
15+
carousel as carousel_media,
16+
)
1417
from app import (
1518
config,
1619
credits, # noqa: A004 # app.credits module, not the site builtin
@@ -67,7 +70,11 @@
6770
_resolve_detail_tag_genres,
6871
)
6972
from app.track_modal_views import _DummyPodcastWrapper
70-
from app.view_constants import DETAIL_SECONDARY_FRAGMENT, force_live_metadata_cache_key
73+
from app.view_constants import (
74+
DETAIL_CAROUSEL_FRAGMENT,
75+
DETAIL_SECONDARY_FRAGMENT,
76+
force_live_metadata_cache_key,
77+
)
7178
from lists.views_helpers import get_public_list_for_item
7279

7380
logger = logging.getLogger(__name__)
@@ -172,7 +179,15 @@ def media_details(
172179
title,
173180
):
174181
"""Return the details page for a media item."""
182+
if request.GET.get("fragment") == DETAIL_CAROUSEL_FRAGMENT:
183+
return render(
184+
request,
185+
"app/components/detail_carousel_fragment.html",
186+
{"carousel": carousel_media.resolve_carousel_media(media_type, source, media_id)},
187+
)
188+
175189
detail_view_started_at = time.perf_counter()
190+
carousel_supported = carousel_media.carousel_supported(media_type, source)
176191
render_secondary_only = (
177192
request.GET.get("fragment") == DETAIL_SECONDARY_FRAGMENT
178193
and media_type != MediaTypes.PODCAST.value
@@ -181,6 +196,11 @@ def media_details(
181196
not render_secondary_only and media_type != MediaTypes.PODCAST.value
182197
)
183198
detail_return_url = _detail_request_url(request)
199+
detail_carousel_fragment_url = (
200+
_detail_request_url(request, fragment=DETAIL_CAROUSEL_FRAGMENT)
201+
if carousel_supported
202+
else None
203+
)
184204
detail_secondary_fragment_url = _detail_request_url(
185205
request,
186206
fragment=DETAIL_SECONDARY_FRAGMENT,
@@ -2108,6 +2128,8 @@ def _activity_key(entry):
21082128
"detail_secondary_fragment_url": detail_secondary_fragment_url,
21092129
"defer_detail_secondary": defer_detail_secondary,
21102130
"render_secondary_only": render_secondary_only,
2131+
"carousel_supported": carousel_supported,
2132+
"detail_carousel_fragment_url": detail_carousel_fragment_url,
21112133
**_build_detail_person_rows(media_metadata, item=detail_item),
21122134
}
21132135
logger.info(

src/app/providers/tmdb.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@
4747
"season/{season}/watch/providers",
4848
)
4949
TMDB_SEASON_CACHE_VERSION = 4
50+
# Media-details carousel (trailer + photos): fetched lazily, its own cache
51+
# entry, independent of the movie/tv/season append_to_response payloads above.
52+
CAROUSEL_CACHE_TTL_SUCCESS = 60 * 60 * 24 * 7
53+
CAROUSEL_CACHE_TTL_ABSENT = 60 * 60 * 24
5054

5155

5256
def base_params(language=None):
@@ -1092,6 +1096,90 @@ def tv(media_id, language=None):
10921096
return data
10931097

10941098

1099+
def _carousel_cache_key(media_type, media_id, season_number=None):
1100+
"""Return the cache key for a media-details carousel payload."""
1101+
key = f"tmdb_carousel_{media_type}_{media_id}"
1102+
if season_number is not None:
1103+
key += f"_s{season_number}"
1104+
return key
1105+
1106+
1107+
def _parse_carousel_video(response):
1108+
"""Return the single best YouTube trailer from a TMDB videos append, if any."""
1109+
videos = response.get("videos", {}).get("results", []) or []
1110+
youtube_videos = [v for v in videos if v.get("site") == "YouTube" and v.get("key")]
1111+
trailers = [v for v in youtube_videos if v.get("type") == "Trailer"]
1112+
video = next(iter(trailers), None) or next(iter(youtube_videos), None)
1113+
if not video:
1114+
return None
1115+
return {
1116+
"key": video["key"],
1117+
"name": video.get("name", ""),
1118+
"type": video.get("type", ""),
1119+
}
1120+
1121+
1122+
def _parse_carousel_photos(response):
1123+
"""Return normalized backdrop photos from a TMDB images append."""
1124+
backdrops = response.get("images", {}).get("backdrops", []) or []
1125+
return [
1126+
{
1127+
"file_path": photo["file_path"],
1128+
"width": photo.get("width"),
1129+
"height": photo.get("height"),
1130+
}
1131+
for photo in backdrops
1132+
if photo.get("file_path")
1133+
]
1134+
1135+
1136+
def carousel_media(media_type, media_id, season_number=None, language=None):
1137+
"""Return {"video": {...}|None, "photos": [...]} for the details carousel.
1138+
1139+
Fetched lazily via its own request/cache entry, never folded into the
1140+
movie/tv/season append_to_response calls (those are already close to
1141+
TMDB_APPEND_TO_RESPONSE_MAX_REMOTE_CALLS).
1142+
"""
1143+
cache_key = _carousel_cache_key(media_type, media_id, season_number)
1144+
data = cache.get(cache_key)
1145+
if data is not None:
1146+
return data
1147+
1148+
if media_type == MediaTypes.MOVIE.value:
1149+
url = f"{base_url}/movie/{media_id}"
1150+
elif media_type == MediaTypes.SEASON.value:
1151+
url = f"{base_url}/tv/{media_id}/season/{season_number}"
1152+
else:
1153+
url = f"{base_url}/tv/{media_id}"
1154+
1155+
params = {
1156+
**base_params(language),
1157+
"append_to_response": "videos,images",
1158+
}
1159+
1160+
try:
1161+
response = services.api_request(
1162+
Sources.TMDB.value,
1163+
"GET",
1164+
url,
1165+
params=params,
1166+
)
1167+
except requests.exceptions.HTTPError as error:
1168+
handle_error(error)
1169+
1170+
data = {
1171+
"video": _parse_carousel_video(response),
1172+
"photos": _parse_carousel_photos(response),
1173+
}
1174+
ttl = (
1175+
CAROUSEL_CACHE_TTL_SUCCESS
1176+
if data["video"] or data["photos"]
1177+
else CAROUSEL_CACHE_TTL_ABSENT
1178+
)
1179+
cache.set(cache_key, data, ttl)
1180+
return data
1181+
1182+
10951183
def _build_tv_crew(response):
10961184
"""Build sorted crew list for a TV show, prepending created_by as Creators."""
10971185
crew = get_crew_credits(response.get("aggregate_credits", {}), is_aggregate=True)
@@ -1566,6 +1654,13 @@ def get_profile_image_url(path, size="w185"):
15661654
return settings.IMG_NONE
15671655

15681656

1657+
def get_carousel_image_url(path, size="w1280"):
1658+
"""Return a media-details carousel image URL (backdrops/photos)."""
1659+
if path:
1660+
return f"https://image.tmdb.org/t/p/{size}{path}"
1661+
return settings.IMG_NONE
1662+
1663+
15691664
def _upgrade_person_image_url(url):
15701665
"""Upgrade legacy person profile URLs to a higher-resolution size."""
15711666
if not url:

src/app/season_details_views.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from django.utils import timezone
1111
from django.views.decorators.http import require_GET
1212

13+
from app import carousel as carousel_media
1314
from app import config, helpers
1415
from app.activity_builders import (
1516
_normalize_detail_episode_actions,
@@ -48,6 +49,7 @@
4849
_resolve_detail_tag_genres,
4950
)
5051
from app.view_constants import (
52+
DETAIL_CAROUSEL_FRAGMENT,
5153
DETAIL_SECONDARY_FRAGMENT,
5254
LOCAL_ONLY_MISSING_SEASON_BANNER,
5355
)
@@ -70,10 +72,32 @@ def season_details(
7072
parent_media_type=None,
7173
):
7274
"""Return the details page for a season."""
75+
if request.GET.get("fragment") == DETAIL_CAROUSEL_FRAGMENT:
76+
return render(
77+
request,
78+
"app/components/detail_carousel_fragment.html",
79+
{
80+
"carousel": carousel_media.resolve_carousel_media(
81+
MediaTypes.SEASON.value,
82+
source,
83+
media_id,
84+
season_number=season_number,
85+
),
86+
},
87+
)
88+
7389
detail_view_started_at = time.perf_counter()
90+
carousel_supported = carousel_media.carousel_supported(
91+
MediaTypes.SEASON.value, source
92+
)
7493
render_secondary_only = request.GET.get("fragment") == DETAIL_SECONDARY_FRAGMENT
7594
defer_detail_secondary = not render_secondary_only
7695
detail_return_url = _detail_request_url(request)
96+
detail_carousel_fragment_url = (
97+
_detail_request_url(request, fragment=DETAIL_CAROUSEL_FRAGMENT)
98+
if carousel_supported
99+
else None
100+
)
77101
detail_secondary_fragment_url = _detail_request_url(
78102
request,
79103
fragment=DETAIL_SECONDARY_FRAGMENT,
@@ -1017,6 +1041,8 @@ def _fetch_episode_items(numbers):
10171041
"detail_secondary_fragment_url": detail_secondary_fragment_url,
10181042
"defer_detail_secondary": defer_detail_secondary,
10191043
"render_secondary_only": render_secondary_only,
1044+
"carousel_supported": carousel_supported,
1045+
"detail_carousel_fragment_url": detail_carousel_fragment_url,
10201046
**_build_detail_person_rows(season_metadata),
10211047
}
10221048
logger.info(

src/app/view_constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"server, to restore provider data and progress tracking."
77
)
88
DETAIL_SECONDARY_FRAGMENT = "secondary"
9+
DETAIL_CAROUSEL_FRAGMENT = "carousel"
910

1011
FORCE_LIVE_METADATA_TIMEOUT = 60 # seconds
1112

0 commit comments

Comments
 (0)