Author SHA1 Message Date
JakeBreath 7ca84f4fea Point desktop updates at the Gitea release feed
CI / Frontend build & lint (push) Successful in 25s
CI / Backend tests (push) Successful in 2m41s
The updater now resolves the newest non-draft desktop-v* release through the
Gitea API at check time (J621_UPDATE_REPO, lowercase because the API path is
case-sensitive), picks the platform's latest*.yml asset and uses that release
as a generic electron-updater feed; J621_UPDATE_URL still overrides
everything. Verified against the live API: release picked, yml fetched,
artifact HEAD 200.

electron-builder's publish.url is now metadata only (still needed so the
build emits latest*.yml). Docs updated: CD release assets are the feed, the
website /desktop/ feed only matters for installs before 0.1.2.
2026-09-23 22:00:49 -05:00
JakeBreath 5f237aa2e3 Widen the header and collapse blacklist listings by default
- the header row is no longer capped at 1600px, so the nav hugs the left
  edge and the status/account controls the right; main content and footer
  keep their width
- OnlinePage's blacklist chips and the Followed page's blacklisted-tag
  cloud start collapsed behind a count toggle (N tags / N entries)
2026-09-23 21:57:48 -05:00
JakeBreath 90ba2ecff7 Bump the desktop app to 0.1.2 2026-09-23 21:40:43 -05:00
JakeBreath 73bf4f9e38 Rework the metadata modal: fullscreen, inline sections, bigger previews
- near-fullscreen panel (up to 1400px / 92vh) with two independently
  scrolling columns; the left preview uses self-start so its border hugs the
  image instead of stretching to the modal height
- J-ID matches render as a larger tile grid (was 48px rows) and IQDB
  candidates get bigger tiles too
- Link to e621 post and Custom metadata are shown inline instead of behind
  tabs, each with its own heading
- new ThumbImage component: spinner while loading and a broken-image icon on
  error, with alt text removed so a pending tile never reads as 'J-7786'
2026-09-23 21:39:48 -05:00
JakeBreath 7c2569522f Make thumbnail generation atomic and warm it on import
- write thumbnails to a .part file and os.replace() them, so concurrent
  requests never read a half-written JPEG
- a stale thumbnail plus a vanished source no longer raises through the
  request (getmtime on a missing file returned 500); it falls back cleanly
- ensure_thumbnail(item) warms the preview when a file is indexed, keeping
  image decoding out of the request path
2026-09-23 21:35:09 -05:00
JakeBreath a62195ffce Re-sign visual-match thumbnails on every detail fetch
Match rows stored a signed URL minted when the scan ran, so it aged out (or
used the pre-stable signing scheme) and the modal showed broken tiles even
after legacy signatures were fixed. Rows now carry item_id/j_id and the
detail serializer mints a fresh thumbnail URL per request; matches whose
item no longer exists are dropped.
2026-09-23 21:33:20 -05:00
JakeBreath c73a81a5f4 Fix 500 on legacy signed URLs
A TimestampSigner value is an HMAC over 'payload:timestamp', so a plain
Signer's HMAC check accepts it and the embedded timestamp then reached the
JSON decoder, raising JSONDecodeError (not BadSignature) and surfacing as a
500. That broke every stored visual-match thumbnail URL minted before the
stable scheme, so the J-ID match tiles never loaded on prod.

