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.
This commit is contained in:
2026-09-23 21:33:20 -05:00
parent c73a81a5f4
commit a62195ffce
3 changed files with 84 additions and 0 deletions
+52
View File
@@ -150,6 +150,7 @@ class TempUploadSerializer(serializers.ModelSerializer):
iqdb_checked = serializers.SerializerMethodField() iqdb_checked = serializers.SerializerMethodField()
processing = serializers.SerializerMethodField() processing = serializers.SerializerMethodField()
similar_count = serializers.SerializerMethodField() similar_count = serializers.SerializerMethodField()
visual_matches = serializers.SerializerMethodField()
class Meta: class Meta:
model = TempUpload model = TempUpload
@@ -242,6 +243,57 @@ class TempUploadSerializer(serializers.ModelSerializer):
def get_similar_count(self, obj): def get_similar_count(self, obj):
return len(obj.iqdb_data or []) + len(obj.visual_matches or []) 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): class TempUploadListSerializer(TempUploadSerializer):
"""Compact staged-upload row for the board and the status polling. """Compact staged-upload row for the board and the status polling.
@@ -287,6 +287,37 @@ class StagedUploadWorkflowTests(TestCase):
self.assertEqual(body["visual_matches"], []) self.assertEqual(body["visual_matches"], [])
self.assertEqual(body["status"], TempUpload.STATUS_PENDING) 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): def test_visual_match_phase_rejects_completed_uploads(self):
client = self.api_client(self.uploader) client = self.api_client(self.uploader)
temp = self.make_temp( temp = self.make_temp(
+1
View File
@@ -60,6 +60,7 @@ def match_hashes(hashes, index, limit=10, user=None, request=None):
location = item.locations.first() location = item.locations.first()
matches.append( matches.append(
{ {
"item_id": item.id,
"j_id": f"J-{item.id}", "j_id": f"J-{item.id}",
"filename": Path(location.rel_path).name if location else item.md5, "filename": Path(location.rel_path).name if location else item.md5,
"similarity": round(similarity * 100, 1), "similarity": round(similarity * 100, 1),