Skip to content

Commit 46515f9

Browse files
committed
add api support for backdrop images from tmdb
1 parent 56a1e16 commit 46515f9

9 files changed

Lines changed: 430 additions & 146 deletions

File tree

src/api/contract_serializers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ class CompleteMediaResponseSerializer(serializers.Serializer):
148148
title = serializers.CharField(allow_blank=True, allow_null=True)
149149
max_progress = serializers.IntegerField()
150150
image = serializers.CharField(allow_blank=True, allow_null=True)
151+
# FORK: 16:9 artwork
152+
backdrop = serializers.CharField(allow_null=True)
151153
synopsis = serializers.CharField(allow_blank=True, allow_null=True)
152154
genres = serializers.ListField(child=serializers.CharField(), allow_null=True)
153155
score = serializers.FloatField(allow_null=True)

src/api/contracts/openapi.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2571,6 +2571,9 @@ components:
25712571
image:
25722572
type: string
25732573
nullable: true
2574+
backdrop:
2575+
type: string
2576+
nullable: true
25742577
synopsis:
25752578
type: string
25762579
nullable: true
@@ -2621,6 +2624,7 @@ components:
26212624
type: object
26222625
additionalProperties: {}
26232626
required:
2627+
- backdrop
26242628
- cast
26252629
- consumptions
26262630
- consumptions_number
@@ -2669,6 +2673,9 @@ components:
26692673
image:
26702674
type: string
26712675
nullable: true
2676+
backdrop:
2677+
type: string
2678+
nullable: true
26722679
synopsis:
26732680
type: string
26742681
nullable: true
@@ -2720,6 +2727,7 @@ components:
27202727
type: object
27212728
additionalProperties: {}
27222729
required:
2730+
- backdrop
27232731
- cast
27242732
- consumptions
27252733
- consumptions_number

src/api/serializers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.utils.timezone import now
33
from rest_framework import serializers
44

