Skip to content

Commit cb94073

Browse files
dannyvfilmsclaude
andcommitted
Persist Item.source_url so the details-page Source chip survives fallback
- stored_metadata_fallback() had no source_url, so _build_detail_link_sections() silently dropped the whole Source section whenever the details page fell back to stored Item metadata (e.g. a live provider fetch fails). - IGDB, Hardcover, ComicVine, and MangaUpdates embed a provider-generated slug in source_url that can't be rebuilt from media_id alone, so persist it on Item (synced via apply_item_metadata on every live fetch) instead of a per-source URL-rebuilding helper. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 14c4a20 commit cb94073

5 files changed

Lines changed: 114 additions & 3 deletions

File tree

src/app/metadata_utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
CORE_METADATA_FIELDS = [
1212
"synopsis",
13+
"source_url",
1314
"country",
1415
"languages",
1516
"platforms",
@@ -147,6 +148,7 @@ def extract_item_metadata_values(metadata: dict | None) -> dict[str, object]:
147148

148149
return {
149150
"synopsis": payload.get("synopsis") or "",
151+
"source_url": payload.get("source_url") or "",
150152
"country": details.get("country") or "",
151153
"languages": _coerce_list(details.get("languages")),
152154
"platforms": _coerce_list(details.get("platforms"), allow_scalar=False),
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from django.db import migrations, models
2+
3+
4+
def _column_exists(schema_editor, table_name, column_name):
5+
"""Return True when a database column already exists."""
6+
connection = schema_editor.connection
7+
if connection.vendor == "postgresql":
8+
with connection.cursor() as cursor:
9+
cursor.execute(
10+
"SELECT 1 FROM information_schema.columns "
11+
"WHERE table_schema = current_schema() "
12+
"AND table_name = %s AND column_name = %s",
13+
[table_name, column_name],
14+
)
15+
return cursor.fetchone() is not None
16+
with connection.cursor() as cursor:
17+
description = connection.introspection.get_table_description(cursor, table_name)
18+
columns = {getattr(column, "name", column[0]) for column in description}
19+
return column_name in columns
20+
21+
22+
class AddFieldIfNotExists(migrations.AddField):
23+
"""Add a field only when the backing column doesn't already exist."""
24+
25+
def database_forwards(self, app_label, schema_editor, from_state, to_state):
26+
to_model = to_state.apps.get_model(app_label, self.model_name)
27+
field = to_model._meta.get_field(self.name)
28+
if _column_exists(schema_editor, to_model._meta.db_table, field.column):
29+
return
30+
super().database_forwards(app_label, schema_editor, from_state, to_state)
31+
32+
33+
class Migration(migrations.Migration):
34+
dependencies = [
35+
("app", "0164_musicreleasepreference"),
36+
]
37+
38+
operations = [
39+
AddFieldIfNotExists(
40+
model_name="item",
41+
name="source_url",
42+
field=models.TextField(
43+
blank=True,
44+
default="",
45+
help_text="Cached provider detail page URL, used as a fallback when live metadata is unavailable",
46+
),
47+
),
48+
]

src/app/models/item.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ class Item(CalendarTriggerMixin, models.Model):
4747
default="",
4848
help_text="Cached provider synopsis, used as a fallback when live metadata is unavailable",
4949
)
50+
source_url = models.TextField(
51+
blank=True,
52+
default="",
53+
help_text="Cached provider detail page URL, used as a fallback when live metadata is unavailable",
54+
)
5055
image = models.TextField(
5156
blank=True, default=""
5257
) # if add default, custom media entry will show the value

src/app/services/metadata_fallback.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def stored_metadata_fallback(item):
1212
"localized_title": item.localized_title,
1313
"image": item.image,
1414
"synopsis": item.synopsis,
15+
"source_url": item.source_url,
1516
"genres": item.genres,
1617
"cast": [],
1718
"crew": [],

src/app/tests/views/test_media_details.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,60 @@ def test_media_details_keeps_tracked_tmdb_show_available_when_provider_returns_4
493493
self.assertEqual(response.context["current_instance"], tv)
494494

495495
@patch("app.providers.services.get_media_metadata")
496-
def test_media_details_renders_top_action_row_between_chips_and_description(
496+
def test_media_details_keeps_source_link_when_provider_is_unreachable(
497+
self,
498+
mock_get_metadata,
499+
):
500+
"""The "Source" chip survives falling back to stored Item metadata (#931-style)."""
501+
service_unavailable_response = requests.Response()
502+
service_unavailable_response.status_code = requests.codes.service_unavailable
503+
mock_get_metadata.side_effect = services.ProviderAPIError(
504+
Sources.IGDB.value,
505+
requests.exceptions.HTTPError(response=service_unavailable_response),
506+
)
507+
508+
item = Item.objects.create(
509+
media_id="1473",
510+
source=Sources.IGDB.value,
511+
media_type=MediaTypes.GAME.value,
512+
title="Zone of the Enders: The 2nd Runner",
513+
image="https://example.com/zoe.jpg",
514+
synopsis="Stored synopsis",
515+
source_url="https://www.igdb.com/games/zone-of-the-enders-the-2nd-runner",
516+
)
517+
Game.objects.create(
518+
item=item,
519+
user=self.user,
520+
status=Status.IN_PROGRESS.value,
521+
)
522+
523+
response = self.client.get(
524+
reverse(
525+
"media_details",
526+
kwargs={
527+
"source": Sources.IGDB.value,
528+
"media_type": MediaTypes.GAME.value,
529+
"media_id": item.media_id,
530+
"title": "zone-of-the-enders-the-2nd-runner",
531+
},
532+
),
533+
{"fragment": "secondary"},
534+
)
535+
536+
self.assertEqual(response.status_code, 200)
537+
source_sections = [
538+
section
539+
for section in response.context["detail_link_sections"]
540+
if section["title"] in ("Source", "Tracking Source")
541+
]
542+
self.assertTrue(source_sections, "Expected a Source link section in the fallback")
543+
self.assertEqual(
544+
source_sections[0]["entries"][0]["url"],
545+
"https://www.igdb.com/games/zone-of-the-enders-the-2nd-runner",
546+
)
547+
548+
@patch("app.providers.services.get_media_metadata")
549+
def test_media_details_renders_action_row_before_chips_and_description(
497550
self, mock_get_metadata
498551
):
499552
mock_get_metadata.return_value = {
@@ -567,8 +620,10 @@ def test_media_details_renders_top_action_row_between_chips_and_description(
567620
'class="hidden text-[var(--color-link)] hover:text-[var(--color-link-hover)] text-sm mt-2 focus:outline-none transition-colors cursor-pointer sm:inline-flex"',
568621
content,
569622
)
570-
self.assertLess(content.index("tmdb-logo.png"), content.index("Add to tracker"))
571-
self.assertLess(content.index("Add to tracker"), content.index("Test overview"))
623+
# Movies/TV/seasons render the carousel-column layout: title + action row
624+
# come first, score chips move below them, then the synopsis.
625+
self.assertLess(content.index("Add to tracker"), content.index("tmdb-logo.png"))
626+
self.assertLess(content.index("tmdb-logo.png"), content.index("Test overview"))
572627

573628
@patch("app.providers.services.get_media_metadata")
574629
def test_comic_volume_issue_rows_render_shared_action_buttons(

0 commit comments

Comments
 (0)