Add an ephemeral similarity check page
- /similar (nav: Similar): drop a file to get the exact MD5 match, the perceptual matches against the library, and e621 IQDB candidates (auto-run for images when credentials are configured). Read-only — nothing enters the library. - SimilarityCheck model + /api/similarity/ (create/list/retrieve/delete) with signed preview URLs and an expires_at timestamp. - Temp files are wiped on startup (AppConfig.ready, file-only so no database access during initialization), lazily past SIMILARITY_TTL_MINUTES (default 30, env-overridable), on delete, and by manage.py cleanup_similarity. - uploadFile() takes a target path; .env.example documents the TTL.
This commit is contained in:
@@ -22,6 +22,13 @@ they land.
|
|||||||
- [x] Status filter (matched / custom / unknown — not_found/deleted arrive with
|
- [x] Status filter (matched / custom / unknown — not_found/deleted arrive with
|
||||||
the e621 match cache)
|
the e621 match cache)
|
||||||
|
|
||||||
|
- [x] **Ephemeral similarity check** (`/similar`)
|
||||||
|
- [x] Drop a file: exact MD5 match, perceptual matches against the library,
|
||||||
|
and e621 IQDB candidates (auto-run for images)
|
||||||
|
- [x] Nothing enters the library: temp files are wiped on startup, after
|
||||||
|
`SIMILARITY_TTL_MINUTES` (default 30), on demand and by
|
||||||
|
`manage.py cleanup_similarity`
|
||||||
|
|
||||||
## 2. e621 integration
|
## 2. e621 integration
|
||||||
|
|
||||||
- [x] **Download progress bar on the detail view**
|
- [x] **Download progress bar on the detail view**
|
||||||
@@ -116,6 +123,8 @@ Files now stage first and are resolved before entering the library.
|
|||||||
- [ ] Guest blacklist refresh on a timer (`refresh_guest_blacklist` via cron/systemd)
|
- [ ] Guest blacklist refresh on a timer (`refresh_guest_blacklist` via cron/systemd)
|
||||||
- [ ] Follow sync on a timer (`sync_followed_tags` + `sync_followed_pools` via
|
- [ ] Follow sync on a timer (`sync_followed_tags` + `sync_followed_pools` via
|
||||||
cron/systemd, e.g. every 30 minutes)
|
cron/systemd, e.g. every 30 minutes)
|
||||||
|
- [ ] Similarity temp cleanup on a timer (`cleanup_similarity` via cron; the
|
||||||
|
TTL also cleans lazily when new checks are created)
|
||||||
- [ ] Production setup: build the SPA, serve via Nginx (static + `/media` +
|
- [ ] Production setup: build the SPA, serve via Nginx (static + `/media` +
|
||||||
`/library`), systemd unit for Waitress
|
`/library`), systemd unit for Waitress
|
||||||
- [ ] Automated tests (backend API + frontend components)
|
- [ ] Automated tests (backend API + frontend components)
|
||||||
|
|||||||
@@ -18,3 +18,7 @@ REDIS_URL=redis://127.0.0.1:6380/1
|
|||||||
# Redis by `manage.py refresh_guest_blacklist`.
|
# Redis by `manage.py refresh_guest_blacklist`.
|
||||||
# GUEST_BLACKLIST_FALLBACK=young,cub,shota,loli,child,underage
|
# GUEST_BLACKLIST_FALLBACK=young,cub,shota,loli,child,underage
|
||||||
# GUEST_BLACKLIST_TTL=3600
|
# GUEST_BLACKLIST_TTL=3600
|
||||||
|
|
||||||
|
# Ephemeral similarity checks: temp files are wiped on startup and after
|
||||||
|
# this many minutes.
|
||||||
|
# SIMILARITY_TTL_MINUTES=30
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LibraryConfig(AppConfig):
|
class LibraryConfig(AppConfig):
|
||||||
name = "apps.library"
|
name = "apps.library"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
"""Ephemeral similarity-check files never survive a restart."""
|
||||||
|
from . import similarity
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted = similarity.purge_similarity_files()
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("Similarity file purge skipped: %s", exc)
|
||||||
|
else:
|
||||||
|
if deleted:
|
||||||
|
logger.info("Purged %s similarity file(s) on startup", deleted)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.library.similarity import purge_similarity_checks
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Delete ephemeral similarity-check files. By default removes checks "
|
||||||
|
"older than SIMILARITY_TTL_MINUTES; use --all to wipe every one."
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--minutes",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Delete checks older than N minutes "
|
||||||
|
"(default: the configured TTL, currently "
|
||||||
|
f"{getattr(settings, 'SIMILARITY_TTL_MINUTES', 30)}).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--all",
|
||||||
|
action="store_true",
|
||||||
|
help="Delete every similarity check regardless of age.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if options["all"]:
|
||||||
|
deleted = purge_similarity_checks()
|
||||||
|
scope = "all"
|
||||||
|
else:
|
||||||
|
minutes = (
|
||||||
|
options["minutes"]
|
||||||
|
if options["minutes"] is not None
|
||||||
|
else getattr(settings, "SIMILARITY_TTL_MINUTES", 30)
|
||||||
|
)
|
||||||
|
cutoff = timezone.now() - timedelta(minutes=minutes)
|
||||||
|
deleted = purge_similarity_checks(cutoff)
|
||||||
|
scope = f"older than {minutes} minute(s)"
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"Deleted {deleted} similarity check(s) ({scope}).")
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.1.1 on 2026-09-17 23:18
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('library', '0009_mediaitem_e621_checked_at_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SimilarityCheck',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('file', models.FileField(blank=True, upload_to='similarity/')),
|
||||||
|
('original_filename', models.CharField(max_length=255)),
|
||||||
|
('md5', models.CharField(blank=True, db_index=True, default='', max_length=32)),
|
||||||
|
('size', models.BigIntegerField(default=0)),
|
||||||
|
('results', models.JSONField(blank=True, default=dict)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='similarity_checks', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -281,3 +281,32 @@ class MatchTask(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Match scan {self.id} ({self.status})"
|
return f"Match scan {self.id} ({self.status})"
|
||||||
|
|
||||||
|
|
||||||
|
class SimilarityCheck(models.Model):
|
||||||
|
"""An ephemeral image uploaded only to check what it looks like.
|
||||||
|
|
||||||
|
Never enters the library: files live in the media temp folder and are
|
||||||
|
purged on startup, by the TTL and by `manage.py cleanup_similarity`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="similarity_checks",
|
||||||
|
)
|
||||||
|
file = models.FileField(upload_to="similarity/", blank=True)
|
||||||
|
original_filename = models.CharField(max_length=255)
|
||||||
|
md5 = models.CharField(max_length=32, db_index=True, blank=True, default="")
|
||||||
|
size = models.BigIntegerField(default=0)
|
||||||
|
results = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Similarity check {self.id} ({self.original_filename})"
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import os
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from .models import MediaItem, MediaLocation, TempUpload, DownloadTask, MatchTask
|
from .models import (
|
||||||
|
DownloadTask,
|
||||||
|
MatchTask,
|
||||||
|
MediaItem,
|
||||||
|
MediaLocation,
|
||||||
|
SimilarityCheck,
|
||||||
|
TempUpload,
|
||||||
|
)
|
||||||
from .services import (
|
from .services import (
|
||||||
MEDIA_FILE_SALT,
|
MEDIA_FILE_SALT,
|
||||||
UPLOAD_FILE_SALT,
|
UPLOAD_FILE_SALT,
|
||||||
@@ -246,3 +255,45 @@ class MatchTaskSerializer(serializers.ModelSerializer):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class SimilarityCheckSerializer(serializers.ModelSerializer):
|
||||||
|
check_id = serializers.UUIDField(source="id", read_only=True)
|
||||||
|
file_url = serializers.SerializerMethodField()
|
||||||
|
expires_at = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SimilarityCheck
|
||||||
|
fields = [
|
||||||
|
"check_id",
|
||||||
|
"original_filename",
|
||||||
|
"md5",
|
||||||
|
"size",
|
||||||
|
"file_url",
|
||||||
|
"results",
|
||||||
|
"expires_at",
|
||||||
|
"created_at",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
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_file_url(self, obj):
|
||||||
|
"""Signed URL so <img> tags can preview the temporary upload."""
|
||||||
|
user = self._request_user()
|
||||||
|
if user is None or not obj.file:
|
||||||
|
return None
|
||||||
|
signature = signing.dumps(
|
||||||
|
{"check": str(obj.id), "user": user.id},
|
||||||
|
salt=UPLOAD_FILE_SALT,
|
||||||
|
)
|
||||||
|
return f"/api/similarity/{obj.id}/file/?sig={signature}"
|
||||||
|
|
||||||
|
def get_expires_at(self, obj):
|
||||||
|
ttl = int(getattr(settings, "SIMILARITY_TTL_MINUTES", 30))
|
||||||
|
return obj.created_at + timedelta(minutes=ttl)
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Ephemeral similarity checks.
|
||||||
|
|
||||||
|
A file lands in the media temp folder only long enough to answer "is this
|
||||||
|
already in the library, and what does it look like on e621?". Nothing is
|
||||||
|
indexed: files are deleted on startup, after the TTL, on request through the
|
||||||
|
API, and by `manage.py cleanup_similarity` (for cron).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core import signing
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import mixins, status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||||
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from . import services
|
||||||
|
from .models import MediaItem, SimilarityCheck
|
||||||
|
from .serializers import SimilarityCheckSerializer
|
||||||
|
from .tools import item_brief
|
||||||
|
from .uploads import find_library_matches
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TTL_MINUTES = int(getattr(settings, "SIMILARITY_TTL_MINUTES", 30))
|
||||||
|
|
||||||
|
|
||||||
|
def purge_similarity_files():
|
||||||
|
"""Delete every stored temp file without touching the database.
|
||||||
|
|
||||||
|
Safe to call during app startup (``AppConfig.ready``) — database rows are
|
||||||
|
removed lazily by ``purge_expired`` and the cleanup command.
|
||||||
|
"""
|
||||||
|
root = Path(settings.MEDIA_ROOT) / "similarity"
|
||||||
|
if not root.exists():
|
||||||
|
return 0
|
||||||
|
deleted = 0
|
||||||
|
for path in sorted(root.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
deleted += 1
|
||||||
|
except OSError as exc: # noqa: PERF203 - keep going past locked files
|
||||||
|
logger.warning("Could not delete %s: %s", path, exc)
|
||||||
|
for path in sorted(root.rglob("*"), reverse=True):
|
||||||
|
if path.is_dir():
|
||||||
|
try:
|
||||||
|
path.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def purge_similarity_checks(older_than=None):
|
||||||
|
"""Delete checks (rows and files). ``older_than=None`` wipes them all."""
|
||||||
|
queryset = SimilarityCheck.objects.all()
|
||||||
|
if older_than is not None:
|
||||||
|
queryset = queryset.filter(created_at__lt=older_than)
|
||||||
|
deleted = 0
|
||||||
|
for check in queryset.iterator():
|
||||||
|
if check.file:
|
||||||
|
check.file.delete(save=False)
|
||||||
|
check.delete()
|
||||||
|
deleted += 1
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def purge_expired():
|
||||||
|
"""Remove checks past the TTL; called lazily before creating new ones."""
|
||||||
|
return purge_similarity_checks(timezone.now() - timedelta(minutes=TTL_MINUTES))
|
||||||
|
|
||||||
|
|
||||||
|
class SimilarityCheckViewSet(
|
||||||
|
mixins.ListModelMixin,
|
||||||
|
mixins.RetrieveModelMixin,
|
||||||
|
mixins.DestroyModelMixin,
|
||||||
|
viewsets.GenericViewSet,
|
||||||
|
):
|
||||||
|
"""Check an upload against the library — exact MD5 plus visual matches."""
|
||||||
|
|
||||||
|
serializer_class = SimilarityCheckSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = SimilarityCheck.objects.all()
|
||||||
|
user = self.request.user
|
||||||
|
if not (user.is_staff or user.is_superuser):
|
||||||
|
queryset = queryset.filter(user=user)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def create(self, request):
|
||||||
|
upload = request.FILES.get("file")
|
||||||
|
if upload is None:
|
||||||
|
return Response(
|
||||||
|
{"detail": "A file is required."}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
extension = Path(upload.name).suffix.lower()
|
||||||
|
if extension not in services.ALLOWED_EXTENSIONS:
|
||||||
|
return Response(
|
||||||
|
{"detail": f"Unsupported file type: {extension or 'unknown'}"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
purge_expired()
|
||||||
|
check = SimilarityCheck.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
file=upload,
|
||||||
|
original_filename=upload.name,
|
||||||
|
size=upload.size,
|
||||||
|
)
|
||||||
|
check.md5 = services.compute_md5(check.file.path)
|
||||||
|
|
||||||
|
exact = MediaItem.objects.filter(md5=check.md5).first()
|
||||||
|
matches = find_library_matches(check.file.path, limit=12, user=request.user)
|
||||||
|
if exact is not None:
|
||||||
|
exact_j_id = f"J-{exact.id}"
|
||||||
|
matches = [
|
||||||
|
match for match in matches if match.get("j_id") != exact_j_id
|
||||||
|
]
|
||||||
|
check.results = {
|
||||||
|
"exact": item_brief(exact, request) if exact is not None else None,
|
||||||
|
"matches": matches,
|
||||||
|
}
|
||||||
|
check.save(update_fields=["md5", "results"])
|
||||||
|
return Response(
|
||||||
|
self.get_serializer(check).data, status=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get", "head"], permission_classes=[AllowAny])
|
||||||
|
def file(self, request, pk=None):
|
||||||
|
"""Serve the temp file; accepts a signed URL like staged uploads."""
|
||||||
|
check = None
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
check = self.get_queryset().filter(pk=pk).first()
|
||||||
|
else:
|
||||||
|
signature = request.query_params.get("sig")
|
||||||
|
payload = None
|
||||||
|
if signature:
|
||||||
|
try:
|
||||||
|
payload = signing.loads(
|
||||||
|
signature, salt=services.UPLOAD_FILE_SALT, max_age=86400
|
||||||
|
)
|
||||||
|
except signing.BadSignature:
|
||||||
|
payload = None
|
||||||
|
if payload and payload.get("check") == str(pk):
|
||||||
|
check = SimilarityCheck.objects.filter(pk=pk).first()
|
||||||
|
if check is None or not check.file:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
return services.serve_file(request, check.file.path)
|
||||||
@@ -10,6 +10,7 @@ from .tools import (
|
|||||||
VisualGroupsView,
|
VisualGroupsView,
|
||||||
VisualMatchesView,
|
VisualMatchesView,
|
||||||
)
|
)
|
||||||
|
from .similarity import SimilarityCheckViewSet
|
||||||
from .uploads import TempUploadViewSet
|
from .uploads import TempUploadViewSet
|
||||||
from .views import (
|
from .views import (
|
||||||
ClientDownloadView,
|
ClientDownloadView,
|
||||||
@@ -23,6 +24,7 @@ router.register("files", MediaItemViewSet, basename="file")
|
|||||||
router.register("uploads", TempUploadViewSet, basename="upload")
|
router.register("uploads", TempUploadViewSet, basename="upload")
|
||||||
router.register("online/downloads", DownloadTaskViewSet, basename="download")
|
router.register("online/downloads", DownloadTaskViewSet, basename="download")
|
||||||
router.register("matches", MatchTaskViewSet, basename="match")
|
router.register("matches", MatchTaskViewSet, basename="match")
|
||||||
|
router.register("similarity", SimilarityCheckViewSet, basename="similarity")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
|
|||||||
@@ -170,6 +170,10 @@ GUEST_BLACKLIST_TTL = int(os.getenv("GUEST_BLACKLIST_TTL", "3600"))
|
|||||||
# Similarity threshold for flagging staged uploads that match library items.
|
# Similarity threshold for flagging staged uploads that match library items.
|
||||||
VISUAL_MATCH_THRESHOLD = float(os.getenv("VISUAL_MATCH_THRESHOLD", "0.9"))
|
VISUAL_MATCH_THRESHOLD = float(os.getenv("VISUAL_MATCH_THRESHOLD", "0.9"))
|
||||||
|
|
||||||
|
# Ephemeral similarity-check uploads are deleted after this many minutes
|
||||||
|
# (and always on startup).
|
||||||
|
SIMILARITY_TTL_MINUTES = int(os.getenv("SIMILARITY_TTL_MINUTES", "30"))
|
||||||
|
|
||||||
# Redis cache (run via docker compose at the repo root), shared by web
|
# Redis cache (run via docker compose at the repo root), shared by web
|
||||||
# workers and management commands (e.g. the mirrored guest blacklist).
|
# workers and management commands (e.g. the mirrored guest blacklist).
|
||||||
CACHES = {
|
CACHES = {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import Md5Redirect from "@/features/library/Md5Redirect";
|
|||||||
import OnlinePage from "@/features/online/OnlinePage";
|
import OnlinePage from "@/features/online/OnlinePage";
|
||||||
import PoolDetailPage from "@/features/pools/PoolDetailPage";
|
import PoolDetailPage from "@/features/pools/PoolDetailPage";
|
||||||
import PoolsPage from "@/features/pools/PoolsPage";
|
import PoolsPage from "@/features/pools/PoolsPage";
|
||||||
|
import SimilarPage from "@/features/similar/SimilarPage";
|
||||||
import UploadPage from "@/features/upload/UploadPage";
|
import UploadPage from "@/features/upload/UploadPage";
|
||||||
import UsersPage from "@/features/users/UsersPage";
|
import UsersPage from "@/features/users/UsersPage";
|
||||||
import { isAgeVerified, markAgeVerified } from "@/lib/age";
|
import { isAgeVerified, markAgeVerified } from "@/lib/age";
|
||||||
@@ -107,6 +108,14 @@ export default function App() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/similar"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<SimilarPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/upload"
|
path="/upload"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export function AppShell() {
|
|||||||
{ to: "/online", label: "Online" },
|
{ to: "/online", label: "Online" },
|
||||||
{ to: "/pools", label: "Pools" },
|
{ to: "/pools", label: "Pools" },
|
||||||
...(user ? [{ to: "/followed", label: "Followed" }] : []),
|
...(user ? [{ to: "/followed", label: "Followed" }] : []),
|
||||||
|
...(user ? [{ to: "/similar", label: "Similar" }] : []),
|
||||||
...(user?.can_upload
|
...(user?.can_upload
|
||||||
? [
|
? [
|
||||||
{ to: "/upload", label: "Upload" },
|
{ to: "/upload", label: "Upload" },
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ExternalLink, ImagePlus, Trash2, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type DragEvent,
|
||||||
|
} from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Button, Spinner } from "@/components/ui";
|
||||||
|
import { api, errorMessage, uploadFile } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import {
|
||||||
|
e621ErrorMessage,
|
||||||
|
effectiveCredentials,
|
||||||
|
iqdbSearch,
|
||||||
|
RATING_LABELS,
|
||||||
|
type E621IqdbResult,
|
||||||
|
} from "@/lib/e621";
|
||||||
|
import { formatBytes, formatDate } from "@/lib/format";
|
||||||
|
import type { SimilarityCheck } from "@/lib/types";
|
||||||
|
import { useE621 } from "@/store/e621";
|
||||||
|
|
||||||
|
const VIDEO_RE = /\.(mp4|webm)$/i;
|
||||||
|
|
||||||
|
const ratingPill: Record<string, string> = {
|
||||||
|
s: "bg-ctp-green text-ctp-crust",
|
||||||
|
q: "bg-ctp-peach text-ctp-crust",
|
||||||
|
e: "bg-ctp-red text-ctp-crust",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SimilarPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const credentials = useE621((state) => state.credentials);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [check, setCheck] = useState<SimilarityCheck | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [percent, setPercent] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const [iqdb, setIqdb] = useState<E621IqdbResult[] | null>(null);
|
||||||
|
const [iqdbBusy, setIqdbBusy] = useState(false);
|
||||||
|
const [iqdbError, setIqdbError] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<E621IqdbResult | null>(null);
|
||||||
|
|
||||||
|
const configured = Boolean(credentials?.configured);
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api(`/api/similarity/${check?.check_id}/`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setCheck(null);
|
||||||
|
setFile(null);
|
||||||
|
setIqdb(null);
|
||||||
|
setSelected(null);
|
||||||
|
setError(null);
|
||||||
|
setIqdbError(null);
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startCheck(selectedFile: File) {
|
||||||
|
setFile(selectedFile);
|
||||||
|
setCheck(null);
|
||||||
|
setIqdb(null);
|
||||||
|
setSelected(null);
|
||||||
|
setError(null);
|
||||||
|
setIqdbError(null);
|
||||||
|
setPercent(0);
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const data = await uploadFile<SimilarityCheck>(
|
||||||
|
selectedFile,
|
||||||
|
{},
|
||||||
|
setPercent,
|
||||||
|
"/api/similarity/",
|
||||||
|
);
|
||||||
|
setCheck(data);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VIDEO_RE.test(selectedFile.name)) return;
|
||||||
|
if (!configured) return;
|
||||||
|
setIqdbBusy(true);
|
||||||
|
try {
|
||||||
|
const results = await iqdbSearch(
|
||||||
|
effectiveCredentials(credentials),
|
||||||
|
selectedFile,
|
||||||
|
selectedFile.name,
|
||||||
|
);
|
||||||
|
setIqdb(results);
|
||||||
|
} catch (err) {
|
||||||
|
setIqdbError(e621ErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setIqdbBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const chosen = event.target.files?.[0];
|
||||||
|
if (chosen) void startCheck(chosen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event: DragEvent<HTMLDivElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
const dropped = event.dataTransfer.files?.[0];
|
||||||
|
if (dropped) void startCheck(dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVideo = file ? VIDEO_RE.test(file.name) : false;
|
||||||
|
const exact = check?.results.exact ?? null;
|
||||||
|
const matches = check?.results.matches ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-lg font-semibold">Similarity check</h1>
|
||||||
|
<p className="mt-1 text-sm text-ctp-overlay0">
|
||||||
|
Drop a file to see whether it is already in the library and what it
|
||||||
|
looks like on e621. Nothing is added to the library — the temporary
|
||||||
|
copy is deleted automatically after a while and on the next server
|
||||||
|
start.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 py-10 text-center transition",
|
||||||
|
dragging
|
||||||
|
? "border-ctp-mauve bg-ctp-mauve/10"
|
||||||
|
: "border-ctp-surface1 bg-ctp-base",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ImagePlus className="h-6 w-6 text-ctp-overlay0" />
|
||||||
|
<p className="text-sm text-ctp-subtext0">
|
||||||
|
Drag a file here, or{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-ctp-blue hover:underline"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
choose one
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-ctp-overlay0">
|
||||||
|
Images get the full check (MD5, visual hashes, e621 IQDB); videos
|
||||||
|
only get the exact MD5 match.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploading ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-1.5 w-48 overflow-hidden rounded-full bg-ctp-surface0">
|
||||||
|
<div
|
||||||
|
className="h-full bg-ctp-mauve transition-all"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs text-ctp-overlay0">
|
||||||
|
uploading… {percent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? <p className="text-sm text-ctp-red">{error}</p> : null}
|
||||||
|
|
||||||
|
{check ? (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
|
<aside className="flex flex-col gap-4">
|
||||||
|
<section className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base">
|
||||||
|
<div className="flex max-h-72 items-center justify-center overflow-hidden bg-ctp-mantle">
|
||||||
|
{check.file_url ? (
|
||||||
|
isVideo ? (
|
||||||
|
<video
|
||||||
|
src={check.file_url}
|
||||||
|
controls
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
className="max-h-72 w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={check.file_url}
|
||||||
|
alt={check.original_filename}
|
||||||
|
className="max-h-72 w-full object-contain"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<p
|
||||||
|
className="truncate font-mono text-xs text-ctp-subtext1"
|
||||||
|
title={check.original_filename}
|
||||||
|
>
|
||||||
|
{check.original_filename}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate font-mono text-[10px] text-ctp-overlay0">
|
||||||
|
{check.md5}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-ctp-overlay0">
|
||||||
|
{formatBytes(check.size)} · expires{" "}
|
||||||
|
{formatDate(check.expires_at)}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
onClick={() => deleteMutation.mutate()}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> Delete now
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setCheck(null);
|
||||||
|
setFile(null);
|
||||||
|
setIqdb(null);
|
||||||
|
setSelected(null);
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" /> Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-6">
|
||||||
|
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||||
|
In the library (exact MD5)
|
||||||
|
</h2>
|
||||||
|
{exact ? (
|
||||||
|
<Link
|
||||||
|
to={`/detail/${exact.j_id}`}
|
||||||
|
className="mt-3 flex items-center gap-3 rounded-md border border-ctp-green/40 bg-ctp-green/10 p-2 transition hover:bg-ctp-green/15"
|
||||||
|
>
|
||||||
|
{exact.thumbnail_url ? (
|
||||||
|
<img
|
||||||
|
src={exact.thumbnail_url}
|
||||||
|
alt={exact.j_id}
|
||||||
|
className="h-14 w-14 rounded object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block font-mono text-sm text-ctp-text">
|
||||||
|
{exact.j_id}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-ctp-overlay0">
|
||||||
|
{exact.filename} · {formatBytes(exact.size)} ·{" "}
|
||||||
|
{exact.location_count} cop
|
||||||
|
{exact.location_count === 1 ? "y" : "ies"}
|
||||||
|
{exact.e621_post_id
|
||||||
|
? ` · e621 #${exact.e621_post_id}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-xs text-ctp-overlay0">
|
||||||
|
Not in the library — no item has this MD5.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||||
|
Visually similar in the library{" "}
|
||||||
|
<span className="font-mono text-[11px] text-ctp-overlay0">
|
||||||
|
{matches.length}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{isVideo ? (
|
||||||
|
<p className="mt-3 text-xs text-ctp-overlay0">
|
||||||
|
Visual hashing works on images only.
|
||||||
|
</p>
|
||||||
|
) : matches.length === 0 ? (
|
||||||
|
<p className="mt-3 text-xs text-ctp-overlay0">
|
||||||
|
Nothing above the similarity threshold.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{matches.map((match) => (
|
||||||
|
<Link
|
||||||
|
key={match.j_id}
|
||||||
|
to={`/detail/${match.j_id}`}
|
||||||
|
className="group overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-base transition hover:border-ctp-surface2"
|
||||||
|
>
|
||||||
|
<span className="relative block aspect-square overflow-hidden bg-ctp-mantle">
|
||||||
|
{match.thumbnail_url ? (
|
||||||
|
<img
|
||||||
|
src={match.thumbnail_url}
|
||||||
|
alt={match.j_id}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover transition group-hover:scale-[1.03]"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="absolute right-2 top-2 rounded-sm bg-ctp-crust/80 px-1.5 py-0.5 font-mono text-[10px] text-ctp-subtext0">
|
||||||
|
{match.similarity}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="block px-2 py-1.5 font-mono text-[11px] text-ctp-subtext0">
|
||||||
|
{match.j_id}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||||
|
e621 IQDB
|
||||||
|
</h2>
|
||||||
|
{iqdbBusy ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px] text-ctp-overlay0">
|
||||||
|
<Spinner className="h-3 w-3" /> searching…
|
||||||
|
</span>
|
||||||
|
) : iqdb ? (
|
||||||
|
<span className="font-mono text-[11px] text-ctp-overlay0">
|
||||||
|
{iqdb.length} candidate(s)
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isVideo ? (
|
||||||
|
<p className="mt-3 text-xs text-ctp-overlay0">
|
||||||
|
IQDB works on images only.
|
||||||
|
</p>
|
||||||
|
) : !configured ? (
|
||||||
|
<p className="mt-3 text-xs text-ctp-overlay0">
|
||||||
|
<Link to="/account" className="text-ctp-blue hover:underline">
|
||||||
|
Add e621 credentials
|
||||||
|
</Link>{" "}
|
||||||
|
to reverse-search this image on e621.
|
||||||
|
</p>
|
||||||
|
) : iqdbError ? (
|
||||||
|
<p className="mt-3 text-xs text-ctp-red">{iqdbError}</p>
|
||||||
|
) : iqdbBusy && !iqdb ? (
|
||||||
|
<div className="mt-3 flex justify-center py-4">
|
||||||
|
<Spinner className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
) : iqdb && iqdb.length === 0 ? (
|
||||||
|
<p className="mt-3 text-xs text-ctp-overlay0">
|
||||||
|
No similar posts found on e621.
|
||||||
|
</p>
|
||||||
|
) : iqdb ? (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4 xl:grid-cols-6">
|
||||||
|
{iqdb.map((candidate) => {
|
||||||
|
const isExact =
|
||||||
|
candidate.md5 !== null &&
|
||||||
|
check.md5 === candidate.md5;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${candidate.post_id}-${candidate.md5}`}
|
||||||
|
type="button"
|
||||||
|
disabled={candidate.post_id === null}
|
||||||
|
onClick={() => setSelected(candidate)}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-md border bg-ctp-mantle text-left transition",
|
||||||
|
selected?.post_id === candidate.post_id
|
||||||
|
? "border-ctp-mauve ring-2 ring-ctp-mauve/40"
|
||||||
|
: "border-ctp-surface0 hover:border-ctp-surface2",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="relative block aspect-square overflow-hidden">
|
||||||
|
{candidate.preview_url ? (
|
||||||
|
<img
|
||||||
|
src={candidate.preview_url}
|
||||||
|
alt={`Post #${candidate.post_id}`}
|
||||||
|
loading="lazy"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-full w-full items-center justify-center text-[10px] text-ctp-overlay0">
|
||||||
|
No preview
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{candidate.rating ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute left-1 top-1 rounded-full px-1 py-0.5 font-mono text-[9px] font-semibold uppercase",
|
||||||
|
ratingPill[candidate.rating] ??
|
||||||
|
"bg-ctp-surface1 text-ctp-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{candidate.rating}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{isExact ? (
|
||||||
|
<span className="absolute right-1 top-1 rounded-full bg-ctp-green/90 px-1 py-0.5 font-mono text-[9px] font-semibold uppercase text-ctp-crust">
|
||||||
|
MD5
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="block px-1 py-0.5 font-mono text-[10px] text-ctp-overlay0">
|
||||||
|
#{candidate.post_id}
|
||||||
|
{candidate.score !== null
|
||||||
|
? ` · ${Math.round(candidate.score)}%`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<div className="mt-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-3">
|
||||||
|
<p className="font-mono text-xs text-ctp-subtext1">
|
||||||
|
Post #{selected.post_id}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-ctp-overlay0">
|
||||||
|
{selected.rating
|
||||||
|
? (RATING_LABELS[selected.rating] ??
|
||||||
|
selected.rating)
|
||||||
|
: "Unknown rating"}
|
||||||
|
{selected.score_total !== null
|
||||||
|
? ` · ▲ ${selected.score_total}`
|
||||||
|
: ""}
|
||||||
|
{selected.fav_count !== null
|
||||||
|
? ` · ${selected.fav_count} favs`
|
||||||
|
: ""}
|
||||||
|
{selected.width && selected.height
|
||||||
|
? ` · ${selected.width}×${selected.height}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
{selected.tags_preview.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{selected.tags_preview.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded border border-ctp-surface1 px-1 py-0.5 font-mono text-[10px] text-ctp-subtext0"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to={`/detail/${selected.post_id}`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-ctp-blue hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" /> View post
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-[11px] text-ctp-overlay0">
|
||||||
|
Select a candidate for its rating, score, favorites and
|
||||||
|
tags.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-xs text-ctp-overlay0">
|
||||||
|
Waiting for the library check…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,11 +85,12 @@ export async function api<T>(path: string, options: ApiOptions = {}): Promise<T>
|
|||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uploadFile(
|
export function uploadFile<T = TempUpload>(
|
||||||
file: File,
|
file: File,
|
||||||
fields: Record<string, string> = {},
|
fields: Record<string, string> = {},
|
||||||
onProgress?: (percent: number) => void,
|
onProgress?: (percent: number) => void,
|
||||||
): Promise<TempUpload> {
|
path = "/api/uploads/",
|
||||||
|
): Promise<T> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
@@ -98,7 +99,7 @@ export function uploadFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const request = new XMLHttpRequest();
|
const request = new XMLHttpRequest();
|
||||||
request.open("POST", "/api/uploads/");
|
request.open("POST", path);
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) request.setRequestHeader("Authorization", `Token ${token}`);
|
if (token) request.setRequestHeader("Authorization", `Token ${token}`);
|
||||||
request.upload.addEventListener("progress", (event) => {
|
request.upload.addEventListener("progress", (event) => {
|
||||||
@@ -114,7 +115,7 @@ export function uploadFile(
|
|||||||
data = null;
|
data = null;
|
||||||
}
|
}
|
||||||
if (request.status >= 200 && request.status < 300) {
|
if (request.status >= 200 && request.status < 300) {
|
||||||
resolve(data as TempUpload);
|
resolve(data as T);
|
||||||
} else {
|
} else {
|
||||||
reject(new ApiError(request.status, data));
|
reject(new ApiError(request.status, data));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,6 +265,20 @@ export interface FollowFeed {
|
|||||||
results: FollowFeedEntry[];
|
results: FollowFeedEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SimilarityCheck {
|
||||||
|
check_id: string;
|
||||||
|
original_filename: string;
|
||||||
|
md5: string;
|
||||||
|
size: number;
|
||||||
|
file_url: string | null;
|
||||||
|
results: {
|
||||||
|
exact: DuplicateEntry | null;
|
||||||
|
matches: DuplicateEntry[];
|
||||||
|
};
|
||||||
|
expires_at: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FollowCloud {
|
export interface FollowCloud {
|
||||||
status: "ready" | "building";
|
status: "ready" | "building";
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user