Fix hidden library items not rendering in the browser

Items flagged hidden_from_guests (blacklisted tags) returned 404 for
<img> requests since tags cannot send the auth header. The API now
exposes signed raw_url/thumbnail_url fields (mirroring upload previews
and avatars), and the SPA uses them in the gallery, detail view,
duplicates and delete screens, and upload visual matches.
This commit is contained in:
2026-09-17 13:25:20 -05:00
parent 4df573da43
commit db74f7ab18
11 changed files with 88 additions and 24 deletions
+6 -4
View File
@@ -15,10 +15,12 @@ they land.
- [x] Delete by J-ID with preview grid and bulk selection (plus per-copy - [x] Delete by J-ID with preview grid and bulk selection (plus per-copy
deletion of duplicate locations) deletion of duplicate locations)
- [x] Temp folder cleanup - [x] Temp folder cleanup
- [ ] **Library search upgrades** - [x] **Library search upgrades**
- [ ] Search by tags (custom + e621 tags), not just filename - [x] Search by tags (custom + e621 tags) with a Filename / Tags / Both selector
- [ ] Tag cloud from the library - [x] Tag cloud in the sidebar (click to search, hidden from guests for
- [ ] Status filter (matched / not found / deleted / unknown / custom) blacklisted items)
- [x] Status filter (matched / custom / unknown — not_found/deleted arrive with
the e621 match cache)
## 2. e621 integration ## 2. e621 integration
+25 -1
View File
@@ -5,7 +5,12 @@ from django.core import signing
from rest_framework import serializers from rest_framework import serializers
from .models import MediaItem, MediaLocation, TempUpload, DownloadTask from .models import MediaItem, MediaLocation, TempUpload, DownloadTask
from .services import MEDIA_FILE_SALT, UPLOAD_FILE_SALT, VIDEO_EXTENSIONS from .services import (
MEDIA_FILE_SALT,
UPLOAD_FILE_SALT,
VIDEO_EXTENSIONS,
signed_media_url,
)
class MediaLocationSerializer(serializers.ModelSerializer): class MediaLocationSerializer(serializers.ModelSerializer):
@@ -22,6 +27,8 @@ class MediaItemSerializer(serializers.ModelSerializer):
locations = MediaLocationSerializer(many=True, read_only=True) locations = MediaLocationSerializer(many=True, read_only=True)
j_id = serializers.SerializerMethodField() j_id = serializers.SerializerMethodField()
display_rating = serializers.SerializerMethodField() display_rating = serializers.SerializerMethodField()
raw_url = serializers.SerializerMethodField()
thumbnail_url = serializers.SerializerMethodField()
filename = serializers.SerializerMethodField() filename = serializers.SerializerMethodField()
extension = serializers.SerializerMethodField() extension = serializers.SerializerMethodField()
kind = serializers.SerializerMethodField() kind = serializers.SerializerMethodField()
@@ -42,6 +49,8 @@ class MediaItemSerializer(serializers.ModelSerializer):
"filename", "filename",
"extension", "extension",
"kind", "kind",
"raw_url",
"thumbnail_url",
"uploaded_by", "uploaded_by",
"uploaded_by_id", "uploaded_by_id",
"e621_post_id", "e621_post_id",
@@ -59,6 +68,8 @@ class MediaItemSerializer(serializers.ModelSerializer):
"filename", "filename",
"extension", "extension",
"kind", "kind",
"raw_url",
"thumbnail_url",
"uploaded_by", "uploaded_by",
"uploaded_by_id", "uploaded_by_id",
"e621_post_id", "e621_post_id",
@@ -68,9 +79,22 @@ class MediaItemSerializer(serializers.ModelSerializer):
"updated_at", "updated_at",
] ]
def _request_user(self):
request = self.context.get("request")
user = getattr(request, "user", None)
if user is None or not getattr(user, "is_authenticated", False):
return None
return user
def get_j_id(self, obj): def get_j_id(self, obj):
return f"J-{obj.id}" return f"J-{obj.id}"
def get_raw_url(self, obj):
return signed_media_url(obj, self._request_user(), "raw")
def get_thumbnail_url(self, obj):
return signed_media_url(obj, self._request_user(), "thumbnail")
def get_display_rating(self, obj): def get_display_rating(self, obj):
if obj.rating: if obj.rating:
return obj.rating return obj.rating
+13
View File
@@ -10,6 +10,7 @@ from pathlib import Path
import imagehash import imagehash
from django.conf import settings from django.conf import settings
from django.core import signing
from django.http import FileResponse, Http404, HttpResponse from django.http import FileResponse, Http404, HttpResponse
from django.utils.text import get_valid_filename from django.utils.text import get_valid_filename
from PIL import Image from PIL import Image
@@ -72,6 +73,18 @@ def index_file(path, folder):
return item, created_item, location, created_location return item, created_item, location, created_location
def signed_media_url(item, user, action="raw"):
"""Media URL that <img>/<video> tags can load for a signed-in user."""
base = f"/api/files/J-{item.id}/{action}/"
if user is None or not getattr(user, "is_authenticated", False):
return base
signature = signing.dumps(
{"item": item.id, "user": user.id, "action": action},
salt=MEDIA_FILE_SALT,
)
return f"{base}?sig={signature}"
def rename_location_to_j_id(item, location): def rename_location_to_j_id(item, location):
"""Name a freshly indexed copy J-<id>.<ext> inside its own folder.""" """Name a freshly indexed copy J-<id>.<ext> inside its own folder."""
path = Path(location.path) path = Path(location.path)
+11 -5
View File
@@ -14,6 +14,7 @@ from rest_framework.views import APIView
from .models import TAG_CLOUD_CACHE_KEYS, MediaItem, MediaLocation from .models import TAG_CLOUD_CACHE_KEYS, MediaItem, MediaLocation
from .permissions import CanUpload from .permissions import CanUpload
from .services import signed_media_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -85,9 +86,10 @@ def display_rating(item):
return rating if rating in {"s", "q", "e"} else "" return rating if rating in {"s", "q", "e"} else ""
def item_brief(item): def item_brief(item, request=None):
locations = list(item.locations.all()) locations = list(item.locations.all())
location = locations[0] if locations else None location = locations[0] if locations else None
user = getattr(request, "user", None)
return { return {
"j_id": f"J-{item.id}", "j_id": f"J-{item.id}",
"md5": item.md5, "md5": item.md5,
@@ -97,6 +99,7 @@ def item_brief(item):
"location_count": len(locations), "location_count": len(locations),
"uploaded_by": item.uploaded_by.username if item.uploaded_by else None, "uploaded_by": item.uploaded_by.username if item.uploaded_by else None,
"e621_post_id": item.e621_post_id, "e621_post_id": item.e621_post_id,
"thumbnail_url": signed_media_url(item, user, "thumbnail"),
} }
@@ -128,7 +131,7 @@ class ExactDuplicatesView(APIView):
) )
groups = [] groups = []
for item in items: for item in items:
brief = item_brief(item) brief = item_brief(item, request)
brief["locations"] = [ brief["locations"] = [
{"id": location.id, "rel_path": location.rel_path} {"id": location.id, "rel_path": location.rel_path}
for location in item.locations.all() for location in item.locations.all()
@@ -159,14 +162,14 @@ class VisualMatchesView(APIView):
similarity = similarity_between(target, item, algorithms, threshold) similarity = similarity_between(target, item, algorithms, threshold)
if similarity is None: if similarity is None:
continue continue
brief = item_brief(item) brief = item_brief(item, request)
brief["similarity"] = round(similarity * 100, 1) brief["similarity"] = round(similarity * 100, 1)
matches.append(brief) matches.append(brief)
matches.sort(key=lambda entry: entry["similarity"], reverse=True) matches.sort(key=lambda entry: entry["similarity"], reverse=True)
return Response( return Response(
{ {
"target": item_brief(target), "target": item_brief(target, request),
"threshold": round(threshold * 100, 1), "threshold": round(threshold * 100, 1),
"algorithms": algorithms, "algorithms": algorithms,
"count": len(matches), "count": len(matches),
@@ -236,7 +239,10 @@ class VisualGroupsView(APIView):
"threshold": round(threshold * 100, 1), "threshold": round(threshold * 100, 1),
"algorithms": algorithms, "algorithms": algorithms,
"groups": [ "groups": [
{"size": len(members), "members": [item_brief(item) for item in members]} {
"size": len(members),
"members": [item_brief(item, request) for item in members],
}
for members in page_groups for members in page_groups
], ],
} }
+3 -2
View File
@@ -33,7 +33,7 @@ from .tools import HASH_FIELDS, hashes_similarity
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def find_library_matches(path, limit=10): def find_library_matches(path, limit=10, user=None):
"""Library items visually similar to a staged file.""" """Library items visually similar to a staged file."""
hashes = services.compute_visual_hashes(path) hashes = services.compute_visual_hashes(path)
if not hashes: if not hashes:
@@ -56,6 +56,7 @@ def find_library_matches(path, limit=10):
"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),
"thumbnail_url": services.signed_media_url(item, user, "thumbnail"),
} }
) )
matches.sort(key=lambda entry: entry["similarity"], reverse=True) matches.sort(key=lambda entry: entry["similarity"], reverse=True)
@@ -176,7 +177,7 @@ class TempUploadViewSet(
temp.library_item = existing temp.library_item = existing
temp.file.delete(save=False) temp.file.delete(save=False)
else: else:
matches = find_library_matches(temp.file.path) matches = find_library_matches(temp.file.path, user=request.user)
if matches: if matches:
temp.visual_matches = matches temp.visual_matches = matches
temp.status = TempUpload.STATUS_VISUAL_MATCH temp.status = TempUpload.STATUS_VISUAL_MATCH
+1 -3
View File
@@ -18,9 +18,7 @@ const ratingLabels: Record<string, string> = {
export function MediaCard({ item }: { item: MediaItem }) { export function MediaCard({ item }: { item: MediaItem }) {
const preview = const preview =
item.kind === "video" item.kind === "video" ? item.thumbnail_url : item.raw_url;
? `/api/files/${item.j_id}/thumbnail/`
: `/api/files/${item.j_id}/raw/`;
const rating = item.display_rating; const rating = item.display_rating;
return ( return (
+1 -1
View File
@@ -229,7 +229,7 @@ export default function DeletePage() {
> >
<div className="relative aspect-square overflow-hidden bg-ctp-mantle"> <div className="relative aspect-square overflow-hidden bg-ctp-mantle">
<img <img
src={`/api/files/${item.j_id}/thumbnail/`} src={item.thumbnail_url}
alt={item.filename} alt={item.filename}
loading="lazy" loading="lazy"
className="h-full w-full object-cover" className="h-full w-full object-cover"
@@ -33,6 +33,8 @@ function MemberCard({
selected: boolean; selected: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
const preview =
entry.thumbnail_url ?? `/api/files/${entry.j_id}/thumbnail/`;
return ( return (
<div <div
className={cn( className={cn(
@@ -43,7 +45,7 @@ function MemberCard({
<button type="button" onClick={onToggle} className="block w-full text-left"> <button type="button" onClick={onToggle} className="block w-full text-left">
<div className="relative aspect-square overflow-hidden bg-ctp-mantle"> <div className="relative aspect-square overflow-hidden bg-ctp-mantle">
<img <img
src={`/api/files/${entry.j_id}/thumbnail/`} src={preview}
alt={entry.j_id} alt={entry.j_id}
loading="lazy" loading="lazy"
className="h-full w-full object-cover" className="h-full w-full object-cover"
@@ -219,7 +221,10 @@ export default function DuplicatesPage() {
<div className="w-32 shrink-0"> <div className="w-32 shrink-0">
<div className="aspect-square overflow-hidden rounded-md bg-ctp-mantle"> <div className="aspect-square overflow-hidden rounded-md bg-ctp-mantle">
<img <img
src={`/api/files/${group.j_id}/thumbnail/`} src={
group.thumbnail_url ??
`/api/files/${group.j_id}/thumbnail/`
}
alt={group.j_id} alt={group.j_id}
loading="lazy" loading="lazy"
className="h-full w-full object-cover" className="h-full w-full object-cover"
@@ -300,13 +300,17 @@ export function LibraryDetail({ jId }: { jId: string }) {
const ordering = useUi((state) => state.ordering); const ordering = useUi((state) => state.ordering);
const user = useAuth((state) => state.user); const user = useAuth((state) => state.user);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const rawUrl = `/api/files/${jId}/raw/`;
const query = useQuery({ const query = useQuery({
queryKey: ["file", jId], queryKey: ["file", jId],
queryFn: () => api<MediaItem>(`/api/files/${jId}/`), queryFn: () => api<MediaItem>(`/api/files/${jId}/`),
}); });
const rawUrl = query.data?.raw_url ?? `/api/files/${jId}/raw/`;
const downloadUrl = rawUrl.includes("?")
? `${rawUrl}&download=1`
: `${rawUrl}?download=1`;
const neighborsQuery = useQuery({ const neighborsQuery = useQuery({
queryKey: ["neighbors", jId, ordering], queryKey: ["neighbors", jId, ordering],
queryFn: () => queryFn: () =>
@@ -335,7 +339,7 @@ export function LibraryDetail({ jId }: { jId: string }) {
if (isTypingTarget(event.target)) return; if (isTypingTarget(event.target)) return;
if (event.key === "d" || event.key === "D") { if (event.key === "d" || event.key === "D") {
event.preventDefault(); event.preventDefault();
window.location.href = `${rawUrl}?download=1`; window.location.href = downloadUrl;
return; return;
} }
const neighbors = neighborsQuery.data; const neighbors = neighborsQuery.data;
@@ -347,7 +351,7 @@ export function LibraryDetail({ jId }: { jId: string }) {
} }
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [neighborsQuery.data, navigate, rawUrl]); }, [neighborsQuery.data, navigate, downloadUrl]);
if (query.isPending) { if (query.isPending) {
return ( return (
@@ -400,7 +404,7 @@ export function LibraryDetail({ jId }: { jId: string }) {
} }
actions={ actions={
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<a href={`${rawUrl}?download=1`} className={linkButtonClass}> <a href={downloadUrl} className={linkButtonClass}>
<Download className="h-4 w-4" /> Download <Download className="h-4 w-4" /> Download
</a> </a>
{canDelete ? ( {canDelete ? (
+4 -1
View File
@@ -385,7 +385,10 @@ function MetadataModal({
className="flex items-center gap-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-2" className="flex items-center gap-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-2"
> >
<img <img
src={`/api/files/${match.j_id}/thumbnail/`} src={
match.thumbnail_url ??
`/api/files/${match.j_id}/thumbnail/`
}
alt={match.j_id} alt={match.j_id}
loading="lazy" loading="lazy"
className="h-12 w-12 shrink-0 rounded object-cover" className="h-12 w-12 shrink-0 rounded object-cover"
+9 -1
View File
@@ -36,6 +36,7 @@ export interface DuplicateEntry {
uploaded_by: string | null; uploaded_by: string | null;
e621_post_id: number | null; e621_post_id: number | null;
locations?: { id: number; rel_path: string }[]; locations?: { id: number; rel_path: string }[];
thumbnail_url?: string;
similarity?: number; similarity?: number;
} }
@@ -126,6 +127,8 @@ export interface MediaItem {
e621_post_id: number | null; e621_post_id: number | null;
e621_data: E621StoredPost | null; e621_data: E621StoredPost | null;
locations: MediaLocation[]; locations: MediaLocation[];
raw_url: string;
thumbnail_url: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -209,7 +212,12 @@ export interface TempUpload {
custom_notes: string; custom_notes: string;
iqdb_data: E621IqdbCandidate[] | null; iqdb_data: E621IqdbCandidate[] | null;
visual_matches: visual_matches:
| { j_id: string; filename: string; similarity: number }[] | {
j_id: string;
filename: string;
similarity: number;
thumbnail_url?: string;
}[]
| null; | null;
library_j_id: string | null; library_j_id: string | null;
file_url: string | null; file_url: string | null;