From a62195ffceaf70d5573ac1ccd2b610f89ced489b Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Wed, 23 Sep 2026 21:33:20 -0500 Subject: [PATCH] Re-sign visual-match thumbnails on every detail fetch Match rows stored a signed URL minted when the scan ran, so it aged out (or used the pre-stable signing scheme) and the modal showed broken tiles even after legacy signatures were fixed. Rows now carry item_id/j_id and the detail serializer mints a fresh thumbnail URL per request; matches whose item no longer exists are dropped. --- backend/apps/library/serializers.py | 52 ++++++++++++++++++++++ backend/apps/library/tests/test_uploads.py | 31 +++++++++++++ backend/apps/library/uploads.py | 1 + 3 files changed, 84 insertions(+) diff --git a/backend/apps/library/serializers.py b/backend/apps/library/serializers.py index 2f0352f..d0d5393 100644 --- a/backend/apps/library/serializers.py +++ b/backend/apps/library/serializers.py @@ -150,6 +150,7 @@ class TempUploadSerializer(serializers.ModelSerializer): iqdb_checked = serializers.SerializerMethodField() processing = serializers.SerializerMethodField() similar_count = serializers.SerializerMethodField() + visual_matches = serializers.SerializerMethodField() class Meta: model = TempUpload @@ -242,6 +243,57 @@ class TempUploadSerializer(serializers.ModelSerializer): def get_similar_count(self, obj): return len(obj.iqdb_data or []) + len(obj.visual_matches or []) + @staticmethod + def _visual_item_id(entry): + if not isinstance(entry, dict): + return None + item_id = entry.get("item_id") + if item_id is None: + j_id = str(entry.get("j_id") or "") + if j_id.upper().startswith("J-"): + j_id = j_id[2:] + item_id = j_id if j_id.isdigit() else None + try: + return int(item_id) + except (TypeError, ValueError): + return None + + def get_visual_matches(self, obj): + """Rebuild match rows with fresh signed thumbnail URLs. + + Storing the signed URL meant it aged out (or came from an older + signing scheme) and the "Already in your library" grid showed broken + tiles. The stored rows only carry the item reference now. + """ + entries = obj.visual_matches or [] + if not entries: + return entries + wanted = {} + for entry in entries: + item_id = self._visual_item_id(entry) + if item_id is not None: + wanted[item_id] = None + items = MediaItem.objects.in_bulk(list(wanted)) + user = self._request_user() + request = self.context.get("request") + matches = [] + for entry in entries: + item_id = self._visual_item_id(entry) + item = items.get(item_id) if item_id is not None else None + if item is None: + continue + matches.append( + { + "j_id": f"J-{item.id}", + "filename": entry.get("filename") or item.md5, + "similarity": entry.get("similarity"), + "thumbnail_url": signed_media_url( + item, user, "thumbnail", request=request + ), + } + ) + return matches + class TempUploadListSerializer(TempUploadSerializer): """Compact staged-upload row for the board and the status polling. diff --git a/backend/apps/library/tests/test_uploads.py b/backend/apps/library/tests/test_uploads.py index 327b119..ccb586d 100644 --- a/backend/apps/library/tests/test_uploads.py +++ b/backend/apps/library/tests/test_uploads.py @@ -287,6 +287,37 @@ class StagedUploadWorkflowTests(TestCase): self.assertEqual(body["visual_matches"], []) self.assertEqual(body["status"], TempUpload.STATUS_PENDING) + def test_detail_resigns_stored_visual_match_urls(self): + """Stored matches carry only the item reference; URLs are re-minted. + + Embedding the signed URL meant it expired (or used an older signing + scheme) and the modal showed alt text instead of thumbnails. + """ + client = self.api_client(self.uploader) + self.seed_library_item(client, "seed-resign") + item = MediaItem.objects.get() + temp = self.make_temp(self.uploader, "resign") + TempUpload.objects.filter(pk=temp.pk).update( + visual_matches=[ + { + "item_id": item.id, + "j_id": f"J-{item.id}", + "filename": "seed-resign.png", + "similarity": 96.5, + "thumbnail_url": "/api/files/J-x/thumbnail/?sig=stale", + }, + {"item_id": 999999, "j_id": "J-999999", "filename": "gone.png"}, + ] + ) + body = client.get(f"/api/uploads/{temp.id}/").json() + self.assertEqual(len(body["visual_matches"]), 1) + match = body["visual_matches"][0] + self.assertEqual(match["j_id"], f"J-{item.id}") + self.assertEqual(match["similarity"], 96.5) + self.assertNotIn("stale", match["thumbnail_url"]) + self.assertIn("/thumbnail/", match["thumbnail_url"]) + self.assertIn(f"v={item.md5}", match["thumbnail_url"]) + def test_visual_match_phase_rejects_completed_uploads(self): client = self.api_client(self.uploader) temp = self.make_temp( diff --git a/backend/apps/library/uploads.py b/backend/apps/library/uploads.py index 85760c5..a27d47e 100644 --- a/backend/apps/library/uploads.py +++ b/backend/apps/library/uploads.py @@ -60,6 +60,7 @@ def match_hashes(hashes, index, limit=10, user=None, request=None): location = item.locations.first() matches.append( { + "item_id": item.id, "j_id": f"J-{item.id}", "filename": Path(location.rel_path).name if location else item.md5, "similarity": round(similarity * 100, 1),