Detect the legacy shape by its extra separator and verify it with
TimestampSigner; malformed input returns None instead of raising.
2026-09-23 21:31:34 -05:00
19 changed files with 798 additions and 437 deletions
+6 -4
View File
@@ -7,10 +7,12 @@
# * builds the desktop packages and attaches them (plus the update
# metadata) to the Gitea release tagged `desktop-v<package.json version>`.
#
# The live update feed (deploy/data/desktop, served by the frontend nginx at
# /desktop/) is not touched here: it is runtime state on the deploy host and
# is still published with `deploy/push_desktop.sh --no-build` from a machine
# that can reach it.
# The desktop build also attaches the update metadata (latest*.yml) to the
# release; that is the desktop updater's feed, resolved through the Gitea API
# at check time (see desktop/README.md). The older website feed
# (deploy/data/desktop, served at /desktop/) is runtime state on the deploy
# host and only needed for installs before 0.1.2; it is refreshed with
# `deploy/push_desktop.sh` from a machine that can reach the deploy host.
#
# Registry login uses a repo PAT with the minimal write:package scope (the
# Gitea registry rejects the automatic job token, go-gitea/gitea#23642);
+4 -3
View File
@@ -56,9 +56,10 @@ Project constraints (do not regress):
`desktop-v<version>`). Jobs run on the user-scoped runners: `ubuntu-latest`
on nitro-ci, `desktop` on msi-mortar-ci. Do not add actions/cache
(`cache: pip`/`npm`) to these workflows: Gitea's cache service hangs the job
on restore/save. The live desktop update feed (deploy/data/desktop) is still
published with `deploy/push_desktop.sh --no-build` from a machine with SSH
to the deploy host — CI has no key for that.
on restore/save. Desktop updates read the release assets (latest*.yml) from
the Gitea API at check time; the older website feed (deploy/data/desktop)
only matters for installs before 0.1.2 and is refreshed with
`deploy/push_desktop.sh` from a machine with SSH to the deploy host.
- Security/permission tests live in backend/apps/core/tests and need a
one-time grant: GRANT ALL ON `test_j621`.* TO 'j621'@'%';
+52
View File
@@ -150,6 +150,7 @@ class TempUploadSerializer(serializers.ModelSerializer):
iqdb_checked = serializers.SerializerMethodField()
processing = serializers.SerializerMethodField()
similar_count = serializers.SerializerMethodField()
visual_matches = serializers.SerializerMethodField()
class Meta:
model = TempUpload
@@ -242,6 +243,57 @@ class TempUploadSerializer(serializers.ModelSerializer):
def get_similar_count(self, obj):
return len(obj.iqdb_data or []) + len(obj.visual_matches or [])
@staticmethod
def _visual_item_id(entry):
if not isinstance(entry, dict):
return None
item_id = entry.get("item_id")
if item_id is None:
j_id = str(entry.get("j_id") or "")
if j_id.upper().startswith("J-"):
j_id = j_id[2:]
item_id = j_id if j_id.isdigit() else None
try:
return int(item_id)
except (TypeError, ValueError):
return None
def get_visual_matches(self, obj):
"""Rebuild match rows with fresh signed thumbnail URLs.
Storing the signed URL meant it aged out (or came from an older
signing scheme) and the "Already in your library" grid showed broken
tiles. The stored rows only carry the item reference now.
"""
entries = obj.visual_matches or []
if not entries:
return entries
wanted = {}
for entry in entries:
item_id = self._visual_item_id(entry)
if item_id is not None:
wanted[item_id] = None
items = MediaItem.objects.in_bulk(list(wanted))
user = self._request_user()
request = self.context.get("request")
matches = []
for entry in entries:
item_id = self._visual_item_id(entry)
item = items.get(item_id) if item_id is not None else None
if item is None:
continue
matches.append(
{
"j_id": f"J-{item.id}",
"filename": entry.get("filename") or item.md5,
"similarity": entry.get("similarity"),
"thumbnail_url": signed_media_url(
item, user, "thumbnail", request=request
),
}
)
return matches
class TempUploadListSerializer(TempUploadSerializer):
"""Compact staged-upload row for the board and the status polling.
+60 -11
View File
@@ -6,6 +6,7 @@ import os
import re
import shutil
import subprocess
import uuid
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlencode
@@ -481,15 +482,38 @@ def sanitize_iqdb_results(results):
return cleaned
def _thumbnail_is_fresh(target, path):
"""True when the cached thumbnail exists and is at least as new as source."""
try:
stat = target.stat()
if stat.st_size <= 0:
return False
return stat.st_mtime >= os.path.getmtime(path)
except OSError:
return False
def _thumbs_dir():
thumbs_dir = Path(settings.MEDIA_ROOT) / "thumbs"
thumbs_dir.mkdir(parents=True, exist_ok=True)
return thumbs_dir
def generate_video_thumbnail(md5, path):
"""Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs."""
if not shutil.which("ffmpeg"):
return None
thumbs_dir = Path(settings.MEDIA_ROOT) / "thumbs"
thumbs_dir.mkdir(parents=True, exist_ok=True)
try:
thumbs_dir = _thumbs_dir()
except OSError:
logger.exception("Could not create the thumbnail folder")
return None
target = thumbs_dir / f"{md5}.jpg"
if target.exists() and target.stat().st_mtime >= os.path.getmtime(path):
if _thumbnail_is_fresh(target, path):
return target
# Write beside the target and move it into place, so a concurrent request
# can never read a half-written JPEG.
temp = thumbs_dir / f".{md5}.{uuid.uuid4().hex}.part.jpg"
command = [
"ffmpeg",
"-y",
@@ -503,11 +527,14 @@ def generate_video_thumbnail(md5, path):
"scale=480:-2",
"-loglevel",
"error",
str(target),
str(temp),
]
try:
subprocess.run(command, check=True, capture_output=True, timeout=60)
os.replace(temp, target)
except (subprocess.SubprocessError, OSError):
logger.exception("Could not build a video thumbnail for %s", path)
temp.unlink(missing_ok=True)
return None
return target if target.exists() else None
@@ -517,14 +544,18 @@ def generate_image_thumbnail(md5, path):
The thumbnail action used to serve full-size originals for images; a
cached 480px JPEG keeps the library grid light without touching the
original file. Returns ``None`` when Pillow cannot decode the format, so
callers can fall back to the original.
original file. Returns ``None`` when the source is missing or Pillow
cannot decode it, so callers can fall back to the original.
"""
thumbs_dir = Path(settings.MEDIA_ROOT) / "thumbs"
thumbs_dir.mkdir(parents=True, exist_ok=True)
try:
thumbs_dir = _thumbs_dir()
except OSError:
logger.exception("Could not create the thumbnail folder")
return None
target = thumbs_dir / f"{md5}.jpg"
if target.exists() and target.stat().st_mtime >= os.path.getmtime(path):
if _thumbnail_is_fresh(target, path):
return target
temp = thumbs_dir / f".{md5}.{uuid.uuid4().hex}.part.jpg"
try:
with Image.open(path) as image:
# Animated formats: the first frame is the preview.
@@ -532,9 +563,27 @@ def generate_image_thumbnail(md5, path):
frame = ImageOps.exif_transpose(image) or image
frame = frame.convert("RGB")
frame.thumbnail((480, 480))
frame.save(target, "JPEG", quality=82, optimize=True)
frame.save(temp, "JPEG", quality=82, optimize=True)
os.replace(temp, target)
except Exception: # noqa: BLE001 - previews must never break serving
logger.exception("Could not build an image thumbnail for %s", path)
target.unlink(missing_ok=True)
temp.unlink(missing_ok=True)
return None
return target if target.exists() else None
def ensure_thumbnail(item):
"""Generate an item's cached thumbnail if it is missing or stale.
Warming thumbnails when a file is indexed keeps image decoding out of the
request path, where the upload pipeline's hashing used to starve it.
"""
location = item.locations.first()
if location is None:
return None
path = Path(location.path)
if not path.is_file():
return None
if path.suffix.lower() in VIDEO_EXTENSIONS:
return generate_video_thumbnail(item.md5, path)
return generate_image_thumbnail(item.md5, path)
+13 -6
View File
@@ -36,17 +36,24 @@ def sign_payload(payload, salt, now=None):
def load_payload(signature, salt, legacy_max_age=86400):
"""Verify a signed payload; ``None`` when missing, tampered with or expired.
Signatures minted before the stable scheme (``TimestampSigner``) are still
accepted for one release so pages open across the deploy keep working.
Legacy ``TimestampSigner`` values are still accepted for one release.
Detect them by their extra separator (``payload:timestamp:signature``):
a plain ``Signer`` accepts the HMAC a ``TimestampSigner`` computed over
``payload:timestamp`` and then chokes on the embedded timestamp while
decoding the JSON payload, which used to surface as a 500.
"""
try:
data = signing.Signer(salt=salt).unsign_object(signature)
except signing.BadSignature:
if not signature:
return None
if signature.count(":") >= 2:
try:
return signing.TimestampSigner(salt=salt).unsign_object(
signature, max_age=legacy_max_age
)
except signing.BadSignature:
except (signing.BadSignature, ValueError):
return None
try:
data = signing.Signer(salt=salt).unsign_object(signature)
except (signing.BadSignature, ValueError):
return None
if not isinstance(data, dict):
return None
+50 -1
View File
@@ -12,8 +12,10 @@ import shutil
import tempfile
import time
from pathlib import Path
from unittest import mock
from django.contrib.auth import get_user_model
from django.core import signing
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase, override_settings
@@ -23,7 +25,7 @@ from rest_framework.authtoken.models import Token
from apps.library import services
from apps.library.models import MediaItem, MediaLocation, TempUpload
from apps.library.signing_urls import sign_payload
from apps.library.signing_urls import load_payload, sign_payload
User = get_user_model()
@@ -118,6 +120,27 @@ class MediaCacheTests(TestCase):
self.client.get(url, self.signed("thumbnail"))
self.assertEqual(thumb.stat().st_mtime_ns, before)
def test_ensure_thumbnail_reuses_the_cache(self):
first = services.ensure_thumbnail(self.item)
self.assertIsNotNone(first)
self.assertTrue(first.exists())
mtime = first.stat().st_mtime_ns
second = services.ensure_thumbnail(self.item)
self.assertEqual(second, first)
self.assertEqual(second.stat().st_mtime_ns, mtime)
def test_thumbnail_of_a_missing_source_does_not_error(self):
"""Regression: getmtime() on a vanished source used to raise a 500."""
thumbs = self._media / "thumbs"
thumbs.mkdir(parents=True, exist_ok=True)
(thumbs / f"{self.item.md5}.jpg").write_bytes(b"stale")
Path(self.item.locations.first().path).unlink()
self.assertIsNone(services.ensure_thumbnail(self.item))
response = self.client.get(
f"/api/files/J-{self.item.id}/thumbnail/", self.signed("thumbnail")
)
self.assertEqual(response.status_code, 404)
def test_staged_files_cache_briefly(self):
temp = TempUpload.objects.create(
user=self.user,
@@ -137,3 +160,29 @@ class MediaCacheTests(TestCase):
f"max-age={services.TEMP_CACHE_SECONDS}", response["Cache-Control"]
)
self.assertNotIn("immutable", response["Cache-Control"])
def test_legacy_timestamp_signatures_are_accepted(self):
"""URLs minted before the stable scheme must not 500.
A TimestampSigner HMAC also passes a plain Signer's check, so the
embedded timestamp used to reach the JSON decoder and blow up.
"""
payload = {"item": self.item.id, "user": self.user.id, "action": "raw"}
legacy = signing.dumps(payload, salt=services.MEDIA_FILE_SALT)
self.assertEqual(
load_payload(legacy, services.MEDIA_FILE_SALT)["item"], self.item.id
)
response = self.client.get(
f"/api/files/J-{self.item.id}/raw/", {"sig": legacy}
)
self.assertEqual(response.status_code, 200)
def test_expired_and_malformed_signatures_return_none(self):
payload = {"item": self.item.id, "user": self.user.id, "action": "raw"}
with mock.patch.object(signing, "time") as clock:
clock.time.return_value = time.time() - 3 * 86400
expired = signing.dumps(payload, salt=services.MEDIA_FILE_SALT)
self.assertIsNone(load_payload(expired, services.MEDIA_FILE_SALT))
self.assertIsNone(load_payload("bogus", services.MEDIA_FILE_SALT))
self.assertIsNone(load_payload("a:b", services.MEDIA_FILE_SALT))
self.assertIsNone(load_payload("", services.MEDIA_FILE_SALT))
@@ -287,6 +287,37 @@ class StagedUploadWorkflowTests(TestCase):
self.assertEqual(body["visual_matches"], [])
self.assertEqual(body["status"], TempUpload.STATUS_PENDING)
def test_detail_resigns_stored_visual_match_urls(self):
"""Stored matches carry only the item reference; URLs are re-minted.
Embedding the signed URL meant it expired (or used an older signing
scheme) and the modal showed alt text instead of thumbnails.
"""
client = self.api_client(self.uploader)
self.seed_library_item(client, "seed-resign")
item = MediaItem.objects.get()
temp = self.make_temp(self.uploader, "resign")
TempUpload.objects.filter(pk=temp.pk).update(
visual_matches=[
{
"item_id": item.id,
"j_id": f"J-{item.id}",
"filename": "seed-resign.png",
"similarity": 96.5,
"thumbnail_url": "/api/files/J-x/thumbnail/?sig=stale",
},
{"item_id": 999999, "j_id": "J-999999", "filename": "gone.png"},
]
)
body = client.get(f"/api/uploads/{temp.id}/").json()
self.assertEqual(len(body["visual_matches"]), 1)
match = body["visual_matches"][0]
self.assertEqual(match["j_id"], f"J-{item.id}")
self.assertEqual(match["similarity"], 96.5)
self.assertNotIn("stale", match["thumbnail_url"])
self.assertIn("/thumbnail/", match["thumbnail_url"])
self.assertIn(f"v={item.md5}", match["thumbnail_url"])
def test_visual_match_phase_rejects_completed_uploads(self):
client = self.api_client(self.uploader)
temp = self.make_temp(
+7
View File
@@ -60,6 +60,7 @@ def match_hashes(hashes, index, limit=10, user=None, request=None):
location = item.locations.first()
matches.append(
{
"item_id": item.id,
"j_id": f"J-{item.id}",
"filename": Path(location.rel_path).name if location else item.md5,
"similarity": round(similarity * 100, 1),
@@ -127,6 +128,12 @@ def complete_temp_upload(temp, download_url=None):
services.ensure_visual_hashes(item)
temp.file.delete(save=False)
# Warm the preview while the import is still off the request path.
try:
services.ensure_thumbnail(item)
except Exception: # noqa: BLE001 - a preview must not fail the import
logger.exception("Could not warm the thumbnail for J-%s", item.id)
temp.library_item = item
temp.status = TempUpload.STATUS_COMPLETED
+9 -12
View File
@@ -145,9 +145,12 @@ Build the desktop installers without publishing anything:
```
Hand the files out or attach them to a Gitea release manually — the script
prints sizes and SHA-256 sums for the release notes.
prints sizes and SHA-256 sums for the release notes. The release assets are
also the desktop update feed; the app resolves the newest `desktop-v*`
release on Gitea at check time (see `desktop/README.md`).
Push the desktop builds and their update metadata to the frontend's feed:
The frontend's `/desktop/` feed is optional now — kept for manual downloads
and for installs older than 0.1.2. To publish it:
```bash
./push_desktop.sh # build Linux packages + copy the feed to jakerasp
@@ -160,19 +163,13 @@ The remote copy defaults to `jakerasp:/home/jake/servers/J621`, or
`$J621_DESKTOP_FEED_HOST` when set. Artifacts land in `deploy/data/desktop/`,
which the frontend nginx mounts read-only and serves at `/desktop/`. The
remote copy uses rsync when both ends have it, tar over ssh when the server
does not. The desktop app's "Check for updates…" menu item reads
`latest-linux.yml` / `latest.yml` from there (see `desktop/README.md`).
Backend-only composes have no frontend, so no feed.
does not. Backend-only composes have no frontend, so no website feed.
The manual **CD** workflow (Actions tab) builds the desktop packages on the
runner and attaches them plus the update metadata to the Gitea release
`desktop-v<version>`; it does not touch the live feed, because that is
runtime state on the deploy host and CI has no SSH key for it. After a CD run,
publish the feed from a machine that can reach the deploy checkout:
```bash
./push_desktop.sh --no-build --local # or without --local to also copy it
```
`desktop-v<version>`; that is what the desktop updater reads. The website feed
is not touched by CI (runtime state on the deploy host, no SSH key there); use
`push_desktop.sh` when it needs refreshing for old installs.
## Scheduled jobs
+7 -6
View File
@@ -1,11 +1,12 @@
#!/bin/bash
# Build the J621 desktop packages and publish them to the update feed.
# Build the J621 desktop packages, optionally publish them to the website
# feed.
#
# Locally the feed is deploy/data/desktop, which the frontend nginx mounts
# read-only and serves at /desktop/. With --host the same directory is also
# copied to a remote deploy checkout (rsync, or tar over ssh when the server
# has no rsync). electron-updater reads latest-linux.yml / latest.yml from
# there; the feed URL comes from desktop/electron-builder.yml.
# Desktop updates no longer depend on this: the app resolves the newest
# `desktop-v*` release on Gitea at check time (see desktop/README.md). This
# script builds the packages and can copy them to deploy/data/desktop, which
# the frontend nginx mounts read-only and serves at /desktop/ for manual
# downloads and for pre-0.1.2 installs.
#
# Usage: ./push_desktop.sh [--win] [--no-build] [--local] [--host user@server:/path]
# --win also cross-build the Windows NSIS installer (needs wine)
+19 -8
View File
@@ -49,7 +49,8 @@ npm run dist:all
`deploy/build_desktop.sh` wraps the same commands, installs dependencies on
first run and prints sizes plus SHA-256 sums for release notes. Nothing is
published by it; `deploy/push_desktop.sh` is the one that feeds auto-updates.
published by it; the CD workflow attaches the artifacts to the Gitea release,
which is also the update feed.
The Arch package can be installed and removed with pacman:
@@ -68,19 +69,29 @@ will warn, and it has not been smoke-tested on real Windows.
The app checks only when asked (**J621 → Check for updates…** in the menu):
Linux packages install through pacman/dpkg, which needs administrator rights,
and the Windows build is unsigned, so nothing installs silently. The check
reads `latest-linux.yml` / `latest.yml` from the feed configured in
`electron-builder.yml` (`publish.url`, baked into `app-update.yml`); set
`J621_UPDATE_URL` to point a build at another feed (the smoke test uses this).
and the Windows build is unsigned, so nothing installs silently.
The check resolves the feed itself: it asks the Gitea API for the newest
non-draft `desktop-v*` release (`J621_UPDATE_REPO`, default
`https://gitea.rainbow-herring.ts.net/jakebreath/j621` — lowercase on purpose,
the API path is case-sensitive), picks the `latest-linux.yml` / `latest.yml`
asset for the platform and uses that release as an electron-updater generic
feed. The baked `publish.url` in `electron-builder.yml` is metadata only.
`J621_UPDATE_URL` overrides the whole lookup (the smoke test uses this).
Publishing a release:
1. Bump `version` in `desktop/package.json` — that is what the updater compares.
2. `./deploy/push_desktop.sh --win` builds deb/pacman/NSIS and copies the
artifacts plus both channel files into `deploy/data/desktop/`, which the
frontend nginx serves read-only at `/desktop/`.
2. Run the manual CD workflow, which builds the packages and attaches them
plus both channel files to the Gitea release `desktop-v<version>`.
`./deploy/push_desktop.sh --win` does the same build locally (and can also
copy the files to the website feed, which is optional now).
3. Existing installs find the new version on their next manual check.
Note for the 0.1.1 → 0.1.2 step: 0.1.1 only knows the old `/desktop/` feed, so
publish 0.1.2 there once (`./deploy/push_desktop.sh --no-build`, or install it
manually). From 0.1.2 on, updates come from Gitea.
`package-type` in the app resources tells electron-updater whether to run
`pacman -U` or `dpkg -i` (both via pkexec/sudo); the per-user NSIS install
updates without elevation.
+5 -4
View File
@@ -2,12 +2,13 @@ appId: io.j621.desktop
productName: J621
copyright: Copyright (c) 2026 JakeBreath — Jake Labs Non-Commercial Software Licence
# Update feed served by the frontend nginx (deploy/data/desktop, published
# with deploy/push_desktop.sh). Baked into resources/app-update.yml; override
# at runtime with J621_UPDATE_URL for a fork or a test feed.
# Update feed: desktop/src/main.ts resolves the newest desktop-v* release on
# Gitea at check time (J621_UPDATE_REPO). This block only tells electron-builder
# to emit latest.yml/latest-linux.yml next to the installers; J621_UPDATE_URL
# overrides the feed for a fork or a test.
publish:
provider: generic
url: https://j621.rainbow-herring.ts.net/desktop
url: https://gitea.rainbow-herring.ts.net/JakeBreath/J621/releases
directories:
output: release
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "j621-desktop",
"version": "0.1.1",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "j621-desktop",
"version": "0.1.1",
"version": "0.1.2",
"license": "LicenseRef-Jake-Labs-Non-Commercial",
"dependencies": {
"electron-updater": "^6.8.9"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "j621-desktop",
"productName": "J621",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"description": "Desktop shell for the J621 self-hosted media archive",
"author": {
+82 -4
View File
@@ -238,8 +238,9 @@ function isDownloadNavigation(url: URL): boolean {
/**
* Updates are manual by design: Linux packages install through pacman/dpkg
* (pkexec/sudo) and the Windows build is unsigned, so the app asks before
* downloading and again before installing. The feed comes from `publish` in
* electron-builder.yml and can be overridden with J621_UPDATE_URL.
* downloading and again before installing. The feed is resolved at check time
* from the newest `desktop-v*` release on Gitea (`J621_UPDATE_REPO`);
* `J621_UPDATE_URL` overrides it for forks and the smoke test.
*/
type UpdateEvent =
| { state: "available"; version: string }
@@ -250,9 +251,84 @@ type UpdateEvent =
let updateReporter: ((event: UpdateEvent) => void) | null = null;
let updateCheckRunning = false;
function setUpUpdates(win: BrowserWindow): void {
const UPDATE_REPO =
process.env.J621_UPDATE_REPO?.trim() ||
"https://gitea.rainbow-herring.ts.net/jakebreath/j621";
interface ReleaseAsset {
name: string;
browser_download_url: string;
}
interface Release {
tag_name: string;
draft: boolean;
prerelease: boolean;
assets?: ReleaseAsset[];
}
function releaseVersion(tag: string): [number, number, number] | null {
const match = /^desktop-v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function compareVersions(
a: [number, number, number],
b: [number, number, number],
): number {
for (let index = 0; index < 3; index += 1) {
if (a[index] !== b[index]) return a[index] - b[index];
}
return 0;
}
/**
* Resolve the generic feed base electron-updater should use.
*
* Gitea's API path is case-sensitive (owner/repo must match the login), while
* the asset URLs it returns are canonical, so the base is derived from the
* platform's metadata asset (`latest-linux.yml` / `latest.yml`).
*/
async function resolveReleaseFeed(): Promise<string> {
const override = process.env.J621_UPDATE_URL?.trim();
if (override) autoUpdater.setFeedURL({ provider: "generic", url: override });
if (override) return override;
const match = /^(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)\/?$/.exec(UPDATE_REPO);
if (!match) {
throw new Error(
`J621_UPDATE_REPO must be <origin>/<owner>/<repo> (got ${UPDATE_REPO}).`,
);
}
const [, origin, owner, repo] = match;
const response = await fetch(
`${origin}/api/v1/repos/${owner}/${repo}/releases?limit=50`,
{ headers: { Accept: "application/json" } },
);
if (!response.ok) {
throw new Error(`Release lookup on Gitea failed (HTTP ${response.status}).`);
}
const releases = (await response.json()) as Release[];
const assetName =
process.platform === "win32" ? "latest.yml" : "latest-linux.yml";
let best: { version: [number, number, number]; asset: ReleaseAsset } | null =
null;
for (const release of releases) {
if (release.draft || release.prerelease) continue;
const version = releaseVersion(release.tag_name);
if (!version) continue;
const asset = release.assets?.find((entry) => entry.name === assetName);
if (!asset) continue;
if (!best || compareVersions(version, best.version) > 0) {
best = { version, asset };
}
}
if (!best) {
throw new Error(`No ${assetName} asset found in ${UPDATE_REPO} releases.`);
}
return best.asset.browser_download_url.replace(/\/[^/]*$/, "");
}
function setUpUpdates(win: BrowserWindow): void {
if (!app.isPackaged) autoUpdater.forceDevUpdateConfig = true;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
@@ -336,6 +412,8 @@ async function checkForUpdates(win: BrowserWindow): Promise<void> {
if (updateCheckRunning) return;
updateCheckRunning = true;
try {
const feed = await resolveReleaseFeed();
autoUpdater.setFeedURL({ provider: "generic", url: feed });
await autoUpdater.checkForUpdates();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
+1 -1
View File
@@ -113,7 +113,7 @@ export function AppShell() {
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-40 border-b border-ctp-surface0 bg-ctp-crust/95 backdrop-blur">
<div className="mx-auto flex h-14 w-full max-w-[1600px] items-center gap-4 px-4">
<div className="flex h-14 w-full items-center gap-4 px-3 sm:px-4">
<Link to="/" className="flex shrink-0 items-center">
<span className="rounded bg-ctp-mauve px-2 py-1 font-mono text-xs font-bold tracking-wide text-ctp-crust">
J621
+29 -12
View File
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, StarOff } from "lucide-react";
import { ChevronDown, ChevronRight, Eye, StarOff } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
@@ -148,28 +148,43 @@ function BlacklistCloudPanel() {
q.state.data?.status === "building" ? 2000 : false,
});
const cloud = query.data;
const [expanded, setExpanded] = useState(false);
return (
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<button
type="button"
onClick={() => setExpanded((value) => !value)}
title={expanded ? "Hide the tags" : "Show the tags"}
className="flex w-full items-center justify-between gap-2 text-left"
>
<h2 className="text-sm font-semibold text-ctp-subtext1">
Blacklisted tags
</h2>
<p className="text-xs text-ctp-overlay0">
<span className="flex items-center gap-1.5 font-mono text-[11px] text-ctp-overlay0">
{cloud ? `${cloud.blacklist_count} entries` : "…"}
{cloud?.status === "building" ? (
<Spinner className="h-3 w-3" />
) : null}
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
</button>
{expanded ? (
<>
<p className="mt-2 text-xs text-ctp-overlay0">
{cloud?.source === "user"
? "From your e621 blacklist"
: "From e621's anonymous default blacklist"}
{cloud ? ` · ${cloud.blacklist_count} entries` : ""}
{cloud ? ` · ${cloud.posts} feed post(s) scanned` : ""}
{cloud?.computed_at ? ` · computed ${formatDate(cloud.computed_at)}` : ""}
{cloud?.status === "building" ? (
<span className="ml-2 inline-flex items-center gap-1.5">
<Spinner className="h-3 w-3" /> building…
</span>
) : null}
{cloud?.computed_at
? ` · computed ${formatDate(cloud.computed_at)}`
: ""}
</p>
</div>
{query.isPending ? (
<div className="mt-3 flex justify-center py-4">
<Spinner className="h-4 w-4" />
@@ -190,6 +205,8 @@ function BlacklistCloudPanel() {
No blacklisted tags in your feeds.
</p>
)}
</>
) : null}
</section>
);
}
+22 -3
View File
@@ -1,5 +1,5 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { X } from "lucide-react";
import { ChevronDown, ChevronRight, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom";
@@ -43,6 +43,7 @@ export default function OnlinePage() {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<string | null>(null);
const [blacklistDraft, setBlacklistDraft] = useState("");
const [showBlacklist, setShowBlacklist] = useState(false);
const tagInput = draft ?? tags;
const zoom = useUi((state) => state.zoom);
const perPage = useUi((state) => state.e621PerPage);
@@ -271,10 +272,27 @@ export default function OnlinePage() {
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => setShowBlacklist((value) => !value)}
title={showBlacklist ? "Hide the blacklist" : "Show the blacklist"}
className="flex items-center justify-between gap-2 text-left"
>
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Your blacklist
</span>
{credentials?.configured ? (
<span className="flex items-center gap-1 font-mono text-[11px] text-ctp-overlay0">
{blacklistEntries.length} tag
{blacklistEntries.length === 1 ? "" : "s"}
{showBlacklist ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
</button>
{showBlacklist ? (
credentials?.configured ? (
<>
{blacklistEntries.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
@@ -337,7 +355,8 @@ export default function OnlinePage() {
</Link>{" "}
to manage your blacklist.
</p>
)}
)
) : null}
</div>
<div className="flex flex-col gap-2">
+108 -69
View File
@@ -1,6 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
ImageOff,
ListChecks,
Trash2,
UploadCloud,
@@ -310,6 +311,54 @@ function TempCard({
);
}
/** Image with a spinner/broken-image placeholder instead of alt-text fallback. */
function ThumbImage({
src,
className,
}: {
src: string;
className?: string;
}) {
const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
const [failedSrc, setFailedSrc] = useState<string | null>(null);
// Derived from the current src: no effect needed when it changes.
const ready = Boolean(src) && loadedSrc === src;
const failed = !src || failedSrc === src;
return (
<span
className={cn(
"relative block overflow-hidden bg-ctp-surface0/60",
className,
)}
>
{src ? (
<img
src={src}
alt=""
loading="lazy"
onLoad={() => setLoadedSrc(src)}
onError={() => setFailedSrc(src)}
className={cn(
"h-full w-full object-cover transition-opacity duration-200",
ready ? "opacity-100" : "opacity-0",
)}
/>
) : null}
{!ready && !failed ? (
<span className="absolute inset-0 flex items-center justify-center">
<Spinner className="h-3.5 w-3.5 text-ctp-overlay0" />
</span>
) : null}
{failed ? (
<span className="absolute inset-0 flex items-center justify-center text-ctp-overlay0">
<ImageOff className="h-4 w-4" />
</span>
) : null}
</span>
);
}
/**
* A "J-x uploaded" notification.
*
@@ -378,7 +427,6 @@ function MetadataForm({
}) {
const queryClient = useQueryClient();
const credentials = useE621((state) => state.credentials);
const [tab, setTab] = useState<"link" | "custom">("link");
const [postId, setPostId] = useState("");
const [selected, setSelected] = useState<E621IqdbCandidate | null>(null);
const [rating, setRating] = useState<Rating>(temp.custom_rating);
@@ -485,8 +533,8 @@ function MetadataForm({
}
return (
<>
<div className="flex items-start justify-between gap-3">
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-start justify-between gap-3 border-b border-ctp-surface0 px-5 py-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-ctp-subtext1">
Set metadata
@@ -495,6 +543,12 @@ function MetadataForm({
{temp.original_filename}
</p>
</div>
<div className="flex items-center gap-2">
{temp.processing ? (
<span className="flex items-center gap-1.5 font-mono text-[10px] text-ctp-overlay0">
<Spinner className="h-3 w-3" /> working
</span>
) : null}
<button
type="button"
onClick={onClose}
@@ -504,22 +558,24 @@ function MetadataForm({
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
<div className="w-fit max-w-full overflow-hidden rounded-md border border-ctp-surface0 bg-ctp-mantle">
<div className="grid min-h-0 flex-1 overflow-y-auto lg:grid-cols-[minmax(0,1fr)_minmax(0,34rem)] lg:overflow-hidden">
<div className="flex min-w-0 flex-col gap-5 p-5 lg:overflow-y-auto">
<div className="w-fit max-w-full self-start overflow-hidden rounded-md border border-ctp-surface0 bg-ctp-mantle">
{mediaUrl ? (
isVideo ? (
<video
src={mediaUrl}
controls
muted
className="block h-auto max-h-[55vh] w-auto max-w-full"
className="block h-auto max-h-[70vh] w-auto max-w-full"
/>
) : (
<img
src={mediaUrl}
alt={temp.original_filename}
className="block h-auto max-h-[55vh] w-auto max-w-full"
className="block h-auto max-h-[70vh] w-auto max-w-full"
/>
)
) : (
@@ -529,57 +585,32 @@ function MetadataForm({
)}
</div>
<div className="flex min-w-0 flex-col gap-3">
<div className="flex gap-1.5">
<button
type="button"
onClick={() => setTab("link")}
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition",
tab === "link"
? "bg-ctp-surface0 text-ctp-text"
: "text-ctp-subtext0 hover:bg-ctp-surface0/60 hover:text-ctp-text",
)}
>
Link to e621 post
</button>
<button
type="button"
onClick={() => setTab("custom")}
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition",
tab === "custom"
? "bg-ctp-surface0 text-ctp-text"
: "text-ctp-subtext0 hover:bg-ctp-surface0/60 hover:text-ctp-text",
)}
>
Custom metadata
</button>
</div>
{tab === "link" ? (
<div className="flex flex-col gap-3">
{visualMatches.length > 0 ? (
<div>
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Already in your library
</p>
<div className="mt-2 flex flex-col gap-2">
<div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-3 2xl:grid-cols-4">
{visualMatches.map((match) => (
<div
key={match.j_id}
className="flex items-center gap-3 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-2"
className="overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-surface0/30"
>
<img
<Link
to={`/detail/${match.j_id}`}
className="block"
title={match.filename}
>
<ThumbImage
src={apiUrl(
match.thumbnail_url ??
`/api/files/${match.j_id}/thumbnail/`,
)}
alt={match.j_id}
loading="lazy"
className="h-12 w-12 shrink-0 rounded object-cover"
className="aspect-square w-full"
/>
<span className="min-w-0 flex-1">
</Link>
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
<span className="min-w-0">
<Link
to={`/detail/${match.j_id}`}
className="font-mono text-[11px] text-ctp-blue hover:underline"
@@ -587,7 +618,7 @@ function MetadataForm({
{match.j_id}
</Link>
<span
className="block truncate font-mono text-[11px] text-ctp-subtext0"
className="block truncate font-mono text-[10px] text-ctp-overlay0"
title={match.filename}
>
{match.filename}
@@ -597,11 +628,12 @@ function MetadataForm({
{match.similarity}%
</span>
</div>
</div>
))}
</div>
<p className="mt-2 text-[11px] text-ctp-overlay0">
This upload looks like it is already in the library — you
can discard it below.
This upload looks like it is already in the library — you can
discard it below.
</p>
<button
type="button"
@@ -612,11 +644,13 @@ function MetadataForm({
</button>
</div>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-5 border-t border-ctp-surface0 p-5 lg:overflow-y-auto lg:border-l lg:border-t-0">
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
IQDB candidates
e621 IQDB candidates
</p>
{isVideo ? null : (
<button
@@ -645,7 +679,7 @@ function MetadataForm({
</p>
) : iqdbData && iqdbData.length > 0 ? (
<>
<div className="mt-2 grid grid-cols-3 gap-2">
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
{iqdbData.map((candidate) => (
<button
key={`${candidate.post_id}-${candidate.md5}`}
@@ -661,11 +695,9 @@ function MetadataForm({
>
<span className="relative block aspect-square overflow-hidden">
{candidate.preview_url ? (
<img
<ThumbImage
src={candidate.preview_url}
alt={`Post #${candidate.post_id}`}
loading="lazy"
className="h-full w-full object-cover"
className="h-full w-full"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] text-ctp-overlay0">
@@ -695,14 +727,13 @@ function MetadataForm({
</div>
{selected ? (
<div className="mt-2 rounded-md border border-ctp-surface0 bg-ctp-surface0/40 p-3">
<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)
? (RATING_LABELS[selected.rating] ?? selected.rating)
: "Unknown rating"}
{selected.score_total !== null &&
selected.score_total !== undefined
@@ -744,10 +775,6 @@ function MetadataForm({
View post
</Link>
</div>
<p className="mt-2 text-[11px] text-ctp-overlay0">
Linking downloads the post's file into the library
and drops this staged upload.
</p>
</div>
) : null}
</>
@@ -763,7 +790,11 @@ function MetadataForm({
)}
</div>
<div className="flex gap-2">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Link to an e621 post
</p>
<div className="mt-2 flex gap-2">
<input
className={cn(inputClass, "font-mono")}
placeholder="e621 post ID"
@@ -774,9 +805,17 @@ function MetadataForm({
Link
</Button>
</div>
<p className="mt-2 text-[11px] text-ctp-overlay0">
Linking downloads the post's file into the library and drops this
staged upload.
</p>
</div>
) : (
<div className="flex flex-col gap-3">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
Custom metadata
</p>
<div className="mt-2 flex flex-col gap-3">
<div className="flex flex-wrap gap-1.5">
{ratingChoices.map((choice) => (
<button
@@ -810,7 +849,7 @@ function MetadataForm({
Save custom metadata
</Button>
</div>
)}
</div>
{error ? <p className="text-xs text-ctp-red">{error}</p> : null}
{temp.pipeline_error ? (
@@ -825,7 +864,7 @@ function MetadataForm({
) : null}
</div>
</div>
</>
</div>
);
}
@@ -845,11 +884,11 @@ function MetadataModal({
return (
<div
className="fixed inset-0 z-[70] flex items-start justify-center overflow-y-auto bg-ctp-crust/70 px-4 py-10 backdrop-blur-sm"
className="fixed inset-0 z-[70] flex items-start justify-center overflow-y-auto bg-ctp-crust/70 px-3 py-6 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-2xl rounded-lg border border-ctp-surface1 bg-ctp-base p-5"
className="flex max-h-[92vh] w-full max-w-[min(1400px,96vw)] flex-col overflow-hidden rounded-xl border border-ctp-surface1 bg-ctp-base"
onClick={(event) => event.stopPropagation()}
>
{detailQuery.data ? (
@@ -859,11 +898,11 @@ function MetadataModal({
onClose={onClose}
/>
) : detailQuery.isError ? (
<p className="text-sm text-ctp-red">
<p className="p-5 text-sm text-ctp-red">
Could not load this upload's details.
</p>
) : (
<p className="flex items-center gap-2 text-sm text-ctp-overlay0">
<p className="flex items-center gap-2 p-5 text-sm text-ctp-overlay0">
<Spinner className="h-4 w-4" /> Loading…
</p>
)}