5+
from app.backdrops import resolve_backdrop # FORK: horizontal artwork
56
from app.helpers import build_provider_ids
67
from app.models import (
78
TV,
@@ -184,6 +185,8 @@ def to_representation(self, instance):
184185
"title": episode.get("name"),
185186
"max_progress": 1,
186187
"image": image,
188+
# FORK: show-level backdrop
189+
"backdrop": resolve_backdrop(media_metadata),
187190
"synopsis": episode.get("overview"),
188191
"genres": media_metadata.get("genres", []),
189192
"score": float(episode.get("vote_average")),
@@ -349,6 +352,8 @@ def to_representation(self, instance):
349352
if media_metadata.get("max_progress") is not None
350353
else 1,
351354
"image": media_metadata.get("image"),
355+
# FORK: 16:9 artwork
356+
"backdrop": resolve_backdrop(media_metadata),
352357
"synopsis": media_metadata.get("synopsis"),
353358
"genres": media_metadata.get("genres"),
354359
"score": float(media_metadata.get("score"))

src/api/tests/helpers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def check_complete_media_structure(test_case, item):
8080
test_case.assertIn("media_type", item)
8181
test_case.assertIn("title", item)
8282
test_case.assertIn("image", item)
83+
test_case.assertIn("backdrop", item)
8384
test_case.assertIn("synopsis", item)
8485
test_case.assertIn("genres", item)
8586
test_case.assertIn("score", item)

src/api/tests/test_fork_media.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from http import HTTPStatus as HTTP # noqa: N814
44
from unittest.mock import patch
55

6+
from django.core.cache import cache
7+
68
from app.models import (
79
CollectionEntry,
810
ComicIssue,
@@ -15,6 +17,8 @@
1517

1618
from .base import FloppyApiTestCase
1719

20+
BACKDROP_URL = "https://image.tmdb.org/t/p/w1280/backdrop.jpg"
21+
1822

1923
class ForkMediaTypeOverlayTests(FloppyApiTestCase):
2024
"""Fork media types are first-class citizens of the media endpoints."""
@@ -258,3 +262,128 @@ def test_collection_add_requires_item(self):
258262
**self.auth_headers,
259263
)
260264
self.assertEqual(response.status_code, HTTP.BAD_REQUEST)
265+
266+
267+
class ForkBackdropFieldTests(FloppyApiTestCase):
268+
"""Detail responses carry 16:9 artwork alongside the portrait poster."""
269+
270+
def setUp(self):
271+
"""Start every test with a cold backdrop cache."""
272+
super().setUp()
273+
cache.clear()
274+
self.addCleanup(cache.clear)
275+
self.tv_item = self.items_by_type[MediaTypes.TV.value][0]
276+
277+
def _tv_metadata(self):
278+
return {
279+
"media_id": self.tv_item.media_id,
280+
"source": self.tv_item.source,
281+
"source_url": "https://www.themoviedb.org/tv/1",
282+
"media_type": MediaTypes.TV.value,
283+
"title": self.tv_item.title,
284+
"max_progress": 1,
285+
"image": self.tv_item.image,
286+
"synopsis": "",
287+
"genres": [],
288+
"score": None,
289+
"score_count": None,
290+
"details": {},
291+
"related": {"seasons": [], "recommendations": []},
292+
}
293+
294+
def _get_detail(self):
295+
return self.call_api(
296+
"get",
297+
"api_media_detail",
298+
args=(MediaTypes.TV.value, self.tv_item.source, self.tv_item.media_id),
299+
headers=self.auth_headers,
300+
)
301+
302+
@patch("api.views.services.get_media_metadata")
303+
@patch("lists.models.CustomList._get_tmdb_backdrop")
304+
def test_detail_serves_cached_backdrop_without_calling_tmdb(
305+
self,
306+
mock_backdrop,
307+
mock_metadata,
308+
):
309+
mock_metadata.return_value = self._tv_metadata()
310+
cache.set(f"tmdb_backdrop_tv_{self.tv_item.media_id}", BACKDROP_URL, 60)
311+
312+
response = self._get_detail()
313+
314+
self.assertEqual(response.status_code, HTTP.OK)
315+
payload = response.json()
316+
self.assertEqual(payload["backdrop"], BACKDROP_URL)
317+
# The poster is unchanged — backdrop is an addition, not a replacement.
318+
self.assertEqual(payload["image"], self.tv_item.image)
319+
mock_backdrop.assert_not_called()
320+
321+
@patch("api.views.services.get_media_metadata")
322+
@patch("lists.models.CustomList._get_tmdb_backdrop", return_value=BACKDROP_URL)
323+
def test_detail_fetches_backdrop_when_cache_is_cold(
324+
self,
325+
mock_backdrop,
326+
mock_metadata,
327+
):
328+
"""A single detail view may pay for one provider call; the result caches."""
329+
mock_metadata.return_value = self._tv_metadata()
330+
331+
response = self._get_detail()
332+
333+
self.assertEqual(response.json()["backdrop"], BACKDROP_URL)
334+
mock_backdrop.assert_called_once_with(
335+
MediaTypes.TV.value,
336+
self.tv_item.media_id,
337+
)
338+
339+
@patch("api.views.services.get_media_metadata")
340+
@patch("lists.models.CustomList._get_tmdb_backdrop", return_value=None)
341+
def test_detail_reports_null_when_no_backdrop_exists(
342+
self,
343+
mock_backdrop,
344+
mock_metadata,
345+
):
346+
"""Clients need to distinguish "no artwork" from "a poster", so: null."""
347+
mock_metadata.return_value = self._tv_metadata()
348+
349+
payload = self._get_detail().json()
350+
351+
self.assertIn("backdrop", payload)
352+
self.assertIsNone(payload["backdrop"])
353+
354+
@patch("api.views.services.get_media_metadata")
355+
@patch("lists.models.CustomList._get_tmdb_backdrop", return_value=BACKDROP_URL)
356+
def test_episode_detail_carries_the_show_backdrop(
357+
self,
358+
mock_backdrop,
359+
mock_metadata,
360+
):
361+
"""Episode stills are often missing; the show backdrop covers that gap."""
362+
season_item = self.items_by_type[MediaTypes.SEASON.value][0]
363+
episode_item = self.items_by_type[MediaTypes.EPISODE.value][0]
364+
mock_metadata.return_value = self.build_episode_metadata(
365+
tv_item=self.tv_item,
366+
season_number=season_item.season_number,
367+
episode_number=episode_item.episode_number,
368+
title=episode_item.title,
369+
image=episode_item.image,
370+
)
371+
372+
response = self.call_api(
373+
"get",
374+
"api_media_episode_detail",
375+
args=(
376+
MediaTypes.TV.value,
377+
self.tv_item.source,
378+
self.tv_item.media_id,
379+
season_item.season_number,
380+
episode_item.episode_number,
381+
),
382+
headers=self.auth_headers,
383+
)
384+
385+
self.assertEqual(response.status_code, HTTP.OK)
386+
payload = response.json()
387+
self.assertEqual(payload["backdrop"], BACKDROP_URL)
388+
# The fixture has no still_path, which is exactly the gap being filled.
389+
self.assertIsNone(payload["image"])

src/api/tests/test_media_core.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,7 @@ def test_media_detail_get_returns_expected_shape(self, mock_metadata):
694694
"title",
695695
"max_progress",
696696
"image",
697+
"backdrop",
697698
"synopsis",
698699
"genres",
699700
"score",

src/app/backdrops.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Horizontal (16:9) backdrop resolution shared by the web UI and the API.
2+
3+
Item.image holds a portrait poster (TMDB w500); backdrops are never stored on
4+
the model, only fetched from the provider and cached in Redis for 7 days by
5+
``lists.models.CustomList``.
6+
7+
``resolve_backdrop`` returns ``None`` when no backdrop exists, leaving the
8+
choice of fallback to the caller: the web UI falls back to the poster so a
9+
card always renders, while the API reports ``null`` so clients can pick their
10+
own artwork.
11+
"""
12+
13+
from django.conf import settings
14+
from django.core.cache import cache
15+
16+
from app.models import MediaTypes, Sources
17+
18+
# Episodes and seasons share their show's media_id, and TMDB files anime under
19+
# /tv, so all three resolve against the show-level backdrop.
20+
_SHOW_LEVEL_TYPES = (
21+
MediaTypes.EPISODE.value,
22+
MediaTypes.SEASON.value,
23+
MediaTypes.ANIME.value,
24+
)
25+
_TVDB_TYPES = (MediaTypes.TV.value, *_SHOW_LEVEL_TYPES)
26+
27+
28+
def _read(item, key):
29+
"""Read a field from either a serialized dict or a model instance."""
30+
if isinstance(item, dict):
31+
return item.get(key)
32+
return getattr(item, key, None)
33+
34+
35+
def _identity(item):
36+
"""Return (source, media_type, media_id) or None when incomplete."""
37+
if not item:
38+
return None
39+
source = _read(item, "source")
40+
media_type = _read(item, "media_type")
41+
media_id = _read(item, "media_id")
42+
if not source or not media_type or not media_id:
43+
return None
44+
return source, media_type, media_id
45+
46+
47+
def _tvdb_tmdb_id(item):
48+
"""Return the TMDB cross-reference the TVDB provider stores, if any."""
49+
return (_read(item, "provider_external_ids") or {}).get("tmdb_id")
50+
51+
52+
def _usable(backdrop):
53+
"""Treat the placeholder image as no backdrop at all."""
54+
if backdrop and backdrop != settings.IMG_NONE:
55+
return backdrop
56+
return None
57+
58+
59+
def cached_backdrop(item) -> str | None:
60+
"""Return an already-cached backdrop without triggering provider lookups."""
61+
identity = _identity(item)
62+
if identity is None:
63+
return None
64+
source, media_type, media_id = identity
65+
66+
if source == Sources.TMDB.value:
67+
backdrop_media_type = (
68+
MediaTypes.TV.value if media_type in _SHOW_LEVEL_TYPES else media_type
69+
)
70+
if backdrop_media_type in (MediaTypes.MOVIE.value, MediaTypes.TV.value):
71+
return _usable(
72+
cache.get(f"tmdb_backdrop_{backdrop_media_type}_{media_id}"),
73+
)
74+
75+
if source == Sources.TVDB.value and media_type in _TVDB_TYPES:
76+
tmdb_id = _tvdb_tmdb_id(item)
77+
if tmdb_id:
78+
return _usable(cache.get(f"tmdb_backdrop_tv_{tmdb_id}"))
79+
80+
if source == Sources.IGDB.value and media_type == MediaTypes.GAME.value:
81+
return _usable(cache.get(f"igdb_backdrop_{media_id}"))
82+
83+
return None
84+
85+
86+
def resolve_backdrop(item, *, allow_network=True) -> str | None:
87+
"""Return a horizontal backdrop URL for an item, or None if there is none.
88+
89+
With ``allow_network=False`` only the Redis cache is consulted, so callers
90+
on a hot path never block on a provider request.
91+
"""
92+
cached = cached_backdrop(item)
93+
if cached:
94+
return cached
95+
96+
identity = _identity(item)
97+
if identity is None or not allow_network:
98+
return None
99+
source, media_type, media_id = identity
100+
101+
try:
102+
from lists.models import CustomList
103+
except Exception:
104+
return None
105+
106+
custom_list = CustomList()
107+
108+
if source == Sources.TMDB.value and media_type in _SHOW_LEVEL_TYPES:
109+
return _fetch(custom_list._get_tmdb_backdrop, MediaTypes.TV.value, media_id)
110+
111+
if source == Sources.TMDB.value and media_type in (
112+
MediaTypes.MOVIE.value,
113+
MediaTypes.TV.value,
114+
):
115+
return _fetch(custom_list._get_tmdb_backdrop, media_type, media_id)
116+
117+
if source == Sources.TVDB.value and media_type in _TVDB_TYPES:
118+
tmdb_id = _tvdb_tmdb_id(item)
119+
if tmdb_id:
120+
return _fetch(custom_list._get_tmdb_backdrop, MediaTypes.TV.value, tmdb_id)
121+
122+
if source == Sources.IGDB.value and media_type == MediaTypes.GAME.value:
123+
return _fetch(custom_list._get_igdb_backdrop, media_id)
124+
125+
return None
126+
127+
128+
def _fetch(getter, *args) -> str | None:
129+
"""Call a provider backdrop getter; artwork is never worth raising over."""
130+
try:
131+
return _usable(getter(*args))
132+
except Exception: # deliberate best-effort; failure is non-fatal here
133+
return None

0 commit comments

Comments
 (0)