diff --git a/backend/apps/library/migrations/0008_mediaitem_has_custom_data_mediaitem_search_tags.py b/backend/apps/library/migrations/0008_mediaitem_has_custom_data_mediaitem_search_tags.py new file mode 100644 index 0000000..c6fdcd3 --- /dev/null +++ b/backend/apps/library/migrations/0008_mediaitem_has_custom_data_mediaitem_search_tags.py @@ -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), + ] diff --git a/backend/apps/library/models.py b/backend/apps/library/models.py index 45d56a6..a208f87 100644 --- a/backend/apps/library/models.py +++ b/backend/apps/library/models.py @@ -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): diff --git a/backend/apps/library/tools.py b/backend/apps/library/tools.py index 0d3b4b7..0f2829c 100644 --- a/backend/apps/library/tools.py +++ b/backend/apps/library/tools.py @@ -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.""" diff --git a/backend/apps/library/urls.py b/backend/apps/library/urls.py index bb076eb..dc293d0 100644 --- a/backend/apps/library/urls.py +++ b/backend/apps/library/urls.py @@ -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"), ] diff --git a/backend/apps/library/views.py b/backend/apps/library/views.py index b867e45..c74900c 100644 --- a/backend/apps/library/views.py +++ b/backend/apps/library/views.py @@ -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,8 +51,20 @@ 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: - queryset = queryset.filter(locations__rel_path__icontains=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 for value in self.request.query_params.get("rating", "").split(",") @@ -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" diff --git a/frontend/src/features/library/LibraryPage.tsx b/frontend/src/features/library/LibraryPage.tsx index e907d39..bdaf88a 100644 --- a/frontend/src/features/library/LibraryPage.tsx +++ b/frontend/src/features/library/LibraryPage.tsx @@ -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>(`/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() { setSearchInput(event.target.value)} /> +
+
+ + Status + +
+ {statusFilters.map((option) => ( + + ))} +
+
+
Rating @@ -156,6 +245,31 @@ export default function LibraryPage() {
+
+ + Tag cloud + +
+ {(tagCloudQuery.data?.tags ?? []).map((entry) => ( + + ))} + {tagCloudQuery.isPending ? ( + + ) : null} + {tagCloudQuery.data && tagCloudQuery.data.tags.length === 0 ? ( +

No tags yet.

+ ) : null} +
+
+