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:
+6
-4
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user