Library search upgrades: tag search, tag cloud, status filter

Backend:
- MediaItem gains search_tags (custom + e621 tags, lowercase) and
  has_custom_data, maintained on save with a data migration backfill
- File list search accepts search_type=filename|tags|both (tag search is
  word-AND across the flattened tag text) and status=matched|custom|
  unknown filters
- New /api/tags/cloud/ endpoint (cached 2 min per guest/auth, invalidated
  on item changes and deletions) returning the most-used tags, honouring
  guest visibility

Frontend:
- Library sidebar: Filename/Tags/Both selector, status pill toggles
  (persisted), and a clickable tag cloud that runs a tag search
- Roadmap updated
This commit is contained in:
2026-09-17 13:19:17 -05:00
parent 7ec8ee974e
commit 4df573da43
7 changed files with 286 additions and 8 deletions
@@ -0,0 +1,43 @@
# Generated by Django 6.1.1 on 2026-09-17 18:04
from django.db import migrations, models
def backfill_derived_fields(apps, schema_editor):
MediaItem = apps.get_model("library", "MediaItem")
for item in MediaItem.objects.all().iterator(chunk_size=200):
names = [str(tag) for tag in (item.tags or [])]
data = item.e621_data if isinstance(item.e621_data, dict) else {}
categories = data.get("tags")
if isinstance(categories, dict):
for values in categories.values():
if isinstance(values, list):
names.extend(str(tag) for tag in values)
search_tags = " ".join(
sorted({str(name).strip().lower() for name in names if str(name).strip()})
)
MediaItem.objects.filter(pk=item.pk).update(
search_tags=search_tags,
has_custom_data=bool(item.tags or item.notes),
)
class Migration(migrations.Migration):
dependencies = [
('library', '0007_tempupload_visual_matches'),
]
operations = [
migrations.AddField(
model_name='mediaitem',
name='has_custom_data',
field=models.BooleanField(db_index=True, default=False),
),
migrations.AddField(
model_name='mediaitem',
name='search_tags',
field=models.TextField(blank=True, default=''),
),
migrations.RunPython(backfill_derived_fields, migrations.RunPython.noop),
]
+33 -1
View File
@@ -4,6 +4,26 @@ from django.db import models
import uuid
TAG_CLOUD_CACHE_KEYS = [
"j621.library.tag_cloud.auth",
"j621.library.tag_cloud.guest",
]
def build_search_tags(tags, e621_data):
"""Lowercase tag text (custom + e621) used for library tag search."""
names = [str(tag) for tag in (tags or [])]
data = e621_data if isinstance(e621_data, dict) else {}
categories = data.get("tags")
if isinstance(categories, dict):
for values in categories.values():
if isinstance(values, list):
names.extend(str(tag) for tag in values)
return " ".join(
sorted({str(name).strip().lower() for name in names if str(name).strip()})
)
class MediaItem(models.Model):
"""A logical media file, identified by its MD5 fingerprint."""
@@ -29,6 +49,9 @@ class MediaItem(models.Model):
dhash = models.CharField(max_length=32, blank=True, default="", db_index=True)
phash = models.CharField(max_length=32, blank=True, default="", db_index=True)
whash = models.CharField(max_length=32, blank=True, default="", db_index=True)
# Derived search/filter helpers: flattened tag text and custom-data flag.
search_tags = models.TextField(blank=True, default="")
has_custom_data = models.BooleanField(default=False, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -39,13 +62,22 @@ class MediaItem(models.Model):
return f"J-{self.pk} ({self.md5})"
def save(self, *args, **kwargs):
from django.core.cache import cache
from .guest_filter import item_is_hidden_for_guests
self.hidden_from_guests = item_is_hidden_for_guests(self)
self.has_custom_data = bool(self.tags or self.notes)
self.search_tags = build_search_tags(self.tags, self.e621_data)
update_fields = kwargs.get("update_fields")
if update_fields is not None:
kwargs["update_fields"] = set(update_fields) | {"hidden_from_guests"}
kwargs["update_fields"] = set(update_fields) | {
"hidden_from_guests",
"has_custom_data",
"search_tags",
}
super().save(*args, **kwargs)
cache.delete_many(TAG_CLOUD_CACHE_KEYS)
class MediaLocation(models.Model):
+53 -2
View File
@@ -8,11 +8,11 @@ from django.conf import settings
from django.core.cache import cache
from django.db.models import Count, Q
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import MediaItem, MediaLocation
from .models import TAG_CLOUD_CACHE_KEYS, MediaItem, MediaLocation
from .permissions import CanUpload
logger = logging.getLogger(__name__)
@@ -299,6 +299,7 @@ class DeleteFilesView(APIView):
item.delete()
cache.delete(STORAGE_CACHE_KEY)
cache.delete_many(TAG_CLOUD_CACHE_KEYS)
for location in MediaLocation.objects.filter(
id__in=location_ids
@@ -338,6 +339,56 @@ class ClearTempView(APIView):
return Response({"removed": removed})
class TagCloudView(APIView):
"""Most-used tags across the library (custom + e621 tags)."""
permission_classes = [AllowAny]
def get(self, request):
cache_key = (
"j621.library.tag_cloud.auth"
if request.user.is_authenticated
else "j621.library.tag_cloud.guest"
)
cached = cache.get(cache_key)
if cached is not None:
return Response(cached)
queryset = MediaItem.objects.all()
if not request.user.is_authenticated:
queryset = queryset.filter(hidden_from_guests=False)
counts = {}
for item in queryset.only("tags", "e621_data").iterator(chunk_size=500):
names = set()
for tag in item.tags or []:
name = str(tag).strip().lower()
if name:
names.add(name)
data = item.e621_data or {}
categories = data.get("tags") if isinstance(data, dict) else None
if isinstance(categories, dict):
for values in categories.values():
if isinstance(values, list):
names.update(
str(tag).strip().lower()
for tag in values
if str(tag).strip()
)
for name in names:
counts[name] = counts.get(name, 0) + 1
ranked = sorted(counts.items(), key=lambda entry: (-entry[1], entry[0]))
payload = {
"count": len(counts),
"tags": [
{"tag": tag, "count": count} for tag, count in ranked[:120]
],
}
cache.set(cache_key, payload, 120)
return Response(payload)
class StorageView(APIView):
"""Disk usage for the watched folder, media root and temp uploads."""
+2
View File
@@ -6,6 +6,7 @@ from .tools import (
DeleteFilesView,
ExactDuplicatesView,
StorageView,
TagCloudView,
VisualGroupsView,
VisualMatchesView,
)
@@ -37,5 +38,6 @@ urlpatterns = [
),
path("delete/", DeleteFilesView.as_view(), name="delete_files"),
path("temp/clear/", ClearTempView.as_view(), name="clear_temp"),
path("tags/cloud/", TagCloudView.as_view(), name="tag_cloud"),
path("storage/", StorageView.as_view(), name="storage_info"),
]
+32 -1
View File
@@ -4,7 +4,7 @@ from urllib.parse import urlparse
from django.conf import settings
from django.core import signing
from django.db.models import Min
from django.db.models import Min, Q
from django.http import Http404, StreamingHttpResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone
@@ -51,7 +51,19 @@ class MediaItemViewSet(
numeric_ids.append(int(number))
queryset = queryset.filter(pk__in=numeric_ids)
search = self.request.query_params.get("search", "").strip()
search_type = self.request.query_params.get("search_type", "filename").strip()
if search:
words = [word for word in search.lower().split() if word]
if search_type == "tags":
for word in words:
queryset = queryset.filter(search_tags__icontains=word)
elif search_type == "both":
query = Q(locations__rel_path__icontains=search)
tag_query = Q()
for word in words:
tag_query &= Q(search_tags__icontains=word)
queryset = queryset.filter(query | tag_query)
else:
queryset = queryset.filter(locations__rel_path__icontains=search)
ratings = [
value
@@ -60,6 +72,25 @@ class MediaItemViewSet(
]
if ratings:
queryset = queryset.filter(rating__in=ratings)
statuses = []
for value in self.request.query_params.getlist("status"):
statuses.extend(
part.strip() for part in value.split(",") if part.strip()
)
if statuses:
status_query = Q()
for status_value in set(statuses):
if status_value == "custom":
status_query |= Q(has_custom_data=True)
elif status_value == "matched":
status_query |= Q(
has_custom_data=False, e621_post_id__isnull=False
)
elif status_value == "unknown":
status_query |= Q(
has_custom_data=False, e621_post_id__isnull=True
)
queryset = queryset.filter(status_query)
ordering = self.request.query_params.get("ordering", "").strip()
queryset = queryset.order_by(
ordering if ordering in LIST_ORDERINGS else "-created_at"
+117 -3
View File
@@ -41,13 +41,34 @@ const ratingFilters = [
},
];
const statusFilters = [
{
value: "matched",
label: "Matched",
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
},
{
value: "custom",
label: "Custom",
active: "border-ctp-mauve/40 bg-ctp-mauve/15 text-ctp-mauve",
},
{
value: "unknown",
label: "Unknown",
active: "border-ctp-surface2 bg-ctp-surface0 text-ctp-subtext1",
},
];
export default function LibraryPage() {
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [searchType, setSearchType] = useState("filename");
const [page, setPage] = useState(1);
const ratings = useUi((state) => state.ratings);
const setRatings = useUi((state) => state.setRatings);
const statuses = useUi((state) => state.statuses);
const setStatuses = useUi((state) => state.setStatuses);
const perPage = useUi((state) => state.perPage);
const setPerPage = useUi((state) => state.setPerPage);
const ordering = useUi((state) => state.ordering);
@@ -68,19 +89,35 @@ export default function LibraryPage() {
}, []);
const query = useQuery({
queryKey: ["files", { search, ratings, ordering, perPage, page }],
queryKey: [
"files",
{ search, searchType, ratings, statuses, ordering, perPage, page },
],
queryFn: () => {
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
ordering,
});
if (search) params.set("search", search);
if (search) {
params.set("search", search);
params.set("search_type", searchType);
}
if (ratings.length) params.set("rating", ratings.join(","));
if (statuses.length) params.set("status", statuses.join(","));
return api<Paginated<MediaItem>>(`/api/files/?${params.toString()}`);
},
});
const tagCloudQuery = useQuery({
queryKey: ["tag-cloud"],
queryFn: () =>
api<{ count: number; tags: { tag: string; count: number }[] }>(
"/api/tags/cloud/",
),
staleTime: 60_000,
});
const pageCount = query.data
? Math.max(1, Math.ceil(query.data.count / perPage))
: 1;
@@ -100,10 +137,28 @@ export default function LibraryPage() {
);
}
function toggleStatus(value: string) {
setPage(1);
setStatuses(
statuses.includes(value)
? statuses.filter((status) => status !== value)
: [...statuses, value],
);
}
function searchTag(tag: string) {
setSearchInput(tag);
setSearch(tag);
setSearchType("tags");
setPage(1);
}
function clearFilters() {
setSearchInput("");
setSearch("");
setSearchType("filename");
setRatings([]);
setStatuses([]);
setPage(1);
}
@@ -118,11 +173,22 @@ export default function LibraryPage() {
<input
id="library-search"
className={cn(inputClass, "pl-8")}
placeholder="Filename…"
placeholder={
searchType === "tags" ? "Tags…" : "Filename…"
}
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
/>
</div>
<select
className={inputClass}
value={searchType}
onChange={(event) => setSearchType(event.target.value)}
>
<option value="filename">Filename</option>
<option value="tags">Tags</option>
<option value="both">Filename or tags</option>
</select>
<div className="flex gap-2">
<Button type="submit" className="flex-1">
Search
@@ -133,6 +199,29 @@ export default function LibraryPage() {
</div>
</form>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Status
</span>
<div className="flex flex-wrap gap-1.5">
{statusFilters.map((option) => (
<button
key={option.value}
type="button"
onClick={() => toggleStatus(option.value)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
statuses.includes(option.value)
? option.active
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Rating
@@ -156,6 +245,31 @@ export default function LibraryPage() {
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Tag cloud
</span>
<div className="flex flex-wrap gap-1.5">
{(tagCloudQuery.data?.tags ?? []).map((entry) => (
<button
key={entry.tag}
type="button"
onClick={() => searchTag(entry.tag)}
title={`Search ${entry.tag}`}
className="rounded border border-ctp-blue/30 bg-ctp-blue/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-blue transition hover:bg-ctp-blue/20"
>
{entry.tag} ({entry.count})
</button>
))}
{tagCloudQuery.isPending ? (
<Spinner className="h-3.5 w-3.5" />
) : null}
{tagCloudQuery.data && tagCloudQuery.data.tags.length === 0 ? (
<p className="text-xs text-ctp-overlay0">No tags yet.</p>
) : null}
</div>
</div>
<label className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Sort
+5
View File
@@ -7,6 +7,8 @@ interface UiState {
toggleSidebar: () => void;
ratings: string[];
setRatings: (ratings: string[]) => void;
statuses: string[];
setStatuses: (statuses: string[]) => void;
perPage: number;
setPerPage: (perPage: number) => void;
ordering: string;
@@ -23,6 +25,9 @@ export const useUi = create<UiState>()(
ratings: [],
setRatings: (ratings) => set({ ratings }),
statuses: [],
setStatuses: (statuses) => set({ statuses }),
perPage: 48,
setPerPage: (perPage) => set({ perPage }),