Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf1d75aca8 | ||
|
|
4889c5c355 | ||
|
|
d2f857d3ee | ||
|
|
9b431eb982 | ||
|
|
7ca84f4fea | ||
|
|
5f237aa2e3 | ||
|
|
90ba2ecff7 | ||
|
|
73bf4f9e38 | ||
|
|
7c2569522f | ||
|
|
a62195ffce | ||
|
|
c73a81a5f4 | ||
|
|
af378e7d71 | ||
|
|
59f397a96c | ||
|
|
9ababb8b48 | ||
|
|
37085c5dac | ||
|
|
ecac4cb8b4 |
+26
-11
@@ -7,10 +7,12 @@
|
|||||||
# * builds the desktop packages and attaches them (plus the update
|
# * builds the desktop packages and attaches them (plus the update
|
||||||
# metadata) to the Gitea release tagged `desktop-v<package.json version>`.
|
# metadata) to the Gitea release tagged `desktop-v<package.json version>`.
|
||||||
#
|
#
|
||||||
# The live update feed (deploy/data/desktop, served by the frontend nginx at
|
# The desktop build also attaches the update metadata (latest*.yml) to the
|
||||||
# /desktop/) is not touched here: it is runtime state on the deploy host and
|
# release; that is the desktop updater's feed, resolved through the Gitea API
|
||||||
# is still published with `deploy/push_desktop.sh --no-build` from a machine
|
# at check time (see desktop/README.md). The older website feed
|
||||||
# that can reach it.
|
# (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
|
# Registry login uses a repo PAT with the minimal write:package scope (the
|
||||||
# Gitea registry rejects the automatic job token, go-gitea/gitea#23642);
|
# Gitea registry rejects the automatic job token, go-gitea/gitea#23642);
|
||||||
@@ -162,8 +164,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
EXISTING="$(curl -sf -H "$AUTH" "$API/releases/$RELEASE_ID/assets" \
|
EXISTING="$(curl -sf -H "$AUTH" "$API/releases/$RELEASE_ID/assets" \
|
||||||
| python3 -c 'import json,sys; print("\n".join(a["name"] for a in json.load(sys.stdin)))' \
|
|| echo '[]')"
|
||||||
|| true)"
|
|
||||||
for FILE in desktop/release/*"$VERSION"*.deb \
|
for FILE in desktop/release/*"$VERSION"*.deb \
|
||||||
desktop/release/*"$VERSION"*.pkg.tar.zst \
|
desktop/release/*"$VERSION"*.pkg.tar.zst \
|
||||||
desktop/release/latest-linux.yml \
|
desktop/release/latest-linux.yml \
|
||||||
@@ -172,11 +173,25 @@ jobs:
|
|||||||
desktop/release/*"$VERSION"*.exe.blockmap; do
|
desktop/release/*"$VERSION"*.exe.blockmap; do
|
||||||
[ -e "$FILE" ] || continue
|
[ -e "$FILE" ] || continue
|
||||||
NAME="$(basename "$FILE")"
|
NAME="$(basename "$FILE")"
|
||||||
case "$EXISTING" in
|
# Replace the asset when it is already there: latest*.yml must
|
||||||
*"$NAME"*) echo " already attached: $NAME"; continue ;;
|
# reference the installers built by *this* run (NSIS builds are
|
||||||
esac
|
# not bit-reproducible), so old copies are deleted first.
|
||||||
echo " attaching $NAME"
|
ASSET_ID="$(printf '%s' "$EXISTING" | python3 -c '
|
||||||
|
import json, sys
|
||||||
|
name = sys.argv[1]
|
||||||
|
print(next((str(a["id"]) for a in json.load(sys.stdin) if a["name"] == name), ""))
|
||||||
|
' "$NAME")"
|
||||||
|
if [ -n "$ASSET_ID" ]; then
|
||||||
|
echo " replacing $NAME"
|
||||||
|
curl -sf -X DELETE -H "$AUTH" \
|
||||||
|
"$API/releases/$RELEASE_ID/assets/$ASSET_ID" >/dev/null
|
||||||
|
else
|
||||||
|
echo " attaching $NAME"
|
||||||
|
fi
|
||||||
|
# Names like "J621 Setup 0.1.1.exe" contain spaces: encode them
|
||||||
|
# or curl refuses the URL (exit 3).
|
||||||
|
ENCODED="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$NAME")"
|
||||||
curl -sf -X POST -H "$AUTH" -H "Content-Type: application/octet-stream" \
|
curl -sf -X POST -H "$AUTH" -H "Content-Type: application/octet-stream" \
|
||||||
--data-binary @"$FILE" "$API/releases/$RELEASE_ID/assets?name=$NAME" >/dev/null
|
--data-binary @"$FILE" "$API/releases/$RELEASE_ID/assets?name=$ENCODED" >/dev/null
|
||||||
done
|
done
|
||||||
echo "Release: ${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG"
|
echo "Release: ${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG"
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ Project constraints (do not regress):
|
|||||||
`desktop-v<version>`). Jobs run on the user-scoped runners: `ubuntu-latest`
|
`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
|
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
|
(`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
|
on restore/save. Desktop updates read the release assets (latest*.yml) from
|
||||||
published with `deploy/push_desktop.sh --no-build` from a machine with SSH
|
the Gitea API at check time; the older website feed (deploy/data/desktop)
|
||||||
to the deploy host — CI has no key for that.
|
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
|
- Security/permission tests live in backend/apps/core/tests and need a
|
||||||
one-time grant: GRANT ALL ON `test_j621`.* TO 'j621'@'%';
|
one-time grant: GRANT ALL ON `test_j621`.* TO 'j621'@'%';
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Self-hosted media library and e621 archive manager, rebuilt as a **React SPA + D
|
|||||||
backend/ Django 6 + DRF API (MariaDB + Redis via docker compose)
|
backend/ Django 6 + DRF API (MariaDB + Redis via docker compose)
|
||||||
frontend/ Vite + React + TypeScript SPA
|
frontend/ Vite + React + TypeScript SPA
|
||||||
deploy/ Docker images, compose variants and Tailscale serve configs
|
deploy/ Docker images, compose variants and Tailscale serve configs
|
||||||
extras/ shell integrations (fish_greeting with fastfetch)
|
extras/ shell integrations (fish + PowerShell greetings with fastfetch, gm-switch)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -75,8 +75,11 @@ curl -H "Authorization: Token <token>" \
|
|||||||
the bundled nginx those aliases negotiate on `Accept`: browsers get the SPA
|
the bundled nginx those aliases negotiate on `Accept`: browsers get the SPA
|
||||||
page, requesters like curl/wget/fastfetch get the JSON. `/api/random/` is
|
page, requesters like curl/wget/fastfetch get the JSON. `/api/random/` is
|
||||||
the unambiguous path for scripts; 404 when nothing matches the filters.
|
the unambiguous path for scripts; 404 when nothing matches the filters.
|
||||||
- A ready-made shell greeting that uses this endpoint lives in
|
- Ready-made shell greetings that use this endpoint live in
|
||||||
[`extras/fish_greeting/`](extras/fish_greeting/README.md).
|
[`extras/fish_greeting/`](extras/fish_greeting/README.md) (Linux/fish) and
|
||||||
|
[`extras/powershell_greeting/`](extras/powershell_greeting/README.md)
|
||||||
|
(Windows/PowerShell); [`extras/win-gm-switch/`](extras/win-gm-switch/)
|
||||||
|
(Rust) switches the rating mode on both.
|
||||||
- **Scoped tokens for scripts**: the Account page's *Shell tokens* section
|
- **Scoped tokens for scripts**: the Account page's *Shell tokens* section
|
||||||
(also `/tokens`) issues `j621r_…` tokens that only authenticate
|
(also `/tokens`) issues `j621r_…` tokens that only authenticate
|
||||||
`/api/random/` — the rest of the API rejects them. They are stored as
|
`/api/random/` — the rest of the API rejects them. They are stored as
|
||||||
|
|||||||
+14
-3
@@ -20,6 +20,13 @@ they land.
|
|||||||
- [x] Tag cloud in the sidebar (click to search, hidden from guests for
|
- [x] Tag cloud in the sidebar (click to search, hidden from guests for
|
||||||
blacklisted items)
|
blacklisted items)
|
||||||
- [x] Status filter (matched / not_found / deleted / custom / unknown)
|
- [x] Status filter (matched / not_found / deleted / custom / unknown)
|
||||||
|
- [x] **Cacheable media serving**
|
||||||
|
- [x] Stable signed URLs (7 d TTL, 24 h rotation) plus a `v=<md5>` cache
|
||||||
|
buster, so replacing a file under the same J-ID invalidates it
|
||||||
|
- [x] ETag/Last-Modified and a private `Cache-Control` (immutable for
|
||||||
|
versioned media), conditional 304s
|
||||||
|
- [x] Real 480 px image thumbnails (cached by MD5) instead of serving
|
||||||
|
full-size originals through the thumbnail endpoint
|
||||||
|
|
||||||
- [x] **Random image** endpoint and page: `GET /api/random/` (aliases
|
- [x] **Random image** endpoint and page: `GET /api/random/` (aliases
|
||||||
`/random`, `/random/`) with `rating=s,q,e` filters; fastfetch mode
|
`/random`, `/random/`) with `rating=s,q,e` filters; fastfetch mode
|
||||||
@@ -104,12 +111,16 @@ Files now stage first and are resolved before entering the library.
|
|||||||
shell indicator; rate-limited runs retry with backoff
|
shell indicator; rate-limited runs retry with backoff
|
||||||
- [x] Perceptual-hash comparison against the library, loaded once per batch
|
- [x] Perceptual-hash comparison against the library, loaded once per batch
|
||||||
- [x] IQDB candidates stored with one batched enrichment request
|
- [x] IQDB candidates stored with one batched enrichment request
|
||||||
|
- [x] Indexed uploads are announced through a bounded per-run feed and the
|
||||||
|
staged row is deleted; nothing persists on the board to dismiss
|
||||||
- [x] **Upload UI**
|
- [x] **Upload UI**
|
||||||
- [x] Three-column board: Pending & Unmatched / Visual Similarity Detected /
|
- [x] Four-column board: Pending & Unmatched / Visual Similarity Detected /
|
||||||
Auto-uploaded & Indexed, private per user (staff included)
|
Auto-uploaded & Indexed (session feed) / Failed, private per user
|
||||||
|
(staff included)
|
||||||
- [x] Metadata modal (link to e621 post, IQDB candidates, custom metadata)
|
- [x] Metadata modal (link to e621 post, IQDB candidates, custom metadata)
|
||||||
- [x] Per-file progress plus background pipeline status
|
- [x] Per-file progress plus background pipeline status
|
||||||
- [x] Bulk actions: bulk rate, discard all (pending/visual), dismiss all
|
- [x] Bulk actions: bulk rate and discard all (chunked past the API's
|
||||||
|
1000-id cap)
|
||||||
|
|
||||||
## 4. Staff tools
|
## 4. Staff tools
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from apps.library.models import (
|
|||||||
TempUpload,
|
TempUpload,
|
||||||
)
|
)
|
||||||
from apps.library.services import MEDIA_FILE_SALT
|
from apps.library.services import MEDIA_FILE_SALT
|
||||||
|
from apps.library.signing_urls import sign_payload
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -122,21 +123,15 @@ class SecurityTestCase(TestCase):
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
def old_signature(self, item, action="raw", age=3 * 86400):
|
def old_signature(self, item, action="raw", age=3 * 86400):
|
||||||
"""A valid signature minted `age` seconds ago."""
|
"""A signed media URL whose expiry is `age` seconds in the past."""
|
||||||
real_time = signing.time
|
return signing.Signer(salt=MEDIA_FILE_SALT).sign_object(
|
||||||
|
{
|
||||||
class Backdated:
|
"item": item.id,
|
||||||
def time(self):
|
"user": self.users["sec-uploader"].id,
|
||||||
return real_time.time() - age
|
"action": action,
|
||||||
|
"exp": int(time.time()) - age,
|
||||||
try:
|
}
|
||||||
signing.time = Backdated()
|
)
|
||||||
return signing.dumps(
|
|
||||||
{"item": item.id, "user": self.users["sec-uploader"].id, "action": action},
|
|
||||||
salt=MEDIA_FILE_SALT,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
signing.time = real_time
|
|
||||||
|
|
||||||
|
|
||||||
class GuestVisibilityTests(SecurityTestCase):
|
class GuestVisibilityTests(SecurityTestCase):
|
||||||
@@ -183,9 +178,9 @@ class GuestVisibilityTests(SecurityTestCase):
|
|||||||
def test_authenticated_users_and_signed_urls_see_protected_items(self):
|
def test_authenticated_users_and_signed_urls_see_protected_items(self):
|
||||||
uploader = self.client_for("sec-uploader")
|
uploader = self.client_for("sec-uploader")
|
||||||
self.assertEqual(uploader.get(f"/api/files/J-{self.hidden.id}/").status_code, 200)
|
self.assertEqual(uploader.get(f"/api/files/J-{self.hidden.id}/").status_code, 200)
|
||||||
signed = signing.dumps(
|
signed = sign_payload(
|
||||||
{"item": self.hidden.id, "user": self.users["sec-uploader"].id, "action": "raw"},
|
{"item": self.hidden.id, "user": self.users["sec-uploader"].id, "action": "raw"},
|
||||||
salt=MEDIA_FILE_SALT,
|
MEDIA_FILE_SALT,
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.guest.get(f"/api/files/J-{self.hidden.id}/raw/?sig={signed}").status_code,
|
self.guest.get(f"/api/files/J-{self.hidden.id}/raw/?sig={signed}").status_code,
|
||||||
@@ -193,9 +188,9 @@ class GuestVisibilityTests(SecurityTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_signature_integrity(self):
|
def test_signature_integrity(self):
|
||||||
signed = signing.dumps(
|
signed = sign_payload(
|
||||||
{"item": self.hidden.id, "user": self.users["sec-uploader"].id, "action": "raw"},
|
{"item": self.hidden.id, "user": self.users["sec-uploader"].id, "action": "raw"},
|
||||||
salt=MEDIA_FILE_SALT,
|
MEDIA_FILE_SALT,
|
||||||
)
|
)
|
||||||
raw = f"/api/files/J-{self.hidden.id}/raw/"
|
raw = f"/api/files/J-{self.hidden.id}/raw/"
|
||||||
thumbnail = f"/api/files/J-{self.hidden.id}/thumbnail/"
|
thumbnail = f"/api/files/J-{self.hidden.id}/thumbnail/"
|
||||||
@@ -203,10 +198,29 @@ class GuestVisibilityTests(SecurityTestCase):
|
|||||||
self.assertEqual(self.guest.get(f"{raw}?sig={signed[:-4]}AAAA").status_code, 404)
|
self.assertEqual(self.guest.get(f"{raw}?sig={signed[:-4]}AAAA").status_code, 404)
|
||||||
# Valid signature, wrong action.
|
# Valid signature, wrong action.
|
||||||
self.assertEqual(self.guest.get(f"{thumbnail}?sig={signed}").status_code, 404)
|
self.assertEqual(self.guest.get(f"{thumbnail}?sig={signed}").status_code, 404)
|
||||||
# Expired signature (minted three days ago).
|
# Expired signature (expiry three days ago).
|
||||||
expired = self.old_signature(self.hidden)
|
expired = self.old_signature(self.hidden)
|
||||||
self.assertEqual(self.guest.get(f"{raw}?sig={expired}").status_code, 404)
|
self.assertEqual(self.guest.get(f"{raw}?sig={expired}").status_code, 404)
|
||||||
|
|
||||||
|
def test_signed_media_urls_are_stable_and_versioned(self):
|
||||||
|
"""The same item must keep the same URL across responses.
|
||||||
|
|
||||||
|
A per-second signature made browsers re-download every image on every
|
||||||
|
poll; the MD5 version parameter busts caches only when the file itself
|
||||||
|
changes (the optimize flow rewrites files under the same J-ID).
|
||||||
|
"""
|
||||||
|
item = self.visible
|
||||||
|
first = services.signed_media_url(item, self.users["sec-uploader"])
|
||||||
|
time.sleep(1.1)
|
||||||
|
second = services.signed_media_url(item, self.users["sec-uploader"])
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertIn(f"v={item.md5}", first)
|
||||||
|
MediaItem.objects.filter(pk=item.pk).update(md5="b" * 32)
|
||||||
|
item.refresh_from_db()
|
||||||
|
self.assertNotEqual(
|
||||||
|
services.signed_media_url(item, self.users["sec-uploader"]), first
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RoleBoundaryTests(SecurityTestCase):
|
class RoleBoundaryTests(SecurityTestCase):
|
||||||
def test_non_uploader_is_read_only(self):
|
def test_non_uploader_is_read_only(self):
|
||||||
@@ -451,9 +465,9 @@ class ThrottleTests(SecurityTestCase):
|
|||||||
md5=hashlib.md5(b"throttle-temp").hexdigest(),
|
md5=hashlib.md5(b"throttle-temp").hexdigest(),
|
||||||
size=6,
|
size=6,
|
||||||
)
|
)
|
||||||
signature = signing.dumps(
|
signature = sign_payload(
|
||||||
{"temp": str(temp.id), "user": self.users["sec-uploader"].id},
|
{"temp": str(temp.id), "user": self.users["sec-uploader"].id},
|
||||||
salt=services.UPLOAD_FILE_SALT,
|
services.UPLOAD_FILE_SALT,
|
||||||
)
|
)
|
||||||
url = f"/api/uploads/{temp.id}/file/?sig={signature}"
|
url = f"/api/uploads/{temp.id}/file/?sig={signature}"
|
||||||
codes = {self.guest.get(url).status_code for _ in range(150)}
|
codes = {self.guest.get(url).status_code for _ in range(150)}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.1.1 on 2026-09-23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def purge_completed_uploads(apps, schema_editor):
|
||||||
|
"""Completed staged uploads are notifications, not records.
|
||||||
|
|
||||||
|
The board used to keep every auto-uploaded/indexed row until it was
|
||||||
|
dismissed by hand, so they accumulated without bound (and bulk dismissal
|
||||||
|
is capped at 1000 ids). Completions now live in a small per-run feed, so
|
||||||
|
the old rows are removed here.
|
||||||
|
"""
|
||||||
|
TempUpload = apps.get_model("library", "TempUpload")
|
||||||
|
for temp in TempUpload.objects.filter(status="completed").iterator():
|
||||||
|
if temp.file:
|
||||||
|
temp.file.delete(save=False)
|
||||||
|
temp.delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("library", "0011_upload_pipeline"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="uploadrun",
|
||||||
|
name="recent_completions",
|
||||||
|
field=models.JSONField(blank=True, default=list),
|
||||||
|
),
|
||||||
|
migrations.RunPython(purge_completed_uploads, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -229,6 +229,11 @@ class UploadRun(models.Model):
|
|||||||
matched = models.IntegerField(default=0)
|
matched = models.IntegerField(default=0)
|
||||||
failed = models.IntegerField(default=0)
|
failed = models.IntegerField(default=0)
|
||||||
error = models.TextField(blank=True, default="")
|
error = models.TextField(blank=True, default="")
|
||||||
|
# Rolling feed of recent completions for the upload board. Completed
|
||||||
|
# staged uploads are deleted as soon as they are indexed; this only tells
|
||||||
|
# the live page "filename -> J-x" while it watches. Bounded, never
|
||||||
|
# dismissed, and ignored by fresh page loads.
|
||||||
|
recent_completions = models.JSONField(default=list, blank=True)
|
||||||
started_at = models.DateTimeField(null=True, blank=True)
|
started_at = models.DateTimeField(null=True, blank=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from datetime import timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
@@ -20,6 +19,7 @@ from .services import (
|
|||||||
VIDEO_EXTENSIONS,
|
VIDEO_EXTENSIONS,
|
||||||
signed_media_url,
|
signed_media_url,
|
||||||
)
|
)
|
||||||
|
from .signing_urls import sign_payload
|
||||||
|
|
||||||
|
|
||||||
class MediaLocationSerializer(serializers.ModelSerializer):
|
class MediaLocationSerializer(serializers.ModelSerializer):
|
||||||
@@ -150,6 +150,7 @@ class TempUploadSerializer(serializers.ModelSerializer):
|
|||||||
iqdb_checked = serializers.SerializerMethodField()
|
iqdb_checked = serializers.SerializerMethodField()
|
||||||
processing = serializers.SerializerMethodField()
|
processing = serializers.SerializerMethodField()
|
||||||
similar_count = serializers.SerializerMethodField()
|
similar_count = serializers.SerializerMethodField()
|
||||||
|
visual_matches = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TempUpload
|
model = TempUpload
|
||||||
@@ -199,9 +200,9 @@ class TempUploadSerializer(serializers.ModelSerializer):
|
|||||||
user = self._request_user()
|
user = self._request_user()
|
||||||
if user is None:
|
if user is None:
|
||||||
return None
|
return None
|
||||||
signature = signing.dumps(
|
signature = sign_payload(
|
||||||
{"temp": str(obj.id), "user": user.id},
|
{"temp": str(obj.id), "user": user.id},
|
||||||
salt=UPLOAD_FILE_SALT,
|
UPLOAD_FILE_SALT,
|
||||||
)
|
)
|
||||||
url = f"/api/uploads/{obj.id}/file/?sig={signature}"
|
url = f"/api/uploads/{obj.id}/file/?sig={signature}"
|
||||||
request = self.context.get("request")
|
request = self.context.get("request")
|
||||||
@@ -242,6 +243,57 @@ class TempUploadSerializer(serializers.ModelSerializer):
|
|||||||
def get_similar_count(self, obj):
|
def get_similar_count(self, obj):
|
||||||
return len(obj.iqdb_data or []) + len(obj.visual_matches or [])
|
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):
|
class TempUploadListSerializer(TempUploadSerializer):
|
||||||
"""Compact staged-upload row for the board and the status polling.
|
"""Compact staged-upload row for the board and the status polling.
|
||||||
@@ -339,9 +391,9 @@ class SimilarityCheckSerializer(serializers.ModelSerializer):
|
|||||||
user = self._request_user()
|
user = self._request_user()
|
||||||
if user is None or not obj.file:
|
if user is None or not obj.file:
|
||||||
return None
|
return None
|
||||||
signature = signing.dumps(
|
signature = sign_payload(
|
||||||
{"check": str(obj.id), "user": user.id},
|
{"check": str(obj.id), "user": user.id},
|
||||||
salt=UPLOAD_FILE_SALT,
|
UPLOAD_FILE_SALT,
|
||||||
)
|
)
|
||||||
url = f"/api/similarity/{obj.id}/file/?sig={signature}"
|
url = f"/api/similarity/{obj.id}/file/?sig={signature}"
|
||||||
request = self.context.get("request")
|
request = self.context.get("request")
|
||||||
|
|||||||
@@ -6,16 +6,21 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import imagehash
|
import imagehash
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
|
||||||
from django.http import FileResponse, Http404, HttpResponse
|
from django.http import FileResponse, Http404, HttpResponse
|
||||||
|
from django.utils.cache import get_conditional_response
|
||||||
|
from django.utils.http import http_date
|
||||||
from django.utils.text import get_valid_filename
|
from django.utils.text import get_valid_filename
|
||||||
from PIL import Image
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
from .models import MediaItem, MediaLocation
|
from .models import MediaItem, MediaLocation
|
||||||
|
from .signing_urls import sign_payload
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -37,6 +42,11 @@ UPLOAD_FILE_SALT = "j621.upload-file"
|
|||||||
MEDIA_FILE_SALT = "j621.media-file"
|
MEDIA_FILE_SALT = "j621.media-file"
|
||||||
CHUNK_SIZE = 1024 * 1024
|
CHUNK_SIZE = 1024 * 1024
|
||||||
RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
|
RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
|
||||||
|
# Versioned media URLs are immutable, so they may sit in the browser cache for
|
||||||
|
# as long as the signature is guaranteed to stay valid (7 days).
|
||||||
|
MEDIA_CACHE_SECONDS = 6 * 86400
|
||||||
|
# Staged uploads and similarity files can be deleted at any moment.
|
||||||
|
TEMP_CACHE_SECONDS = 3600
|
||||||
|
|
||||||
|
|
||||||
def compute_md5(path):
|
def compute_md5(path):
|
||||||
@@ -78,14 +88,24 @@ def signed_media_url(item, user, action="raw", request=None):
|
|||||||
|
|
||||||
With a ``request`` the URL is absolute, so the SPA also works when it is
|
With a ``request`` the URL is absolute, so the SPA also works when it is
|
||||||
served from a different origin; without one it stays relative.
|
served from a different origin; without one it stays relative.
|
||||||
|
|
||||||
|
The ``v`` parameter is the item's MD5: it busts the browser cache exactly
|
||||||
|
when the file is replaced (the optimize flow rewrites files under the same
|
||||||
|
J-ID), which is what lets the URL be cached for days instead of re-minted
|
||||||
|
on every response.
|
||||||
"""
|
"""
|
||||||
path = f"/api/files/J-{item.id}/{action}/"
|
path = f"/api/files/J-{item.id}/{action}/"
|
||||||
|
params = {}
|
||||||
if user is not None and getattr(user, "is_authenticated", False):
|
if user is not None and getattr(user, "is_authenticated", False):
|
||||||
signature = signing.dumps(
|
params = {
|
||||||
{"item": item.id, "user": user.id, "action": action},
|
"v": item.md5,
|
||||||
salt=MEDIA_FILE_SALT,
|
"sig": sign_payload(
|
||||||
)
|
{"item": item.id, "user": user.id, "action": action},
|
||||||
path = f"{path}?sig={signature}"
|
MEDIA_FILE_SALT,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if params:
|
||||||
|
path = f"{path}?{urlencode(params)}"
|
||||||
if request is None:
|
if request is None:
|
||||||
return path
|
return path
|
||||||
return request.build_absolute_uri(path)
|
return request.build_absolute_uri(path)
|
||||||
@@ -207,12 +227,36 @@ class RangeFileWrapper:
|
|||||||
self.file.close()
|
self.file.close()
|
||||||
|
|
||||||
|
|
||||||
def serve_file(request, path, download=False):
|
def _apply_cache_headers(response, cache_control, etag, mtime):
|
||||||
"""Serve a file with HTTP range support (needed for video seeking)."""
|
response["Cache-Control"] = cache_control
|
||||||
|
response["ETag"] = etag
|
||||||
|
response["Last-Modified"] = http_date(mtime)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def serve_file(request, path, download=False, *, max_age=TEMP_CACHE_SECONDS, immutable=False):
|
||||||
|
"""Serve a file with HTTP range support (needed for video seeking).
|
||||||
|
|
||||||
|
Responses carry validators (ETag/Last-Modified) and a private
|
||||||
|
``Cache-Control`` so browsers reuse media instead of re-downloading it on
|
||||||
|
every SPA poll. ``max_age``/``immutable`` are chosen by the caller: versioned
|
||||||
|
library media can be cached hard, staged files only briefly.
|
||||||
|
"""
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise Http404
|
raise Http404
|
||||||
size = path.stat().st_size
|
stat = path.stat()
|
||||||
|
size = stat.st_size
|
||||||
|
etag = f'W/"{size:x}-{stat.st_mtime_ns:x}"'
|
||||||
|
last_modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
|
||||||
|
conditional = get_conditional_response(
|
||||||
|
request, etag=etag, last_modified=last_modified
|
||||||
|
)
|
||||||
|
if conditional is not None:
|
||||||
|
return conditional
|
||||||
|
cache_control = f"private, max-age={int(max_age)}"
|
||||||
|
if immutable:
|
||||||
|
cache_control += ", immutable"
|
||||||
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
||||||
range_header = request.headers.get("Range", "").strip()
|
range_header = request.headers.get("Range", "").strip()
|
||||||
if range_header:
|
if range_header:
|
||||||
@@ -240,7 +284,9 @@ def serve_file(request, path, download=False):
|
|||||||
response["Content-Length"] = str(length)
|
response["Content-Length"] = str(length)
|
||||||
response["Content-Range"] = f"bytes {start}-{end}/{size}"
|
response["Content-Range"] = f"bytes {start}-{end}/{size}"
|
||||||
response["Accept-Ranges"] = "bytes"
|
response["Accept-Ranges"] = "bytes"
|
||||||
return response
|
return _apply_cache_headers(
|
||||||
|
response, cache_control, etag, stat.st_mtime
|
||||||
|
)
|
||||||
response = FileResponse(
|
response = FileResponse(
|
||||||
open(path, "rb"),
|
open(path, "rb"),
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
@@ -248,7 +294,7 @@ def serve_file(request, path, download=False):
|
|||||||
filename=path.name,
|
filename=path.name,
|
||||||
)
|
)
|
||||||
response["Accept-Ranges"] = "bytes"
|
response["Accept-Ranges"] = "bytes"
|
||||||
return response
|
return _apply_cache_headers(response, cache_control, etag, stat.st_mtime)
|
||||||
|
|
||||||
|
|
||||||
class DownloadCancelled(Exception):
|
class DownloadCancelled(Exception):
|
||||||
@@ -436,15 +482,38 @@ def sanitize_iqdb_results(results):
|
|||||||
return cleaned
|
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):
|
def generate_video_thumbnail(md5, path):
|
||||||
"""Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs."""
|
"""Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs."""
|
||||||
if not shutil.which("ffmpeg"):
|
if not shutil.which("ffmpeg"):
|
||||||
return None
|
return None
|
||||||
thumbs_dir = Path(settings.MEDIA_ROOT) / "thumbs"
|
try:
|
||||||
thumbs_dir.mkdir(parents=True, exist_ok=True)
|
thumbs_dir = _thumbs_dir()
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Could not create the thumbnail folder")
|
||||||
|
return None
|
||||||
target = thumbs_dir / f"{md5}.jpg"
|
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
|
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 = [
|
command = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-y",
|
"-y",
|
||||||
@@ -458,10 +527,63 @@ def generate_video_thumbnail(md5, path):
|
|||||||
"scale=480:-2",
|
"scale=480:-2",
|
||||||
"-loglevel",
|
"-loglevel",
|
||||||
"error",
|
"error",
|
||||||
str(target),
|
str(temp),
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
subprocess.run(command, check=True, capture_output=True, timeout=60)
|
subprocess.run(command, check=True, capture_output=True, timeout=60)
|
||||||
|
os.replace(temp, target)
|
||||||
except (subprocess.SubprocessError, OSError):
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
logger.exception("Could not build a video thumbnail for %s", path)
|
||||||
|
temp.unlink(missing_ok=True)
|
||||||
return None
|
return None
|
||||||
return target if target.exists() else None
|
return target if target.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_image_thumbnail(md5, path):
|
||||||
|
"""Downscale an image, cached under MEDIA_ROOT/thumbs like video thumbs.
|
||||||
|
|
||||||
|
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 the source is missing or Pillow
|
||||||
|
cannot decode it, so callers can fall back to the original.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
thumbs_dir = _thumbs_dir()
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Could not create the thumbnail folder")
|
||||||
|
return None
|
||||||
|
target = thumbs_dir / f"{md5}.jpg"
|
||||||
|
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.
|
||||||
|
image.seek(0)
|
||||||
|
frame = ImageOps.exif_transpose(image) or image
|
||||||
|
frame = frame.convert("RGB")
|
||||||
|
frame.thumbnail((480, 480))
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Stable, expiring signatures for media URLs.
|
||||||
|
|
||||||
|
The SPA loads media with ``<img>``/``<video>`` tags, which cannot send the
|
||||||
|
API's ``Authorization`` header, so those URLs carry a signature instead. The
|
||||||
|
signature has to be *stable*: a URL that changes on every response makes the
|
||||||
|
browser treat every refetch as a new resource and re-download the file.
|
||||||
|
|
||||||
|
URLs are signed with a plain ``Signer`` (no per-second timestamp) plus an
|
||||||
|
explicit ``exp`` claim quantized to a bucket, so every request inside a bucket
|
||||||
|
mints the exact same URL. The URL rotates once per bucket and is valid for at
|
||||||
|
least ``URL_TTL_SECONDS`` and at most ``URL_TTL_SECONDS + URL_BUCKET_SECONDS``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.core import signing
|
||||||
|
|
||||||
|
URL_TTL_SECONDS = 7 * 86400
|
||||||
|
URL_BUCKET_SECONDS = 24 * 3600
|
||||||
|
_BUCKETS = URL_TTL_SECONDS // URL_BUCKET_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def _expiry(now=None):
|
||||||
|
current = time.time() if now is None else now
|
||||||
|
bucket = int(current // URL_BUCKET_SECONDS)
|
||||||
|
return (bucket + _BUCKETS + 1) * URL_BUCKET_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def sign_payload(payload, salt, now=None):
|
||||||
|
"""Sign a payload with a stable, bucket-quantized expiry."""
|
||||||
|
return signing.Signer(salt=salt).sign_object(
|
||||||
|
{**payload, "exp": _expiry(now)}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_payload(signature, salt, legacy_max_age=86400):
|
||||||
|
"""Verify a signed payload; ``None`` when missing, tampered with or expired.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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, 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
|
||||||
|
try:
|
||||||
|
expired = int(data.get("exp", 0)) < time.time()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return None if expired else data
|
||||||
@@ -11,7 +11,6 @@ from datetime import timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import mixins, status, viewsets
|
from rest_framework import mixins, status, viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
@@ -22,6 +21,7 @@ from rest_framework.response import Response
|
|||||||
from . import services
|
from . import services
|
||||||
from .models import MediaItem, SimilarityCheck
|
from .models import MediaItem, SimilarityCheck
|
||||||
from .serializers import SimilarityCheckSerializer
|
from .serializers import SimilarityCheckSerializer
|
||||||
|
from .signing_urls import load_payload
|
||||||
from .tools import item_brief
|
from .tools import item_brief
|
||||||
from .uploads import find_library_matches
|
from .uploads import find_library_matches
|
||||||
|
|
||||||
@@ -149,14 +149,7 @@ class SimilarityCheckViewSet(
|
|||||||
check = self.get_queryset().filter(pk=pk).first()
|
check = self.get_queryset().filter(pk=pk).first()
|
||||||
else:
|
else:
|
||||||
signature = request.query_params.get("sig")
|
signature = request.query_params.get("sig")
|
||||||
payload = None
|
payload = load_payload(signature, services.UPLOAD_FILE_SALT) if signature else 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):
|
if payload and payload.get("check") == str(pk):
|
||||||
check = SimilarityCheck.objects.filter(pk=pk).first()
|
check = SimilarityCheck.objects.filter(pk=pk).first()
|
||||||
if check is None or not check.file:
|
if check is None or not check.file:
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Signed media URLs must be stable, versioned and cacheable.
|
||||||
|
|
||||||
|
Regression: signatures embedded the current second, so every API response
|
||||||
|
re-minted every URL and browsers re-downloaded each image on every poll; the
|
||||||
|
file responses also carried no cache headers at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
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
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
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 load_payload, sign_payload
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
TINY_PNG = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def png_bytes(width=1200, height=800, color=(20, 120, 200)):
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", (width, height), color).save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class MediaCacheTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls._tmp = tempfile.mkdtemp(prefix="j621-cache-")
|
||||||
|
cls._media = Path(cls._tmp) / "media"
|
||||||
|
cls._watched = cls._media / "library"
|
||||||
|
cls._watched.mkdir(parents=True, exist_ok=True)
|
||||||
|
cls._settings = override_settings(
|
||||||
|
MEDIA_ROOT=str(cls._media), WATCHED_FOLDER=str(cls._watched)
|
||||||
|
)
|
||||||
|
cls._settings.enable()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls._settings.disable()
|
||||||
|
shutil.rmtree(cls._tmp, ignore_errors=True)
|
||||||
|
super().tearDownClass()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="cache-uploader", password="cache-pass-123456"
|
||||||
|
)
|
||||||
|
self.user.role = "uploader"
|
||||||
|
self.user.save(update_fields=["role"])
|
||||||
|
self.token = Token.objects.create(user=self.user).key
|
||||||
|
payload = png_bytes()
|
||||||
|
path = self._watched / "cache-image.png"
|
||||||
|
path.write_bytes(payload)
|
||||||
|
self.item = MediaItem.objects.create(
|
||||||
|
md5=hashlib.md5(payload).hexdigest(), size=len(payload)
|
||||||
|
)
|
||||||
|
MediaLocation.objects.create(
|
||||||
|
item=self.item, path=str(path), rel_path=path.name, mtime=time.time()
|
||||||
|
)
|
||||||
|
self.client = Client()
|
||||||
|
|
||||||
|
def signed(self, action):
|
||||||
|
return {
|
||||||
|
"sig": sign_payload(
|
||||||
|
{"item": self.item.id, "user": self.user.id, "action": action},
|
||||||
|
services.MEDIA_FILE_SALT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_media_response_carries_cache_headers(self):
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/files/J-{self.item.id}/raw/", self.signed("raw")
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("private", response["Cache-Control"])
|
||||||
|
self.assertIn(
|
||||||
|
f"max-age={services.MEDIA_CACHE_SECONDS}", response["Cache-Control"]
|
||||||
|
)
|
||||||
|
self.assertIn("immutable", response["Cache-Control"])
|
||||||
|
self.assertTrue(response["ETag"])
|
||||||
|
self.assertTrue(response["Last-Modified"])
|
||||||
|
|
||||||
|
def test_media_revalidation_returns_304(self):
|
||||||
|
url = f"/api/files/J-{self.item.id}/raw/"
|
||||||
|
first = self.client.get(url, self.signed("raw"))
|
||||||
|
second = self.client.get(
|
||||||
|
url, self.signed("raw"), HTTP_IF_NONE_MATCH=first["ETag"]
|
||||||
|
)
|
||||||
|
self.assertEqual(second.status_code, 304)
|
||||||
|
self.assertEqual(second.content, b"")
|
||||||
|
|
||||||
|
def test_image_thumbnail_is_generated_and_reused(self):
|
||||||
|
url = f"/api/files/J-{self.item.id}/thumbnail/"
|
||||||
|
response = self.client.get(url, self.signed("thumbnail"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "image/jpeg")
|
||||||
|
thumb = self._media / "thumbs" / f"{self.item.md5}.jpg"
|
||||||
|
self.assertTrue(thumb.exists())
|
||||||
|
with Image.open(thumb) as image:
|
||||||
|
self.assertLessEqual(max(image.size), 480)
|
||||||
|
before = thumb.stat().st_mtime_ns
|
||||||
|
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,
|
||||||
|
file=SimpleUploadedFile("staged.png", TINY_PNG, content_type="image/png"),
|
||||||
|
original_filename="staged.png",
|
||||||
|
md5=hashlib.md5(b"staged").hexdigest(),
|
||||||
|
size=len(TINY_PNG),
|
||||||
|
)
|
||||||
|
signature = sign_payload(
|
||||||
|
{"temp": str(temp.id), "user": self.user.id}, services.UPLOAD_FILE_SALT
|
||||||
|
)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/uploads/{temp.id}/file/", {"sig": signature}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(
|
||||||
|
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))
|
||||||
@@ -170,12 +170,18 @@ class StagedUploadWorkflowTests(TestCase):
|
|||||||
self.assertEqual(len(body["resolved"]), 2)
|
self.assertEqual(len(body["resolved"]), 2)
|
||||||
self.assertEqual(body["errors"], [])
|
self.assertEqual(body["errors"], [])
|
||||||
|
|
||||||
for temp in (first, second):
|
# Completed uploads are notifications now: the staged rows are gone
|
||||||
temp.refresh_from_db()
|
# and the live feed carries the filename -> J-ID mapping.
|
||||||
self.assertEqual(temp.status, TempUpload.STATUS_COMPLETED)
|
self.assertFalse(
|
||||||
self.assertIsNotNone(temp.library_item_id)
|
TempUpload.objects.filter(pk__in=[first.id, second.id]).exists()
|
||||||
self.assertEqual(temp.library_item.rating, "q")
|
)
|
||||||
self.assertEqual(temp.library_item.uploaded_by_id, self.uploader.id)
|
feed = UploadRun.objects.get(user=self.uploader).recent_completions
|
||||||
|
self.assertEqual(
|
||||||
|
{entry["filename"] for entry in feed}, {"one.png", "two.png"}
|
||||||
|
)
|
||||||
|
for item in MediaItem.objects.all():
|
||||||
|
self.assertEqual(item.rating, "q")
|
||||||
|
self.assertEqual(item.uploaded_by_id, self.uploader.id)
|
||||||
untouched.refresh_from_db()
|
untouched.refresh_from_db()
|
||||||
self.assertEqual(untouched.status, TempUpload.STATUS_PENDING)
|
self.assertEqual(untouched.status, TempUpload.STATUS_PENDING)
|
||||||
|
|
||||||
@@ -231,6 +237,25 @@ class StagedUploadWorkflowTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
return seed
|
return seed
|
||||||
|
|
||||||
|
def test_duplicate_upload_returns_a_completion_without_a_record(self):
|
||||||
|
client = self.api_client(self.uploader)
|
||||||
|
first = self.upload_via_api(client, "same.png")
|
||||||
|
self.assertEqual(first.status_code, 201)
|
||||||
|
# Index the first upload so the second one is byte-identical.
|
||||||
|
seed = TempUpload.objects.get(pk=first.json()["temp_id"])
|
||||||
|
self.resolve_bulk(client, [seed.id], "s")
|
||||||
|
|
||||||
|
response = self.upload_via_api(client, "same.png")
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(body["status"], TempUpload.STATUS_COMPLETED)
|
||||||
|
self.assertEqual(body["resolution"], TempUpload.RESOLUTION_DUPLICATE)
|
||||||
|
self.assertTrue(body["library_j_id"].startswith("J-"))
|
||||||
|
# Duplicates never become board records; the feed announces them.
|
||||||
|
self.assertFalse(TempUpload.objects.filter(pk=body["temp_id"]).exists())
|
||||||
|
feed = UploadRun.objects.get(user=self.uploader).recent_completions
|
||||||
|
self.assertEqual(feed[-1]["filename"], "same.png")
|
||||||
|
|
||||||
def test_upload_defers_visual_similarity_to_its_phase(self):
|
def test_upload_defers_visual_similarity_to_its_phase(self):
|
||||||
client = self.api_client(self.uploader)
|
client = self.api_client(self.uploader)
|
||||||
self.seed_library_item(client, "seed-defer")
|
self.seed_library_item(client, "seed-defer")
|
||||||
@@ -262,6 +287,37 @@ class StagedUploadWorkflowTests(TestCase):
|
|||||||
self.assertEqual(body["visual_matches"], [])
|
self.assertEqual(body["visual_matches"], [])
|
||||||
self.assertEqual(body["status"], TempUpload.STATUS_PENDING)
|
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):
|
def test_visual_match_phase_rejects_completed_uploads(self):
|
||||||
client = self.api_client(self.uploader)
|
client = self.api_client(self.uploader)
|
||||||
temp = self.make_temp(
|
temp = self.make_temp(
|
||||||
@@ -312,14 +368,23 @@ class StagedUploadWorkflowTests(TestCase):
|
|||||||
body = response.json()
|
body = response.json()
|
||||||
self.assertEqual(body["errors"], [])
|
self.assertEqual(body["errors"], [])
|
||||||
self.assertEqual(len(body["updated"]), 2)
|
self.assertEqual(len(body["updated"]), 2)
|
||||||
for temp, post_id in ((first, 900001), (second, 900002)):
|
for entry, post_id in zip(body["updated"], (900001, 900002)):
|
||||||
temp.refresh_from_db()
|
self.assertEqual(entry["resolution"], TempUpload.RESOLUTION_AUTO_MD5)
|
||||||
self.assertEqual(temp.status, TempUpload.STATUS_COMPLETED)
|
self.assertEqual(entry["e621_post_id"], post_id)
|
||||||
self.assertEqual(temp.library_item_id is not None, True)
|
self.assertTrue(entry["library_j_id"].startswith("J-"))
|
||||||
self.assertEqual(temp.e621_post_id, post_id)
|
# Indexed uploads no longer leave a board record; the completion feed
|
||||||
self.assertEqual(temp.resolution, TempUpload.RESOLUTION_AUTO_MD5)
|
# carries them for the live page instead.
|
||||||
self.assertEqual(temp.library_item.e621_post_id, post_id)
|
self.assertFalse(TempUpload.objects.exists())
|
||||||
self.assertEqual(MediaItem.objects.count(), 2)
|
self.assertEqual(
|
||||||
|
{item.e621_post_id for item in MediaItem.objects.all()},
|
||||||
|
{900001, 900002},
|
||||||
|
)
|
||||||
|
feed = UploadRun.objects.get(user=self.uploader).recent_completions
|
||||||
|
self.assertEqual(len(feed), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
{entry["filename"] for entry in feed},
|
||||||
|
{"bulk-link-1.png", "bulk-link-2.png"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class IqdbRecordingTests(TestCase):
|
class IqdbRecordingTests(TestCase):
|
||||||
@@ -461,6 +526,7 @@ class UploadPipelineTests(TestCase):
|
|||||||
|
|
||||||
def test_md5_match_auto_imports_the_file(self):
|
def test_md5_match_auto_imports_the_file(self):
|
||||||
temp = self.stage(label="match")
|
temp = self.stage(label="match")
|
||||||
|
temp_id = temp.id
|
||||||
post = {
|
post = {
|
||||||
"id": 123456,
|
"id": 123456,
|
||||||
"rating": "s",
|
"rating": "s",
|
||||||
@@ -477,17 +543,42 @@ class UploadPipelineTests(TestCase):
|
|||||||
):
|
):
|
||||||
upload_pipeline.run_pipeline(self.uploader.id)
|
upload_pipeline.run_pipeline(self.uploader.id)
|
||||||
|
|
||||||
temp.refresh_from_db()
|
# The indexed upload leaves no board record; the feed reports it.
|
||||||
self.assertEqual(temp.status, TempUpload.STATUS_COMPLETED)
|
self.assertFalse(TempUpload.objects.filter(pk=temp_id).exists())
|
||||||
self.assertEqual(temp.resolution, TempUpload.RESOLUTION_AUTO_MD5)
|
item = MediaItem.objects.get(e621_post_id=123456)
|
||||||
self.assertEqual(temp.e621_post_id, 123456)
|
self.assertEqual(item.uploaded_by_id, self.uploader.id)
|
||||||
self.assertIsNotNone(temp.library_item_id)
|
|
||||||
self.assertIsNotNone(temp.e621_checked_at)
|
|
||||||
self.assertIsNone(temp.claimed_at)
|
|
||||||
run = UploadRun.objects.get(user=self.uploader)
|
run = UploadRun.objects.get(user=self.uploader)
|
||||||
self.assertEqual(run.status, UploadRun.STATUS_IDLE)
|
self.assertEqual(run.status, UploadRun.STATUS_IDLE)
|
||||||
self.assertEqual(run.matched, 1)
|
self.assertEqual(run.matched, 1)
|
||||||
self.assertEqual(run.processed, 1)
|
self.assertEqual(run.processed, 1)
|
||||||
|
self.assertEqual(len(run.recent_completions), 1)
|
||||||
|
entry = run.recent_completions[0]
|
||||||
|
self.assertEqual(entry["id"], str(temp_id))
|
||||||
|
self.assertEqual(entry["item_id"], item.id)
|
||||||
|
self.assertEqual(entry["filename"], "match.png")
|
||||||
|
self.assertEqual(entry["resolution"], TempUpload.RESOLUTION_AUTO_MD5)
|
||||||
|
|
||||||
|
def test_status_reports_the_completion_feed(self):
|
||||||
|
temp = self.stage(label="feed")
|
||||||
|
client = self.api_client(self.uploader)
|
||||||
|
post = {
|
||||||
|
"id": 654321,
|
||||||
|
"rating": "s",
|
||||||
|
"file": {"md5": temp.md5, "url": "https://static1.e621.net/data/f.png"},
|
||||||
|
}
|
||||||
|
with mock.patch.object(
|
||||||
|
upload_pipeline.e621,
|
||||||
|
"check_md5_batch",
|
||||||
|
return_value={temp.md5: post},
|
||||||
|
):
|
||||||
|
upload_pipeline.run_pipeline(self.uploader.id)
|
||||||
|
|
||||||
|
body = client.get("/api/uploads/status/").json()
|
||||||
|
completions = body["recent_completions"]
|
||||||
|
self.assertEqual(len(completions), 1)
|
||||||
|
self.assertEqual(completions[0]["filename"], "feed.png")
|
||||||
|
self.assertEqual(completions[0]["j_id"], f"J-{MediaItem.objects.get().id}")
|
||||||
|
self.assertIn("/thumbnail/", completions[0]["thumbnail_url"])
|
||||||
|
|
||||||
def test_unmatched_file_runs_every_phase(self):
|
def test_unmatched_file_runs_every_phase(self):
|
||||||
temp = self.stage(label="nomatch")
|
temp = self.stage(label="nomatch")
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from django.db.models import F, Q
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from . import e621, services
|
from . import e621, services
|
||||||
from .models import TempUpload, UploadRun
|
from .models import MediaItem, TempUpload, UploadRun
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -46,6 +46,9 @@ MAX_ATTEMPTS = 3
|
|||||||
# Round-level e621 retries before the run is paused.
|
# Round-level e621 retries before the run is paused.
|
||||||
ROUND_ATTEMPTS = 3
|
ROUND_ATTEMPTS = 3
|
||||||
ROUND_RETRY_SECONDS = 20
|
ROUND_RETRY_SECONDS = 20
|
||||||
|
# Completions kept in the live feed. The board only shows what happened while
|
||||||
|
# the page was open, so a bounded rolling window is plenty.
|
||||||
|
COMPLETION_FEED_LIMIT = 200
|
||||||
|
|
||||||
WORK_STATUSES = (TempUpload.STATUS_PENDING, TempUpload.STATUS_VISUAL_MATCH)
|
WORK_STATUSES = (TempUpload.STATUS_PENDING, TempUpload.STATUS_VISUAL_MATCH)
|
||||||
VIDEO_RE = r"\.(mp4|webm)$"
|
VIDEO_RE = r"\.(mp4|webm)$"
|
||||||
@@ -116,7 +119,7 @@ def waiting_counts(user):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def status_payload(user):
|
def status_payload(user, request=None):
|
||||||
"""Cheap state for the shell/upload page to poll."""
|
"""Cheap state for the shell/upload page to poll."""
|
||||||
run = UploadRun.objects.filter(user=user).first()
|
run = UploadRun.objects.filter(user=user).first()
|
||||||
outstanding = count_outstanding(user)
|
outstanding = count_outstanding(user)
|
||||||
@@ -141,10 +144,67 @@ def status_payload(user):
|
|||||||
"error": run.error if run is not None else "",
|
"error": run.error if run is not None else "",
|
||||||
"outstanding": outstanding,
|
"outstanding": outstanding,
|
||||||
"waiting": waiting_counts(user),
|
"waiting": waiting_counts(user),
|
||||||
|
"recent_completions": completion_payload(run, user, request=request),
|
||||||
"updated_at": run.updated_at.isoformat() if run is not None else None,
|
"updated_at": run.updated_at.isoformat() if run is not None else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def completion_payload(run, user, request=None):
|
||||||
|
"""The live completion feed: filename -> J-ID for freshly indexed uploads.
|
||||||
|
|
||||||
|
Completed ``TempUpload`` rows are deleted, so this is the only place the
|
||||||
|
board learns about them. It is a notification feed, not durable state:
|
||||||
|
bounded, never dismissed, and ignored by fresh page loads.
|
||||||
|
"""
|
||||||
|
entries = list(run.recent_completions or []) if run is not None else []
|
||||||
|
if not entries:
|
||||||
|
return []
|
||||||
|
ids = [entry.get("item_id") for entry in entries if entry.get("item_id")]
|
||||||
|
items = MediaItem.objects.in_bulk(ids)
|
||||||
|
out = []
|
||||||
|
for entry in reversed(entries): # newest first
|
||||||
|
item = items.get(entry.get("item_id"))
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": entry.get("id"),
|
||||||
|
"filename": entry.get("filename"),
|
||||||
|
"j_id": f"J-{item.id}",
|
||||||
|
"resolution": entry.get("resolution", ""),
|
||||||
|
"post_id": entry.get("post_id"),
|
||||||
|
"thumbnail_url": services.signed_media_url(
|
||||||
|
item, user, "thumbnail", request=request
|
||||||
|
),
|
||||||
|
"at": entry.get("at"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def record_completion(temp, item, resolution=""):
|
||||||
|
"""Append one completion to the owner's feed; never fails an import."""
|
||||||
|
entry = {
|
||||||
|
"id": str(temp.pk),
|
||||||
|
"filename": temp.original_filename,
|
||||||
|
"item_id": item.pk,
|
||||||
|
"resolution": resolution or temp.resolution or "",
|
||||||
|
"post_id": temp.e621_post_id,
|
||||||
|
"at": timezone.now().isoformat(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
run, _ = UploadRun.objects.select_for_update().get_or_create(
|
||||||
|
user_id=temp.user_id
|
||||||
|
)
|
||||||
|
feed = list(run.recent_completions or [])
|
||||||
|
feed.append(entry)
|
||||||
|
run.recent_completions = feed[-COMPLETION_FEED_LIMIT:]
|
||||||
|
run.save(update_fields=["recent_completions", "updated_at"])
|
||||||
|
except Exception: # noqa: BLE001 - a notification must not break an import
|
||||||
|
logger.exception("Could not record the completion of %s", temp.pk)
|
||||||
|
|
||||||
|
|
||||||
def reap_stale_claims():
|
def reap_stale_claims():
|
||||||
"""Queue rows left claimed by a recycled worker and pause dead runs."""
|
"""Queue rows left claimed by a recycled worker and pause dead runs."""
|
||||||
cutoff = timezone.now() - STALE_CLAIM_AFTER
|
cutoff = timezone.now() - STALE_CLAIM_AFTER
|
||||||
@@ -237,20 +297,17 @@ def run_pipeline(user_id):
|
|||||||
break
|
break
|
||||||
process_round(run, user, rows)
|
process_round(run, user, rows)
|
||||||
except PipelinePaused as exc:
|
except PipelinePaused as exc:
|
||||||
run.status = UploadRun.STATUS_PAUSED
|
_save_run(run, status=UploadRun.STATUS_PAUSED, phase="", error=str(exc))
|
||||||
run.phase = ""
|
|
||||||
run.error = str(exc)
|
|
||||||
run.save()
|
|
||||||
except Exception as exc: # noqa: BLE001 - surface crashes as a run error
|
except Exception as exc: # noqa: BLE001 - surface crashes as a run error
|
||||||
logger.exception("Upload pipeline for user %s failed", user_id)
|
logger.exception("Upload pipeline for user %s failed", user_id)
|
||||||
run.status = UploadRun.STATUS_ERROR
|
_save_run(
|
||||||
run.phase = ""
|
run,
|
||||||
run.error = f"The upload pipeline stopped: {exc}"
|
status=UploadRun.STATUS_ERROR,
|
||||||
run.save()
|
phase="",
|
||||||
|
error=f"The upload pipeline stopped: {exc}",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
run.status = UploadRun.STATUS_IDLE
|
_save_run(run, status=UploadRun.STATUS_IDLE, phase="")
|
||||||
run.phase = ""
|
|
||||||
run.save()
|
|
||||||
|
|
||||||
|
|
||||||
def claim_round(user, size=CLAIM_SIZE):
|
def claim_round(user, size=CLAIM_SIZE):
|
||||||
@@ -460,7 +517,9 @@ def finalize_round(run, ids, matched):
|
|||||||
TempUpload.objects.filter(pk__in=release).update(claimed_at=None)
|
TempUpload.objects.filter(pk__in=release).update(claimed_at=None)
|
||||||
_save_run(
|
_save_run(
|
||||||
run,
|
run,
|
||||||
processed=run.processed + len(finished),
|
# Completed rows are deleted as they are imported, so they cannot be
|
||||||
|
# seen in the refreshed rows; count the matches explicitly.
|
||||||
|
processed=run.processed + len(finished) + matched,
|
||||||
failed=run.failed + len(failed - finished),
|
failed=run.failed + len(failed - finished),
|
||||||
matched=run.matched + matched,
|
matched=run.matched + matched,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core import signing
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -30,6 +29,7 @@ from . import services
|
|||||||
from .models import MediaItem, TempUpload
|
from .models import MediaItem, TempUpload
|
||||||
from .permissions import CanUpload
|
from .permissions import CanUpload
|
||||||
from .serializers import TempUploadListSerializer, TempUploadSerializer
|
from .serializers import TempUploadListSerializer, TempUploadSerializer
|
||||||
|
from .signing_urls import load_payload
|
||||||
from .tools import HASH_FIELDS, hashed_items, hashes_similarity
|
from .tools import HASH_FIELDS, hashed_items, hashes_similarity
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -60,6 +60,7 @@ def match_hashes(hashes, index, limit=10, user=None, request=None):
|
|||||||
location = item.locations.first()
|
location = item.locations.first()
|
||||||
matches.append(
|
matches.append(
|
||||||
{
|
{
|
||||||
|
"item_id": item.id,
|
||||||
"j_id": f"J-{item.id}",
|
"j_id": f"J-{item.id}",
|
||||||
"filename": Path(location.rel_path).name if location else item.md5,
|
"filename": Path(location.rel_path).name if location else item.md5,
|
||||||
"similarity": round(similarity * 100, 1),
|
"similarity": round(similarity * 100, 1),
|
||||||
@@ -127,6 +128,12 @@ def complete_temp_upload(temp, download_url=None):
|
|||||||
services.ensure_visual_hashes(item)
|
services.ensure_visual_hashes(item)
|
||||||
temp.file.delete(save=False)
|
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.library_item = item
|
||||||
temp.status = TempUpload.STATUS_COMPLETED
|
temp.status = TempUpload.STATUS_COMPLETED
|
||||||
|
|
||||||
@@ -158,6 +165,13 @@ def complete_temp_upload(temp, download_url=None):
|
|||||||
item.save(update_fields=update_fields + ["updated_at"])
|
item.save(update_fields=update_fields + ["updated_at"])
|
||||||
|
|
||||||
temp.save()
|
temp.save()
|
||||||
|
from .upload_pipeline import record_completion
|
||||||
|
|
||||||
|
record_completion(temp, item)
|
||||||
|
# The board learns about completions from the live feed, so the record is
|
||||||
|
# deleted as soon as the file is indexed: nothing left to dismiss. Delete
|
||||||
|
# through the queryset so callers keep ``temp.pk`` for their response.
|
||||||
|
TempUpload.objects.filter(pk=temp.pk).delete()
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
@@ -190,6 +204,28 @@ class TempUploadViewSet(
|
|||||||
user=self.request.user
|
user=self.request.user
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _completed_payload(self, temp, item):
|
||||||
|
"""Synthetic row for an upload that is indexed immediately.
|
||||||
|
|
||||||
|
Duplicates and resolved uploads never leave a board record; the SPA
|
||||||
|
turns this response (or the live completion feed) into a "J-x
|
||||||
|
uploaded" card that lives only in the page session.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"temp_id": str(temp.pk),
|
||||||
|
"original_filename": temp.original_filename,
|
||||||
|
"md5": temp.md5,
|
||||||
|
"size": temp.size,
|
||||||
|
"status": TempUpload.STATUS_COMPLETED,
|
||||||
|
"resolution": temp.resolution,
|
||||||
|
"e621_post_id": temp.e621_post_id,
|
||||||
|
"library_j_id": f"J-{item.id}",
|
||||||
|
"file_url": None,
|
||||||
|
"preview_url": services.signed_media_url(
|
||||||
|
item, self.request.user, "thumbnail", request=self.request
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
def create(self, request):
|
def create(self, request):
|
||||||
upload = request.FILES.get("file")
|
upload = request.FILES.get("file")
|
||||||
if upload is None:
|
if upload is None:
|
||||||
@@ -217,6 +253,13 @@ class TempUploadViewSet(
|
|||||||
temp.resolution = TempUpload.RESOLUTION_DUPLICATE
|
temp.resolution = TempUpload.RESOLUTION_DUPLICATE
|
||||||
temp.library_item = existing
|
temp.library_item = existing
|
||||||
temp.file.delete(save=False)
|
temp.file.delete(save=False)
|
||||||
|
temp.save()
|
||||||
|
from .upload_pipeline import record_completion
|
||||||
|
|
||||||
|
record_completion(temp, existing)
|
||||||
|
payload = self._completed_payload(temp, existing)
|
||||||
|
TempUpload.objects.filter(pk=temp.pk).delete()
|
||||||
|
return Response(payload, status=status.HTTP_201_CREATED)
|
||||||
# Visual similarity and IQDB run in the background pipeline so a large
|
# Visual similarity and IQDB run in the background pipeline so a large
|
||||||
# batch uploads at full speed and the work survives the browser.
|
# batch uploads at full speed and the work survives the browser.
|
||||||
temp.save()
|
temp.save()
|
||||||
@@ -257,7 +300,7 @@ class TempUploadViewSet(
|
|||||||
"""Cheap pipeline state for the shell indicator and the upload page."""
|
"""Cheap pipeline state for the shell indicator and the upload page."""
|
||||||
from .upload_pipeline import status_payload
|
from .upload_pipeline import status_payload
|
||||||
|
|
||||||
return Response(status_payload(request.user))
|
return Response(status_payload(request.user, request=request))
|
||||||
|
|
||||||
@action(detail=False, methods=["post"])
|
@action(detail=False, methods=["post"])
|
||||||
def process(self, request):
|
def process(self, request):
|
||||||
@@ -269,7 +312,7 @@ class TempUploadViewSet(
|
|||||||
from .upload_pipeline import start_pipeline, status_payload
|
from .upload_pipeline import start_pipeline, status_payload
|
||||||
|
|
||||||
start_pipeline(request.user)
|
start_pipeline(request.user)
|
||||||
return Response(status_payload(request.user))
|
return Response(status_payload(request.user, request=request))
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
@action(detail=True, methods=["post"])
|
||||||
def retry(self, request, pk=None):
|
def retry(self, request, pk=None):
|
||||||
@@ -314,7 +357,7 @@ class TempUploadViewSet(
|
|||||||
update["status"] = TempUpload.STATUS_PENDING
|
update["status"] = TempUpload.STATUS_PENDING
|
||||||
TempUpload.objects.filter(pk=temp.pk).update(**update)
|
TempUpload.objects.filter(pk=temp.pk).update(**update)
|
||||||
start_pipeline(request.user)
|
start_pipeline(request.user)
|
||||||
return Response(status_payload(request.user))
|
return Response(status_payload(request.user, request=request))
|
||||||
|
|
||||||
@action(detail=False, methods=["post"], url_path="retry-all")
|
@action(detail=False, methods=["post"], url_path="retry-all")
|
||||||
def retry_all(self, request):
|
def retry_all(self, request):
|
||||||
@@ -340,7 +383,7 @@ class TempUploadViewSet(
|
|||||||
e621_checked_at=None,
|
e621_checked_at=None,
|
||||||
)
|
)
|
||||||
start_pipeline(request.user)
|
start_pipeline(request.user)
|
||||||
return Response(status_payload(request.user))
|
return Response(status_payload(request.user, request=request))
|
||||||
|
|
||||||
@action(detail=False, methods=["post"], url_path="discard-bulk")
|
@action(detail=False, methods=["post"], url_path="discard-bulk")
|
||||||
def discard_bulk(self, request):
|
def discard_bulk(self, request):
|
||||||
@@ -393,14 +436,7 @@ class TempUploadViewSet(
|
|||||||
if user is None:
|
if user is None:
|
||||||
signature = request.query_params.get("sig")
|
signature = request.query_params.get("sig")
|
||||||
if signature:
|
if signature:
|
||||||
try:
|
payload = load_payload(signature, services.UPLOAD_FILE_SALT)
|
||||||
payload = signing.loads(
|
|
||||||
signature,
|
|
||||||
salt=services.UPLOAD_FILE_SALT,
|
|
||||||
max_age=86400,
|
|
||||||
)
|
|
||||||
except signing.BadSignature:
|
|
||||||
payload = None
|
|
||||||
if payload and str(payload.get("temp")) == str(pk):
|
if payload and str(payload.get("temp")) == str(pk):
|
||||||
user = (
|
user = (
|
||||||
get_user_model()
|
get_user_model()
|
||||||
@@ -501,7 +537,7 @@ class TempUploadViewSet(
|
|||||||
|
|
||||||
temp.save()
|
temp.save()
|
||||||
try:
|
try:
|
||||||
complete_temp_upload(temp, download_url=download_url)
|
item = complete_temp_upload(temp, download_url=download_url)
|
||||||
except Exception as exc: # noqa: BLE001 - report completion failures
|
except Exception as exc: # noqa: BLE001 - report completion failures
|
||||||
logger.exception("Could not complete staged upload %s", temp.id)
|
logger.exception("Could not complete staged upload %s", temp.id)
|
||||||
temp.status = TempUpload.STATUS_ERROR
|
temp.status = TempUpload.STATUS_ERROR
|
||||||
@@ -510,8 +546,7 @@ class TempUploadViewSet(
|
|||||||
{"detail": f"Could not finish the upload: {exc}"},
|
{"detail": f"Could not finish the upload: {exc}"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
temp.refresh_from_db()
|
return Response(self._completed_payload(temp, item))
|
||||||
return Response(self.get_serializer(temp).data)
|
|
||||||
|
|
||||||
@action(detail=False, methods=["post"], url_path="link-bulk")
|
@action(detail=False, methods=["post"], url_path="link-bulk")
|
||||||
def link_bulk(self, request):
|
def link_bulk(self, request):
|
||||||
@@ -583,15 +618,14 @@ class TempUploadViewSet(
|
|||||||
candidate_url = ""
|
candidate_url = ""
|
||||||
temp.save()
|
temp.save()
|
||||||
try:
|
try:
|
||||||
complete_temp_upload(temp, download_url=candidate_url or None)
|
item = complete_temp_upload(temp, download_url=candidate_url or None)
|
||||||
except Exception as exc: # noqa: BLE001 - report per-file failures
|
except Exception as exc: # noqa: BLE001 - report per-file failures
|
||||||
logger.exception("Could not complete staged upload %s", temp.id)
|
logger.exception("Could not complete staged upload %s", temp.id)
|
||||||
temp.status = TempUpload.STATUS_ERROR
|
temp.status = TempUpload.STATUS_ERROR
|
||||||
temp.save(update_fields=["status", "updated_at"])
|
temp.save(update_fields=["status", "updated_at"])
|
||||||
errors.append({"temp_id": temp_id, "error": str(exc)})
|
errors.append({"temp_id": temp_id, "error": str(exc)})
|
||||||
continue
|
continue
|
||||||
temp.refresh_from_db()
|
updated.append(self._completed_payload(temp, item))
|
||||||
updated.append(self.get_serializer(temp).data)
|
|
||||||
|
|
||||||
return Response({"updated": updated, "errors": errors})
|
return Response({"updated": updated, "errors": errors})
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from pathlib import Path
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
|
||||||
from django.db.models import Min, Q
|
from django.db.models import Min, Q
|
||||||
from django.http import Http404, StreamingHttpResponse
|
from django.http import Http404, StreamingHttpResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
@@ -31,6 +30,7 @@ from .serializers import (
|
|||||||
MatchTaskSerializer,
|
MatchTaskSerializer,
|
||||||
MediaItemSerializer,
|
MediaItemSerializer,
|
||||||
)
|
)
|
||||||
|
from .signing_urls import load_payload
|
||||||
|
|
||||||
LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"}
|
LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"}
|
||||||
MD5_RE = re.compile(r"[0-9a-fA-F]{32}")
|
MD5_RE = re.compile(r"[0-9a-fA-F]{32}")
|
||||||
@@ -132,11 +132,8 @@ class MediaItemViewSet(
|
|||||||
signature = request.query_params.get("sig")
|
signature = request.query_params.get("sig")
|
||||||
if not signature:
|
if not signature:
|
||||||
return None
|
return None
|
||||||
try:
|
payload = load_payload(signature, services.MEDIA_FILE_SALT)
|
||||||
payload = signing.loads(
|
if payload is None:
|
||||||
signature, salt=services.MEDIA_FILE_SALT, max_age=86400
|
|
||||||
)
|
|
||||||
except signing.BadSignature:
|
|
||||||
return None
|
return None
|
||||||
if payload.get("action") != action_name:
|
if payload.get("action") != action_name:
|
||||||
return None
|
return None
|
||||||
@@ -158,7 +155,11 @@ class MediaItemViewSet(
|
|||||||
status=status.HTTP_404_NOT_FOUND,
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
)
|
)
|
||||||
return services.serve_file(
|
return services.serve_file(
|
||||||
request, location.path, download=request.query_params.get("download") == "1"
|
request,
|
||||||
|
location.path,
|
||||||
|
download=request.query_params.get("download") == "1",
|
||||||
|
max_age=services.MEDIA_CACHE_SECONDS,
|
||||||
|
immutable=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@action(detail=True, methods=["get"], throttle_classes=[])
|
@action(detail=True, methods=["get"], throttle_classes=[])
|
||||||
@@ -173,13 +174,26 @@ class MediaItemViewSet(
|
|||||||
path = Path(location.path)
|
path = Path(location.path)
|
||||||
if path.suffix.lower() in services.VIDEO_EXTENSIONS:
|
if path.suffix.lower() in services.VIDEO_EXTENSIONS:
|
||||||
thumbnail = services.generate_video_thumbnail(item.md5, path)
|
thumbnail = services.generate_video_thumbnail(item.md5, path)
|
||||||
if thumbnail is None:
|
else:
|
||||||
return Response(
|
thumbnail = services.generate_image_thumbnail(item.md5, path)
|
||||||
{"detail": "Thumbnail unavailable."},
|
if thumbnail is not None:
|
||||||
status=status.HTTP_404_NOT_FOUND,
|
return services.serve_file(
|
||||||
)
|
request,
|
||||||
return services.serve_file(request, thumbnail)
|
thumbnail,
|
||||||
return services.serve_file(request, path)
|
max_age=services.MEDIA_CACHE_SECONDS,
|
||||||
|
immutable=True,
|
||||||
|
)
|
||||||
|
if path.suffix.lower() in services.VIDEO_EXTENSIONS:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Thumbnail unavailable."},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
return services.serve_file(
|
||||||
|
request,
|
||||||
|
path,
|
||||||
|
max_age=services.MEDIA_CACHE_SECONDS,
|
||||||
|
immutable=True,
|
||||||
|
)
|
||||||
|
|
||||||
@action(detail=False, methods=["post"], permission_classes=[AllowAny])
|
@action(detail=False, methods=["post"], permission_classes=[AllowAny])
|
||||||
def lookup(self, request):
|
def lookup(self, request):
|
||||||
|
|||||||
+9
-12
@@ -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
|
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
|
```bash
|
||||||
./push_desktop.sh # build Linux packages + copy the feed to jakerasp
|
./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/`,
|
`$J621_DESKTOP_FEED_HOST` when set. Artifacts land in `deploy/data/desktop/`,
|
||||||
which the frontend nginx mounts read-only and serves at `/desktop/`. The
|
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
|
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
|
does not. Backend-only composes have no frontend, so no website feed.
|
||||||
`latest-linux.yml` / `latest.yml` from there (see `desktop/README.md`).
|
|
||||||
Backend-only composes have no frontend, so no feed.
|
|
||||||
|
|
||||||
The manual **CD** workflow (Actions tab) builds the desktop packages on the
|
The manual **CD** workflow (Actions tab) builds the desktop packages on the
|
||||||
runner and attaches them plus the update metadata to the Gitea release
|
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
|
`desktop-v<version>`; that is what the desktop updater reads. The website feed
|
||||||
runtime state on the deploy host and CI has no SSH key for it. After a CD run,
|
is not touched by CI (runtime state on the deploy host, no SSH key there); use
|
||||||
publish the feed from a machine that can reach the deploy checkout:
|
`push_desktop.sh` when it needs refreshing for old installs.
|
||||||
|
|
||||||
```bash
|
|
||||||
./push_desktop.sh --no-build --local # or without --local to also copy it
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scheduled jobs
|
## Scheduled jobs
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/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
|
# Desktop updates no longer depend on this: the app resolves the newest
|
||||||
# read-only and serves at /desktop/. With --host the same directory is also
|
# `desktop-v*` release on Gitea at check time (see desktop/README.md). This
|
||||||
# copied to a remote deploy checkout (rsync, or tar over ssh when the server
|
# script builds the packages and can copy them to deploy/data/desktop, which
|
||||||
# has no rsync). electron-updater reads latest-linux.yml / latest.yml from
|
# the frontend nginx mounts read-only and serves at /desktop/ for manual
|
||||||
# there; the feed URL comes from desktop/electron-builder.yml.
|
# downloads and for pre-0.1.2 installs.
|
||||||
#
|
#
|
||||||
# Usage: ./push_desktop.sh [--win] [--no-build] [--local] [--host user@server:/path]
|
# Usage: ./push_desktop.sh [--win] [--no-build] [--local] [--host user@server:/path]
|
||||||
# --win also cross-build the Windows NSIS installer (needs wine)
|
# --win also cross-build the Windows NSIS installer (needs wine)
|
||||||
|
|||||||
+19
-8
@@ -49,7 +49,8 @@ npm run dist:all
|
|||||||
|
|
||||||
`deploy/build_desktop.sh` wraps the same commands, installs dependencies on
|
`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
|
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:
|
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):
|
The app checks only when asked (**J621 → Check for updates…** in the menu):
|
||||||
Linux packages install through pacman/dpkg, which needs administrator rights,
|
Linux packages install through pacman/dpkg, which needs administrator rights,
|
||||||
and the Windows build is unsigned, so nothing installs silently. The check
|
and the Windows build is unsigned, so nothing installs silently.
|
||||||
reads `latest-linux.yml` / `latest.yml` from the feed configured in
|
|
||||||
`electron-builder.yml` (`publish.url`, baked into `app-update.yml`); set
|
The check resolves the feed itself: it asks the Gitea API for the newest
|
||||||
`J621_UPDATE_URL` to point a build at another feed (the smoke test uses this).
|
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:
|
Publishing a release:
|
||||||
|
|
||||||
1. Bump `version` in `desktop/package.json` — that is what the updater compares.
|
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
|
2. Run the manual CD workflow, which builds the packages and attaches them
|
||||||
artifacts plus both channel files into `deploy/data/desktop/`, which the
|
plus both channel files to the Gitea release `desktop-v<version>`.
|
||||||
frontend nginx serves read-only at `/desktop/`.
|
`./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.
|
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
|
`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
|
`pacman -U` or `dpkg -i` (both via pkexec/sudo); the per-user NSIS install
|
||||||
updates without elevation.
|
updates without elevation.
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ appId: io.j621.desktop
|
|||||||
productName: J621
|
productName: J621
|
||||||
copyright: Copyright (c) 2026 JakeBreath — Jake Labs Non-Commercial Software Licence
|
copyright: Copyright (c) 2026 JakeBreath — Jake Labs Non-Commercial Software Licence
|
||||||
|
|
||||||
# Update feed served by the frontend nginx (deploy/data/desktop, published
|
# Update feed: desktop/src/main.ts resolves the newest desktop-v* release on
|
||||||
# with deploy/push_desktop.sh). Baked into resources/app-update.yml; override
|
# Gitea at check time (J621_UPDATE_REPO). This block only tells electron-builder
|
||||||
# at runtime with J621_UPDATE_URL for a fork or a test feed.
|
# to emit latest.yml/latest-linux.yml next to the installers; J621_UPDATE_URL
|
||||||
|
# overrides the feed for a fork or a test.
|
||||||
publish:
|
publish:
|
||||||
provider: generic
|
provider: generic
|
||||||
url: https://j621.rainbow-herring.ts.net/desktop
|
url: https://gitea.rainbow-herring.ts.net/JakeBreath/J621/releases
|
||||||
|
|
||||||
directories:
|
directories:
|
||||||
output: release
|
output: release
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "j621-desktop",
|
"name": "j621-desktop",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "j621-desktop",
|
"name": "j621-desktop",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"license": "LicenseRef-Jake-Labs-Non-Commercial",
|
"license": "LicenseRef-Jake-Labs-Non-Commercial",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.8.9"
|
"electron-updater": "^6.8.9"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "j621-desktop",
|
"name": "j621-desktop",
|
||||||
"productName": "J621",
|
"productName": "J621",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Desktop shell for the J621 self-hosted media archive",
|
"description": "Desktop shell for the J621 self-hosted media archive",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
+82
-4
@@ -238,8 +238,9 @@ function isDownloadNavigation(url: URL): boolean {
|
|||||||
/**
|
/**
|
||||||
* Updates are manual by design: Linux packages install through pacman/dpkg
|
* Updates are manual by design: Linux packages install through pacman/dpkg
|
||||||
* (pkexec/sudo) and the Windows build is unsigned, so the app asks before
|
* (pkexec/sudo) and the Windows build is unsigned, so the app asks before
|
||||||
* downloading and again before installing. The feed comes from `publish` in
|
* downloading and again before installing. The feed is resolved at check time
|
||||||
* electron-builder.yml and can be overridden with J621_UPDATE_URL.
|
* from the newest `desktop-v*` release on Gitea (`J621_UPDATE_REPO`);
|
||||||
|
* `J621_UPDATE_URL` overrides it for forks and the smoke test.
|
||||||
*/
|
*/
|
||||||
type UpdateEvent =
|
type UpdateEvent =
|
||||||
| { state: "available"; version: string }
|
| { state: "available"; version: string }
|
||||||
@@ -250,9 +251,84 @@ type UpdateEvent =
|
|||||||
let updateReporter: ((event: UpdateEvent) => void) | null = null;
|
let updateReporter: ((event: UpdateEvent) => void) | null = null;
|
||||||
let updateCheckRunning = false;
|
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();
|
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;
|
if (!app.isPackaged) autoUpdater.forceDevUpdateConfig = true;
|
||||||
autoUpdater.autoDownload = false;
|
autoUpdater.autoDownload = false;
|
||||||
autoUpdater.autoInstallOnAppQuit = false;
|
autoUpdater.autoInstallOnAppQuit = false;
|
||||||
@@ -336,6 +412,8 @@ async function checkForUpdates(win: BrowserWindow): Promise<void> {
|
|||||||
if (updateCheckRunning) return;
|
if (updateCheckRunning) return;
|
||||||
updateCheckRunning = true;
|
updateCheckRunning = true;
|
||||||
try {
|
try {
|
||||||
|
const feed = await resolveReleaseFeed();
|
||||||
|
autoUpdater.setFeedURL({ provider: "generic", url: feed });
|
||||||
await autoUpdater.checkForUpdates();
|
await autoUpdater.checkForUpdates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Install the J621 Windows extras (PowerShell greeting + Rust gm-switch).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# .\install-windows.ps1
|
||||||
|
# .\install-windows.ps1 -Url https://j621.example.ts.net -Token <token>
|
||||||
|
# .\install-windows.ps1 -NoPrompt
|
||||||
|
# .\install-windows.ps1 -Uninstall
|
||||||
|
#
|
||||||
|
# Options are forwarded to the component installers (see
|
||||||
|
# powershell_greeting\install.ps1 and win-gm-switch\install.ps1).
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Url,
|
||||||
|
[string]$Token,
|
||||||
|
[switch]$NoPrompt,
|
||||||
|
[switch]$Force,
|
||||||
|
[switch]$NoProfilePatch,
|
||||||
|
[switch]$SkipGreeting,
|
||||||
|
[switch]$SkipGmSwitch,
|
||||||
|
[switch]$Uninstall
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$here = $PSScriptRoot
|
||||||
|
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
|
||||||
|
$failures = New-Object System.Collections.Generic.List[string]
|
||||||
|
|
||||||
|
if (-not $SkipGmSwitch) {
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host '=== gm-switch ==='
|
||||||
|
$params = @{}
|
||||||
|
if ($Uninstall) { $params.Uninstall = $true }
|
||||||
|
& (Join-Path $here 'win-gm-switch\install.ps1') @params
|
||||||
|
if ($LASTEXITCODE -ne 0) { $failures.Add('gm-switch installer failed') }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipGreeting) {
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host '=== PowerShell greeting ==='
|
||||||
|
$params = @{}
|
||||||
|
if ($Uninstall) { $params.Uninstall = $true }
|
||||||
|
if ($Url) { $params.Url = $Url }
|
||||||
|
if ($Token) { $params.Token = $Token }
|
||||||
|
if ($NoPrompt) { $params.NoPrompt = $true }
|
||||||
|
if ($Force) { $params.Force = $true }
|
||||||
|
if ($NoProfilePatch) { $params.NoProfilePatch = $true }
|
||||||
|
& (Join-Path $here 'powershell_greeting\install.ps1') @params
|
||||||
|
if ($LASTEXITCODE -ne 0) { $failures.Add('greeting installer failed') }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
if ($failures.Count -eq 0) {
|
||||||
|
Write-Host 'All done.'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
foreach ($failure in $failures) { Write-Host $failure }
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# J621Greeting.ps1 - show a random J621 library image as the shell greeting.
|
||||||
|
#
|
||||||
|
# PowerShell port of extras/fish_greeting/fish_greeting.fish (the J621 REST
|
||||||
|
# rewrite): asks GET /api/random/?fastfetch=1&rating=X for a random
|
||||||
|
# png/jpg/gif, downloads the signed url and runs fastfetch with the image as
|
||||||
|
# its logo. Based on the original script from
|
||||||
|
# J621-Django/extras/fish_greeting system/fish_greeting.fish.
|
||||||
|
#
|
||||||
|
# Requires: fastfetch and curl.exe (ships with Windows 10 1803+).
|
||||||
|
#
|
||||||
|
# Rating modes (same file name as the fish version):
|
||||||
|
# %APPDATA%\j621Greeting\greeting_mode 0 = NSFW (explicit)
|
||||||
|
# 1 = SFW (safe)
|
||||||
|
# 2 = Questionable (default)
|
||||||
|
#
|
||||||
|
# Configuration: %APPDATA%\j621Greeting\config.ps1 (sets environment
|
||||||
|
# variables; see config.example.ps1):
|
||||||
|
# J621_BASE API origin (default https://j621.rainbow-herring.ts.net)
|
||||||
|
# J621_TOKEN API / scoped greeting token, sent as "Authorization: Token ..."
|
||||||
|
# J621_WEB web origin used in the printed link (defaults to J621_BASE)
|
||||||
|
# J621_FASTFETCH_CONFIG fastfetch config name (default "jake")
|
||||||
|
# J621_MODE_FILE mode file override
|
||||||
|
# J621_LOGO_TYPE force a fastfetch logo type (sixel, kitty-direct, ...)
|
||||||
|
# J621_ALWAYS_FETCH=1 fetch even in terminals without image support
|
||||||
|
# J621_LOGO_WIDTH logo width in columns (default 35)
|
||||||
|
# J621_LOGO_HEIGHT iterm box height in rows (default 25)
|
||||||
|
|
||||||
|
#Requires -Version 5.1
|
||||||
|
|
||||||
|
function Get-J621FileKind {
|
||||||
|
param([string]$Path)
|
||||||
|
try {
|
||||||
|
$stream = [IO.File]::OpenRead($Path)
|
||||||
|
try {
|
||||||
|
$buffer = New-Object byte[] 8
|
||||||
|
$read = $stream.Read($buffer, 0, 8)
|
||||||
|
if ($read -ge 4 -and $buffer[0] -eq 0x89 -and $buffer[1] -eq 0x50 -and $buffer[2] -eq 0x4E -and $buffer[3] -eq 0x47) { return 'png' }
|
||||||
|
if ($read -ge 2 -and $buffer[0] -eq 0xFF -and $buffer[1] -eq 0xD8) { return 'jpeg' }
|
||||||
|
if ($read -ge 3 -and $buffer[0] -eq 0x47 -and $buffer[1] -eq 0x49 -and $buffer[2] -eq 0x46) { return 'gif' }
|
||||||
|
return 'unknown'
|
||||||
|
} finally { $stream.Dispose() }
|
||||||
|
} catch { return 'unknown' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-J621Accent {
|
||||||
|
param([string]$Text)
|
||||||
|
return "$([char]27)[4;2;38;2;138;0;222;49m$Text$([char]27)[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-J621Error {
|
||||||
|
param([string]$Text)
|
||||||
|
return "$([char]27)[31m$Text$([char]27)[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-J621FastfetchArgs {
|
||||||
|
$configName = if ($env:J621_FASTFETCH_CONFIG) { $env:J621_FASTFETCH_CONFIG } else { 'jake' }
|
||||||
|
$candidates = @(
|
||||||
|
(Join-Path $env:APPDATA "fastfetch\$configName.jsonc"),
|
||||||
|
(Join-Path $env:APPDATA "fastfetch\$configName\config.jsonc")
|
||||||
|
)
|
||||||
|
foreach ($candidate in $candidates) {
|
||||||
|
if (Test-Path -LiteralPath $candidate) { return @('--config', $configName) }
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-J621Greeting {
|
||||||
|
param([int]$Depth = 0)
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
$configFile = Join-Path $env:APPDATA 'j621Greeting\config.ps1'
|
||||||
|
if (Test-Path -LiteralPath $configFile) { . $configFile }
|
||||||
|
|
||||||
|
$base = if ($env:J621_BASE) { $env:J621_BASE.TrimEnd('/') } else { 'https://j621.rainbow-herring.ts.net' }
|
||||||
|
$webBase = if ($env:J621_WEB) { $env:J621_WEB.TrimEnd('/') } else { $base }
|
||||||
|
$token = $env:J621_TOKEN
|
||||||
|
$logoWidth = if ($env:J621_LOGO_WIDTH) { $env:J621_LOGO_WIDTH } else { '35' }
|
||||||
|
$modeFile = if ($env:J621_MODE_FILE) { $env:J621_MODE_FILE } else { Join-Path $env:APPDATA 'j621Greeting\greeting_mode' }
|
||||||
|
|
||||||
|
# --- Setup logging ---
|
||||||
|
$logDir = Join-Path $env:APPDATA 'j621Logos'
|
||||||
|
$logFile = Join-Path $logDir 'logs.log'
|
||||||
|
if (-not (Test-Path -LiteralPath $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
|
||||||
|
$stamp = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
|
||||||
|
|
||||||
|
# --- Mode selection ---
|
||||||
|
$mode = '2'
|
||||||
|
if (Test-Path -LiteralPath $modeFile) {
|
||||||
|
$raw = Get-Content -LiteralPath $modeFile -Raw -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $raw) { $mode = $raw.Trim() }
|
||||||
|
}
|
||||||
|
switch ($mode) {
|
||||||
|
'0' { $rating = 'e'; $modeName = 'NSFW' }
|
||||||
|
'1' { $rating = 's'; $modeName = 'SFW' }
|
||||||
|
'2' { $rating = 'q'; $modeName = 'Questionable' }
|
||||||
|
default { Write-Output 'Unknown mode, using default NSFW'; $rating = 'e'; $modeName = 'NSFW' }
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Pick the image protocol for this terminal ---
|
||||||
|
$logoType = $env:J621_LOGO_TYPE
|
||||||
|
if (-not $logoType) {
|
||||||
|
if ($env:WT_SESSION) { $logoType = 'sixel' }
|
||||||
|
elseif ($env:WEZTERM_PANE) { $logoType = 'iterm' }
|
||||||
|
}
|
||||||
|
$fetchImage = [bool]$logoType -or ($env:J621_ALWAYS_FETCH -eq '1')
|
||||||
|
$ffArgs = @(Get-J621FastfetchArgs)
|
||||||
|
|
||||||
|
if (-not $fetchImage) {
|
||||||
|
# Terminal without image support (e.g. VS Code): plain fastfetch.
|
||||||
|
& fastfetch @ffArgs
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Ask the API for a random image (JSON with a signed URL) ---
|
||||||
|
$apiUrl = "$base/api/random/?fastfetch=1&rating=$rating"
|
||||||
|
$curlArgs = @('-s', '-L', '--max-time', '20')
|
||||||
|
if ($token) { $curlArgs += @('-H', "Authorization: Token $token") }
|
||||||
|
$json = & curl.exe @curlArgs $apiUrl 2>$null | Out-String
|
||||||
|
|
||||||
|
$data = $null
|
||||||
|
try { $data = $json | ConvertFrom-Json } catch { $data = $null }
|
||||||
|
|
||||||
|
$imageUrl = $null; $jId = $null; $md5 = $null; $filename = $null; $detail = $null; $ext = 'img'
|
||||||
|
if ($data) {
|
||||||
|
if ($data.url) { $imageUrl = [string]$data.url }
|
||||||
|
if ($data.j_id) { $jId = [string]$data.j_id }
|
||||||
|
if ($data.md5) { $md5 = [string]$data.md5 }
|
||||||
|
if ($data.filename) { $filename = [string]$data.filename }
|
||||||
|
if ($data.detail) { $detail = [string]$data.detail }
|
||||||
|
if ($data.extension) { $ext = [string]$data.extension }
|
||||||
|
}
|
||||||
|
if ($ext -notmatch '^[A-Za-z0-9]+$') { $ext = 'img' }
|
||||||
|
|
||||||
|
if (-not $imageUrl) {
|
||||||
|
& fastfetch @ffArgs
|
||||||
|
if ($detail) { Write-Host (Format-J621Accent "No logo ($detail)") } else { Write-Host (Format-J621Accent 'No logo (API error)') }
|
||||||
|
Add-Content -LiteralPath $logFile -Value "$stamp No logo for rating=${rating}: $detail"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Download the signed image ---
|
||||||
|
$tempFile = Join-Path ([IO.Path]::GetTempPath()) ('j621_logo_' + [guid]::NewGuid().ToString('N') + '.' + $ext)
|
||||||
|
$downloadSuccess = $false
|
||||||
|
& curl.exe -s -L --max-time 60 -o $tempFile $imageUrl 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $tempFile) -and (Get-Item -LiteralPath $tempFile).Length -gt 0) { $downloadSuccess = $true }
|
||||||
|
|
||||||
|
if (-not $downloadSuccess) {
|
||||||
|
& fastfetch @ffArgs
|
||||||
|
Write-Host (Format-J621Accent 'No logo (download failed)')
|
||||||
|
Add-Content -LiteralPath $logFile -Value "$stamp Download failed for $jId"
|
||||||
|
Remove-Item -LiteralPath $tempFile -ErrorAction SilentlyContinue
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Accept only PNG, JPEG, GIF; anything else causes one more roll ---
|
||||||
|
$kind = Get-J621FileKind -Path $tempFile
|
||||||
|
if ($kind -eq 'unknown') {
|
||||||
|
if ($Depth -lt 3) {
|
||||||
|
Show-J621Greeting -Depth ($Depth + 1)
|
||||||
|
} else {
|
||||||
|
Write-Host (Format-J621Error 'Max recursion depth reached, skipping unsupported file type: unknown')
|
||||||
|
}
|
||||||
|
if ($Depth -eq 0) {
|
||||||
|
Write-Host (Format-J621Error ("Forked (Got unsupported type: unknown - $filename)"))
|
||||||
|
if ($md5) { Write-Output "Link: $webBase/view/$md5" }
|
||||||
|
}
|
||||||
|
Remove-Item -LiteralPath $tempFile -ErrorAction SilentlyContinue
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Display result ---
|
||||||
|
if ($logoType) {
|
||||||
|
$logoArgs = @('--logo-type', $logoType, '--logo', $tempFile, '--logo-width', $logoWidth)
|
||||||
|
if ($logoType -eq 'iterm') {
|
||||||
|
# Box + preserveAspectRatio: never stretched, whatever the aspect ratio
|
||||||
|
# (kitty-direct cannot do this through ConPTY: its payload has no rows).
|
||||||
|
$logoHeight = if ($env:J621_LOGO_HEIGHT) { $env:J621_LOGO_HEIGHT } else { '25' }
|
||||||
|
$logoArgs += @('--logo-height', $logoHeight, '--logo-preserve-aspect-ratio', 'true')
|
||||||
|
} elseif ($env:J621_LOGO_HEIGHT) {
|
||||||
|
$logoArgs += @('--logo-height', $env:J621_LOGO_HEIGHT)
|
||||||
|
}
|
||||||
|
& fastfetch @ffArgs @logoArgs
|
||||||
|
} else {
|
||||||
|
& fastfetch @ffArgs
|
||||||
|
}
|
||||||
|
Add-Content -LiteralPath $logFile -Value "$stamp Downloaded: $jId $filename [$modeName]"
|
||||||
|
if ($filename) {
|
||||||
|
Write-Host (Format-J621Accent "Logo from API: $filename ($modeName, $jId)")
|
||||||
|
Write-Output "Link: $webBase/detail/$jId"
|
||||||
|
} else {
|
||||||
|
Write-Host (Format-J621Accent "Logo from API ($modeName)")
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Keep only the last 1000 lines of the log ---
|
||||||
|
$lines = @(Get-Content -LiteralPath $logFile -Tail 1000)
|
||||||
|
Set-Content -LiteralPath $logFile -Value $lines
|
||||||
|
|
||||||
|
# --- Cleanup ---
|
||||||
|
Remove-Item -LiteralPath $tempFile -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# PowerShell greeting (J621 Windows port)
|
||||||
|
|
||||||
|
Shows a random J621 library image as your shell greeting, using fastfetch.
|
||||||
|
This is the Windows/PowerShell port of [`../fish_greeting/`](../fish_greeting/) -
|
||||||
|
same API, same modes, same logs, adapted to PowerShell 5.1+ and Windows
|
||||||
|
terminals.
|
||||||
|
|
||||||
|
## What changed from the fish version
|
||||||
|
|
||||||
|
| | fish | PowerShell (this port) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Entry point | `fish_greeting` function | `Show-J621Greeting` (loaded from your profile) |
|
||||||
|
| Endpoint | `GET /api/random/?fastfetch=1&rating=X` | same |
|
||||||
|
| Config | `~/.config/j621Greeting/config.fish` | `%APPDATA%\j621Greeting\config.ps1` |
|
||||||
|
| Mode file | `~/.config/fish/greeting_mode` | `%APPDATA%\j621Greeting\greeting_mode` (the file `gm-switch.exe` writes) |
|
||||||
|
| JSON parsing | `jq` / `python3` / grep | `ConvertFrom-Json` (built-in) |
|
||||||
|
| MIME detection | `file --mime-type` | PNG/JPEG/GIF magic bytes |
|
||||||
|
| GIFs | `gifsicle` + `kitten icat` | none: static first frame in Windows Terminal, animated in WezTerm (its `kitty-direct` plays the GIF) |
|
||||||
|
| fastfetch logo | `--logo-type kitty --logo ... --logo-width 35` | `sixel` in Windows Terminal, `iterm` (aspect-preserving box) in WezTerm, no image elsewhere |
|
||||||
|
| Logs | `~/.config/j621Logos/logs.log` | `%APPDATA%\j621Logos\logs.log` |
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd extras\powershell_greeting
|
||||||
|
.\install.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer copies `J621Greeting.ps1` into `%APPDATA%\j621Greeting\`, asks
|
||||||
|
for your API origin (and an optional scoped token), writes
|
||||||
|
`%APPDATA%\j621Greeting\config.ps1`, wires a marker block into your
|
||||||
|
PowerShell profile (it replaces the previous plain-`fastfetch` hook), checks
|
||||||
|
the tools it needs and probes the backend (`/health` plus a random roll). It
|
||||||
|
exits non-zero when the backend cannot be reached.
|
||||||
|
|
||||||
|
Non-interactive / re-install:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\install.ps1 -Url https://j621.example.ts.net -Token <api-token>
|
||||||
|
.\install.ps1 -NoPrompt # defaults, never asks
|
||||||
|
.\install.ps1 -Force # rewrite an existing config
|
||||||
|
.\install.ps1 -NoProfilePatch # do not touch your PowerShell profile
|
||||||
|
.\install.ps1 -Uninstall # remove the profile block + installed script
|
||||||
|
```
|
||||||
|
|
||||||
|
Test it manually any time with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
greeting
|
||||||
|
```
|
||||||
|
|
||||||
|
The profile block also installs a `greeting` helper that (re)sources
|
||||||
|
`J621Greeting.ps1` and runs `Show-J621Greeting`, so it works even when the
|
||||||
|
automatic interactive greeting was skipped.
|
||||||
|
|
||||||
|
Requirements: `fastfetch` and `curl.exe` (ships with Windows 10 1803+).
|
||||||
|
Image logos need Windows Terminal 1.22+ (sixel) or WezTerm with kitty
|
||||||
|
graphics enabled; other terminals fall back to a plain fastfetch.
|
||||||
|
|
||||||
|
## No token? That is fine
|
||||||
|
|
||||||
|
The greeting is meant to run **without** a token: it then behaves like a
|
||||||
|
guest of your instance - unsigned image links and only items that are
|
||||||
|
visible to guests. Adding a token to the config unlocks signed links and
|
||||||
|
items that are hidden from guests. The recommended value is a **scoped
|
||||||
|
greeting token**: open the app, go to *Account -> Shell tokens* (or `/tokens`),
|
||||||
|
create one and copy the `j621r_...` key - it only works with `/api/random/`,
|
||||||
|
so it is safe to keep in the config file.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`%APPDATA%\j621Greeting\config.ps1` is read on every run (environment
|
||||||
|
variables already set in your session win over it):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
if (-not $env:J621_BASE) { $env:J621_BASE = 'https://j621.rainbow-herring.ts.net' } # API origin
|
||||||
|
if (-not $env:J621_WEB) { $env:J621_WEB = 'https://j621.example.ts.net' } # link origin (split deploys)
|
||||||
|
if (-not $env:J621_TOKEN) { $env:J621_TOKEN = '<api token>' } # signed URLs + hidden items
|
||||||
|
if (-not $env:J621_FASTFETCH_CONFIG) { $env:J621_FASTFETCH_CONFIG = 'jake' } # fastfetch config name
|
||||||
|
```
|
||||||
|
|
||||||
|
Extra escapes (all optional):
|
||||||
|
|
||||||
|
- `J621_LOGO_TYPE` - force a fastfetch logo type (`sixel`, `kitty-direct`, ...)
|
||||||
|
- `J621_ALWAYS_FETCH=1` - fetch even in terminals without image support (prints the link, ASCII logo)
|
||||||
|
- `J621_LOGO_WIDTH` - logo width in columns (default 35)
|
||||||
|
- `J621_MODE_FILE` - mode file override (shared with `gm-switch`)
|
||||||
|
|
||||||
|
## Rating filters
|
||||||
|
|
||||||
|
| `greeting_mode` | Rating requested | Label |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `0` | `e` | NSFW |
|
||||||
|
| `1` | `s` | SFW |
|
||||||
|
| `2` (default) | `q` | Questionable |
|
||||||
|
|
||||||
|
The mode file is `%APPDATA%\j621Greeting\greeting_mode`. Use
|
||||||
|
[`../win-gm-switch/`](../win-gm-switch/) (or its Start Menu shortcuts) to
|
||||||
|
switch, or just edit the file.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **No image logo in Windows Terminal**: WT 1.22+ is required for sixel;
|
||||||
|
update with `winget upgrade Microsoft.WindowsTerminal`.
|
||||||
|
- **WezTerm**: kitty graphics must be on - `config.enable_kitty_graphics = true`
|
||||||
|
in `~/.wezterm.lua`. The default uses the `iterm` logo type with
|
||||||
|
`preserveAspectRatio`, which never stretches. If something looks off, try
|
||||||
|
`J621_LOGO_TYPE=kitty-direct`, `kitty`, or `sixel` (note: through ConPTY,
|
||||||
|
plain `kitty` falls back to ASCII and `kitty-direct` stretches tall images).
|
||||||
|
- **VS Code / other terminals**: no image protocol, so the greeting prints a
|
||||||
|
plain fastfetch on purpose (no fetch delay). `J621_ALWAYS_FETCH=1` still
|
||||||
|
fetches and prints the link.
|
||||||
|
- `No logo (No image matches those filters.)` - the library has no images
|
||||||
|
for that rating; try another mode or add files.
|
||||||
|
- `No logo (API error)` - check `J621_BASE` and that the deployment is up
|
||||||
|
(`https://<host>/health`).
|
||||||
|
- `No logo (download failed)` - the signed URL expired (they last 24 h) or
|
||||||
|
the item was deleted between the two requests; just run it again.
|
||||||
|
- **The greeting is slow** - the API and image fetch have `--max-time`
|
||||||
|
guards; a slow tailnet link is usually the cause.
|
||||||
|
- Log file: `%APPDATA%\j621Logos\logs.log` (only the last 1000 lines are kept).
|
||||||
|
|
||||||
|
## gm-switch
|
||||||
|
|
||||||
|
The Rust [`../win-gm-switch/`](../win-gm-switch/) tool writes the same mode
|
||||||
|
file, and on Linux it can replace the old C++ `gm-switch` (it writes
|
||||||
|
`~/.config/fish/greeting_mode` there, the same file the fish greeting reads).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Copy to %APPDATA%\j621Greeting\config.ps1 and adjust.
|
||||||
|
# This file runs every time the greeting starts; an environment variable that
|
||||||
|
# is already set in your session wins over the values here.
|
||||||
|
|
||||||
|
# API origin (no trailing slash needed).
|
||||||
|
if (-not $env:J621_BASE) { $env:J621_BASE = 'https://j621.rainbow-herring.ts.net' }
|
||||||
|
|
||||||
|
# Web origin used in the printed link. Only needed when the SPA is served
|
||||||
|
# from a different host than the API (split deployment).
|
||||||
|
# if (-not $env:J621_WEB) { $env:J621_WEB = 'https://j621-frontend.rainbow-herring.ts.net' }
|
||||||
|
|
||||||
|
# API token. Optional: gives you signed image URLs and access to items that
|
||||||
|
# are hidden from guests. The recommended value is a scoped greeting token
|
||||||
|
# (Account -> Shell tokens, or /tokens in the app): those only work with
|
||||||
|
# /api/random/, so they are safe to keep in this file. The full API token
|
||||||
|
# works too, but grants everything.
|
||||||
|
# if (-not $env:J621_TOKEN) { $env:J621_TOKEN = 'j621r_paste-your-token-here' }
|
||||||
|
|
||||||
|
# fastfetch config name used for the greeting logo.
|
||||||
|
# if (-not $env:J621_FASTFETCH_CONFIG) { $env:J621_FASTFETCH_CONFIG = 'jake' }
|
||||||
|
|
||||||
|
# Force a fastfetch logo type instead of the per-terminal default
|
||||||
|
# (Windows Terminal -> sixel, WezTerm -> iterm with preserveAspectRatio,
|
||||||
|
# others -> no image).
|
||||||
|
# if (-not $env:J621_LOGO_TYPE) { $env:J621_LOGO_TYPE = 'iterm' }
|
||||||
|
# if (-not $env:J621_LOGO_HEIGHT) { $env:J621_LOGO_HEIGHT = '25' }
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# Install the J621 PowerShell greeting:
|
||||||
|
# * copies J621Greeting.ps1 into %APPDATA%\j621Greeting\
|
||||||
|
# * writes %APPDATA%\j621Greeting\config.ps1 (asks for the API origin and an
|
||||||
|
# optional token, or takes them as parameters)
|
||||||
|
# * wires the greeting into your PowerShell profile (marker block, once)
|
||||||
|
# * checks the tools it needs and probes the configured backend
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# .\install.ps1
|
||||||
|
# .\install.ps1 -Url https://j621.example.ts.net -Token <api-token>
|
||||||
|
# .\install.ps1 -NoPrompt # never ask, keep/derive defaults
|
||||||
|
# .\install.ps1 -Force # rewrite an existing config
|
||||||
|
# .\install.ps1 -NoProfilePatch # do not touch your PowerShell profile
|
||||||
|
# .\install.ps1 -Uninstall # remove the profile block + installed script
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Url,
|
||||||
|
[string]$Token,
|
||||||
|
[switch]$NoPrompt,
|
||||||
|
[switch]$Force,
|
||||||
|
[switch]$NoProfilePatch,
|
||||||
|
[switch]$Uninstall
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$here = $PSScriptRoot
|
||||||
|
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
|
||||||
|
$configDir = Join-Path $env:APPDATA 'j621Greeting'
|
||||||
|
$configFile = Join-Path $configDir 'config.ps1'
|
||||||
|
$installedScript = Join-Path $configDir 'J621Greeting.ps1'
|
||||||
|
$defaultUrl = 'https://j621.rainbow-herring.ts.net'
|
||||||
|
$markerBegin = '# >>> J621 greeting >>>'
|
||||||
|
$markerEnd = '# <<< J621 greeting <<<'
|
||||||
|
$profilePath = $PROFILE
|
||||||
|
|
||||||
|
function Get-InstalledConfigValue {
|
||||||
|
param([string]$Name)
|
||||||
|
if (-not (Test-Path -LiteralPath $configFile)) { return $null }
|
||||||
|
$content = Get-Content -LiteralPath $configFile -Raw
|
||||||
|
# Ignore commented-out lines: the config ships an example token line, and
|
||||||
|
# matching it would make us probe with the placeholder as a real token.
|
||||||
|
$content = (@($content -split "`r?`n") | Where-Object { $_ -notmatch '^\s*#' }) -join "`n"
|
||||||
|
$rx = '\$env:' + $Name + ' = ''([^'']*)'''
|
||||||
|
if ($content -match $rx) { return $Matches[1] }
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Remove-ProfileBlock {
|
||||||
|
if (-not (Test-Path -LiteralPath $profilePath)) { return }
|
||||||
|
$lines = @(Get-Content -LiteralPath $profilePath)
|
||||||
|
if (-not ($lines -contains $markerBegin)) { return }
|
||||||
|
$out = New-Object System.Collections.Generic.List[string]
|
||||||
|
$inside = $false
|
||||||
|
foreach ($line in $lines) {
|
||||||
|
if ($line.Trim() -eq $markerBegin) { $inside = $true; continue }
|
||||||
|
if ($line.Trim() -eq $markerEnd) { $inside = $false; continue }
|
||||||
|
if (-not $inside) { $out.Add($line) }
|
||||||
|
}
|
||||||
|
while ($out.Count -gt 0 -and [string]::IsNullOrWhiteSpace($out[$out.Count - 1])) { $out.RemoveAt($out.Count - 1) }
|
||||||
|
Set-Content -LiteralPath $profilePath -Value ($out -join "`r`n") -Encoding ASCII
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Uninstall -----------------------------------------------------------------
|
||||||
|
if ($Uninstall) {
|
||||||
|
Remove-ProfileBlock
|
||||||
|
if (Test-Path -LiteralPath $installedScript) { Remove-Item -LiteralPath $installedScript -Force }
|
||||||
|
Write-Host "Removed the J621 greeting from the profile and $installedScript"
|
||||||
|
Write-Host "Your config and logs were left in place: $configDir"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Install the function ------------------------------------------------------
|
||||||
|
New-Item -ItemType Directory -Path $configDir -Force | Out-Null
|
||||||
|
$source = Join-Path $here 'J621Greeting.ps1'
|
||||||
|
if (-not (Test-Path -LiteralPath $source)) { throw "J621Greeting.ps1 not found next to this installer ($source)" }
|
||||||
|
Copy-Item -LiteralPath $source -Destination $installedScript -Force
|
||||||
|
Write-Host "Installed $installedScript"
|
||||||
|
|
||||||
|
# --- Configuration -------------------------------------------------------------
|
||||||
|
$writeConfig = $false
|
||||||
|
if (-not (Test-Path -LiteralPath $configFile)) {
|
||||||
|
$writeConfig = $true
|
||||||
|
} elseif ($Force -or $PSBoundParameters.ContainsKey('Url')) {
|
||||||
|
$writeConfig = $true
|
||||||
|
} else {
|
||||||
|
Write-Host "Keeping existing $configFile (use -Force to rewrite it)"
|
||||||
|
$current = Get-InstalledConfigValue -Name 'J621_BASE'
|
||||||
|
if ($current) { $Url = $current }
|
||||||
|
if (-not $Token) { $Token = Get-InstalledConfigValue -Name 'J621_TOKEN' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($writeConfig) {
|
||||||
|
if (-not $NoPrompt) {
|
||||||
|
if (-not $Url) {
|
||||||
|
$answer = Read-Host "API origin [$defaultUrl]"
|
||||||
|
if ($answer) { $Url = $answer }
|
||||||
|
}
|
||||||
|
if (-not $Token) {
|
||||||
|
$answer = Read-Host 'API token (optional, Enter to run as a guest)'
|
||||||
|
if ($answer) { $Token = $answer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $Url) { $Url = $defaultUrl }
|
||||||
|
$Url = $Url.Trim().TrimEnd('/').Replace("'", "")
|
||||||
|
if ($Token) { $Token = $Token.Trim().Replace("'", "") }
|
||||||
|
|
||||||
|
$lines = New-Object System.Collections.Generic.List[string]
|
||||||
|
$lines.Add('# J621 greeting configuration - written by install.ps1 on ' + (Get-Date -Format 'yyyy-MM-dd'))
|
||||||
|
$lines.Add('# Values here are defaults; an environment variable already set in your')
|
||||||
|
$lines.Add('# session wins over them.')
|
||||||
|
$lines.Add('if (-not $env:J621_BASE) { $env:J621_BASE = ''' + $Url + ''' }')
|
||||||
|
if ($Token) {
|
||||||
|
$lines.Add('if (-not $env:J621_TOKEN) { $env:J621_TOKEN = ''' + $Token + ''' }')
|
||||||
|
} else {
|
||||||
|
$lines.Add('# if (-not $env:J621_TOKEN) { $env:J621_TOKEN = ''paste-a-token-here'' }')
|
||||||
|
}
|
||||||
|
$lines.Add('# if (-not $env:J621_WEB) { $env:J621_WEB = ''' + $Url + ''' }')
|
||||||
|
$lines.Add('# if (-not $env:J621_FASTFETCH_CONFIG) { $env:J621_FASTFETCH_CONFIG = ''jake'' }')
|
||||||
|
Set-Content -LiteralPath $configFile -Value $lines -Encoding ASCII
|
||||||
|
Write-Host "Wrote $configFile"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Profile -------------------------------------------------------------------
|
||||||
|
if (-not $NoProfilePatch) {
|
||||||
|
$existing = ''
|
||||||
|
if (Test-Path -LiteralPath $profilePath) { $existing = Get-Content -LiteralPath $profilePath -Raw }
|
||||||
|
if ($existing -match [regex]::Escape($markerBegin)) {
|
||||||
|
Write-Host 'Profile already sources the greeting (marker present)'
|
||||||
|
} else {
|
||||||
|
$normalized = $existing -replace "`r`n", "`n"
|
||||||
|
$legacy = @(
|
||||||
|
'# Fastfetch greeting on interactive PowerShell start',
|
||||||
|
'if ([Environment]::UserInteractive -and -not [Console]::IsOutputRedirected) {',
|
||||||
|
' fastfetch',
|
||||||
|
'}'
|
||||||
|
) -join "`n"
|
||||||
|
if ($normalized.Contains($legacy)) {
|
||||||
|
$normalized = $normalized.Replace($legacy, '').TrimEnd() + "`n"
|
||||||
|
Write-Host 'Replaced the plain fastfetch block in your profile'
|
||||||
|
}
|
||||||
|
|
||||||
|
$block = @(
|
||||||
|
$markerBegin,
|
||||||
|
'# Random J621 library image as the fastfetch logo (extras/powershell_greeting).',
|
||||||
|
'$j621GreetingScript = "$env:APPDATA\j621Greeting\J621Greeting.ps1"',
|
||||||
|
'if (Test-Path -LiteralPath $j621GreetingScript) { . $j621GreetingScript }',
|
||||||
|
'# Run it again any time with: greeting',
|
||||||
|
'function greeting {',
|
||||||
|
' if (-not (Get-Command Show-J621Greeting -ErrorAction Ignore) -and (Test-Path -LiteralPath $j621GreetingScript)) { . $j621GreetingScript }',
|
||||||
|
' Show-J621Greeting',
|
||||||
|
'}',
|
||||||
|
'if ((Test-Path -LiteralPath $j621GreetingScript) -and [Environment]::UserInteractive -and -not [Console]::IsOutputRedirected) {',
|
||||||
|
' Show-J621Greeting',
|
||||||
|
'}',
|
||||||
|
$markerEnd
|
||||||
|
) -join "`r`n"
|
||||||
|
|
||||||
|
$profileDir = Split-Path -Parent $profilePath
|
||||||
|
if (-not (Test-Path -LiteralPath $profileDir)) { New-Item -ItemType Directory -Path $profileDir -Force | Out-Null }
|
||||||
|
Set-Content -LiteralPath $profilePath -Value ($normalized.TrimEnd() + "`r`n`r`n" + $block + "`r`n") -Encoding ASCII
|
||||||
|
Write-Host "Added the J621 greeting to $profilePath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Dependencies --------------------------------------------------------------
|
||||||
|
$problems = 0
|
||||||
|
foreach ($tool in 'fastfetch', 'curl.exe') {
|
||||||
|
if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) {
|
||||||
|
$problems = 1
|
||||||
|
Write-Host "Missing required tool: $tool"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not ($env:WT_SESSION -or $env:WEZTERM_PANE)) {
|
||||||
|
Write-Host 'Note: image logos need Windows Terminal 1.22+ (sixel) or WezTerm (kitty graphics);'
|
||||||
|
Write-Host 'other terminals fall back to a plain fastfetch.'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Probe the configured backend -----------------------------------------------
|
||||||
|
Write-Host ''
|
||||||
|
$probeUrl = $Url
|
||||||
|
$probeToken = $Token
|
||||||
|
if (-not $probeUrl) { $probeUrl = $defaultUrl }
|
||||||
|
Write-Host "Checking $probeUrl ..."
|
||||||
|
$health = & curl.exe -s -m 10 ($probeUrl + '/health') 2>$null | Out-String
|
||||||
|
if ($health -match '"status"\s*:\s*"ok"') {
|
||||||
|
Write-Host ' backend: ok'
|
||||||
|
$probeArgs = @('-s', '-m', '10')
|
||||||
|
if ($probeToken) { $probeArgs += @('-H', "Authorization: Token $probeToken") }
|
||||||
|
$probe = & curl.exe @probeArgs ($probeUrl + '/api/random/?fastfetch=1') 2>$null | Out-String
|
||||||
|
$detail = $null
|
||||||
|
try {
|
||||||
|
$parsed = $probe | ConvertFrom-Json
|
||||||
|
if ($parsed.detail) { $detail = [string]$parsed.detail }
|
||||||
|
} catch { }
|
||||||
|
if ($detail) {
|
||||||
|
Write-Host " random: $detail"
|
||||||
|
if (-not $probeToken) { Write-Host ' (a token would also let you see items that are hidden from guests)' }
|
||||||
|
} else {
|
||||||
|
Write-Host ' random: ok'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$problems = 1
|
||||||
|
Write-Host " backend did not answer at $probeUrl/health"
|
||||||
|
Write-Host " got: $health"
|
||||||
|
Write-Host " Fix J621_BASE in $configFile (or pass -Url) and run again."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Wrap up --------------------------------------------------------------------
|
||||||
|
Write-Host ''
|
||||||
|
if ($problems -eq 0) {
|
||||||
|
Write-Host 'Done. Test it now with: Show-J621Greeting'
|
||||||
|
} else {
|
||||||
|
Write-Host 'Finished with problems above; the greeting will report them too.'
|
||||||
|
}
|
||||||
|
Write-Host ('Rating mode lives in ' + (Join-Path $configDir 'greeting_mode') + ' (0=NSFW, 1=SFW, 2=Questionable).')
|
||||||
|
Write-Host 'gm-switch.exe and its Start Menu shortcuts write that same file.'
|
||||||
|
exit $problems
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+7
@@ -0,0 +1,7 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gm-switch"
|
||||||
|
version = "0.1.0"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "gm-switch"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Set the J621 shell greeting rating mode (NSFW / SFW / Questionable)"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# win-gm-switch (Rust)
|
||||||
|
|
||||||
|
Rust port of the old `gm-switch.cpp` from
|
||||||
|
`J621-Django/extras/fish_greeting system/`: a tiny CLI that sets the J621
|
||||||
|
shell greeting rating mode (0 = NSFW, 1 = SFW, 2 = Questionable).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
gm-switch --mode 0 # NSFW (explicit)
|
||||||
|
gm-switch --mode 1 # SFW (safe)
|
||||||
|
gm-switch --mode 2 # Questionable
|
||||||
|
gm-switch --status # print the current mode
|
||||||
|
```
|
||||||
|
|
||||||
|
`--path <file>` overrides the mode file (also via the `J621_MODE_FILE`
|
||||||
|
environment variable). Invalid input prints the same usage/errors as the C++
|
||||||
|
tool and exits 1.
|
||||||
|
|
||||||
|
Mode file locations:
|
||||||
|
|
||||||
|
- **Windows**: `%APPDATA%\j621Greeting\greeting_mode` - read by
|
||||||
|
[`../powershell_greeting/`](../powershell_greeting/).
|
||||||
|
- **Linux/macOS**: `~/.config/fish/greeting_mode` - read by
|
||||||
|
[`../fish_greeting/`](../fish_greeting/), so this binary can stand in for
|
||||||
|
the C++ tool there.
|
||||||
|
|
||||||
|
## Install (Windows)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd extras\win-gm-switch
|
||||||
|
.\install.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer builds with `cargo build --release`, installs `gm-switch.exe`
|
||||||
|
to `%LOCALAPPDATA%\J621\bin`, adds that folder to your user PATH (new
|
||||||
|
terminals pick it up) and creates Start Menu shortcuts under **J621
|
||||||
|
Greeting** (NSFW / SFW / Questionable). Each shortcut opens a small console
|
||||||
|
showing the confirmation; close it with `exit` or the window button.
|
||||||
|
|
||||||
|
Uninstall:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\install.ps1 -Uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build and test without installing
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo build --release # -> target\release\gm-switch.exe
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Zero runtime dependencies (std only). The `target\` folder is gitignored.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Build and install gm-switch:
|
||||||
|
# * cargo build --release (needs the Rust toolchain on PATH)
|
||||||
|
# * copies gm-switch.exe to %LOCALAPPDATA%\J621\bin
|
||||||
|
# * adds that folder to your user PATH (if missing)
|
||||||
|
# * creates Start Menu shortcuts for each mode under "J621 Greeting"
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# .\install.ps1
|
||||||
|
# .\install.ps1 -Uninstall
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[switch]$Uninstall
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$here = $PSScriptRoot
|
||||||
|
if (-not $here) { $here = Split-Path -Parent $MyInvocation.MyCommand.Path }
|
||||||
|
$binDir = Join-Path $env:LOCALAPPDATA 'J621\bin'
|
||||||
|
$exe = Join-Path $binDir 'gm-switch.exe'
|
||||||
|
$startMenuDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\J621 Greeting'
|
||||||
|
|
||||||
|
if ($Uninstall) {
|
||||||
|
if (Test-Path -LiteralPath $startMenuDir) {
|
||||||
|
Remove-Item -LiteralPath $startMenuDir -Recurse -Force
|
||||||
|
Write-Host "Removed $startMenuDir"
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $exe) {
|
||||||
|
Remove-Item -LiteralPath $exe -Force
|
||||||
|
Write-Host "Removed $exe"
|
||||||
|
}
|
||||||
|
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||||
|
if ($userPath) {
|
||||||
|
$parts = @($userPath -split ';' | Where-Object { $_ -and $_ -ne $binDir })
|
||||||
|
[Environment]::SetEnvironmentVariable('Path', ($parts -join ';'), 'User')
|
||||||
|
Write-Host "Removed $binDir from your user PATH"
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Build ---------------------------------------------------------------------
|
||||||
|
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Host 'cargo was not found on PATH - install the Rust toolchain (https://rustup.rs) first.'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Push-Location $here
|
||||||
|
try {
|
||||||
|
Write-Host 'Building gm-switch (cargo build --release) ...'
|
||||||
|
# cargo writes progress to stderr; keep that from tripping $ErrorActionPreference.
|
||||||
|
$previousPreference = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
cargo build --release 2>&1 | Write-Host
|
||||||
|
$buildExit = $LASTEXITCODE
|
||||||
|
$ErrorActionPreference = $previousPreference
|
||||||
|
if ($buildExit -ne 0) {
|
||||||
|
Write-Host 'cargo build failed.'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
|
$built = Join-Path $here 'target\release\gm-switch.exe'
|
||||||
|
if (-not (Test-Path -LiteralPath $built)) {
|
||||||
|
Write-Host "Build output not found: $built"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Install -------------------------------------------------------------------
|
||||||
|
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
|
||||||
|
Copy-Item -LiteralPath $built -Destination $exe -Force
|
||||||
|
Write-Host "Installed $exe"
|
||||||
|
|
||||||
|
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||||
|
if (-not $userPath) { $userPath = '' }
|
||||||
|
if (-not (($userPath -split ';') -contains $binDir)) {
|
||||||
|
$newPath = ($userPath.TrimEnd(';') + ';' + $binDir).TrimStart(';')
|
||||||
|
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
|
||||||
|
Write-Host "Added $binDir to your user PATH (open a new terminal to pick it up)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Shortcuts ------------------------------------------------------------------
|
||||||
|
New-Item -ItemType Directory -Path $startMenuDir -Force | Out-Null
|
||||||
|
$modes = @(
|
||||||
|
@{ Value = '0'; Name = 'NSFW' },
|
||||||
|
@{ Value = '1'; Name = 'SFW' },
|
||||||
|
@{ Value = '2'; Name = 'Questionable' }
|
||||||
|
)
|
||||||
|
$shell = New-Object -ComObject WScript.Shell
|
||||||
|
foreach ($m in $modes) {
|
||||||
|
$lnkPath = Join-Path $startMenuDir ('J621 Greeting - ' + $m.Name + '.lnk')
|
||||||
|
$shortcut = $shell.CreateShortcut($lnkPath)
|
||||||
|
$shortcut.TargetPath = "$env:SystemRoot\System32\cmd.exe"
|
||||||
|
$shortcut.Arguments = '/k ""' + $exe + '" --mode ' + $m.Value + '"'
|
||||||
|
$shortcut.WorkingDirectory = $env:USERPROFILE
|
||||||
|
$shortcut.Description = 'Set the J621 shell greeting to ' + $m.Name + ' mode'
|
||||||
|
$shortcut.Save()
|
||||||
|
Write-Host ('Created shortcut: ' + $lnkPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host 'Done. Try: gm-switch --status'
|
||||||
|
Write-Host 'The Start Menu shortcuts open a small console showing the confirmation.'
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
//! gm-switch - set the J621 shell greeting rating mode.
|
||||||
|
//!
|
||||||
|
//! Rust port of the old gm-switch.cpp (J621-Django/extras/fish_greeting
|
||||||
|
//! system). On Windows it writes the mode file used by the PowerShell
|
||||||
|
//! greeting (%APPDATA%\j621Greeting\greeting_mode); on Linux/macOS it writes
|
||||||
|
//! ~/.config/fish/greeting_mode, so it can stand in for the C++ tool there.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! gm-switch --mode <0|1|2> [--path <file>]
|
||||||
|
//! gm-switch --status [--path <file>]
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::exit;
|
||||||
|
|
||||||
|
const USAGE: &str = "\
|
||||||
|
Usage: gm-switch --mode <0|1|2>
|
||||||
|
--mode 0 Set to NSFW mode (explicit)
|
||||||
|
--mode 1 Set to SFW mode (safe)
|
||||||
|
--mode 2 Set to Questionable mode
|
||||||
|
--status Print the current greeting mode
|
||||||
|
--path <file> Override the mode file location
|
||||||
|
";
|
||||||
|
|
||||||
|
fn mode_name(mode: &str) -> Option<&'static str> {
|
||||||
|
match mode {
|
||||||
|
"0" => Some("NSFW"),
|
||||||
|
"1" => Some("SFW"),
|
||||||
|
"2" => Some("Questionable"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_mode_file() -> PathBuf {
|
||||||
|
if let Ok(path) = env::var("J621_MODE_FILE") {
|
||||||
|
if !path.is_empty() {
|
||||||
|
return PathBuf::from(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Ok(appdata) = env::var("APPDATA") {
|
||||||
|
return Path::new(&appdata).join("j621Greeting").join("greeting_mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
if let Ok(home) = env::var("HOME") {
|
||||||
|
return Path::new(&home)
|
||||||
|
.join(".config")
|
||||||
|
.join("fish")
|
||||||
|
.join("greeting_mode");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from("greeting_mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = env::args().skip(1).collect();
|
||||||
|
|
||||||
|
let mut mode: Option<String> = None;
|
||||||
|
let mut status = false;
|
||||||
|
let mut path_override: Option<String> = None;
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < args.len() {
|
||||||
|
match args[i].as_str() {
|
||||||
|
"--mode" => {
|
||||||
|
if i + 1 < args.len() {
|
||||||
|
mode = Some(args[i + 1].clone());
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
eprintln!("Error: --mode requires a value");
|
||||||
|
print!("{USAGE}");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--status" => {
|
||||||
|
status = true;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
"--path" => {
|
||||||
|
if i + 1 < args.len() {
|
||||||
|
path_override = Some(args[i + 1].clone());
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
eprintln!("Error: --path requires a value");
|
||||||
|
print!("{USAGE}");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"-h" | "--help" => {
|
||||||
|
print!("{USAGE}");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
eprintln!("Error: unknown argument: {other}");
|
||||||
|
print!("{USAGE}");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode_file = match &path_override {
|
||||||
|
Some(p) => PathBuf::from(p),
|
||||||
|
None => default_mode_file(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if status {
|
||||||
|
match fs::read_to_string(&mode_file) {
|
||||||
|
Ok(content) => {
|
||||||
|
let value = content.trim().to_string();
|
||||||
|
match mode_name(&value) {
|
||||||
|
Some(name) => println!("Current greeting mode: {name} ({value})"),
|
||||||
|
None => println!(
|
||||||
|
"Current greeting mode: {value} (unrecognized - the greeting falls back to NSFW)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
println!("Current greeting mode: 2 (Questionable, default - no mode file yet)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = match mode {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
print!("{USAGE}");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let name = match mode_name(&value) {
|
||||||
|
Some(name) => name,
|
||||||
|
None => {
|
||||||
|
eprintln!("Error: Mode must be 0, 1, or 2");
|
||||||
|
print!("{USAGE}");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(parent) = mode_file.parent() {
|
||||||
|
if !parent.as_os_str().is_empty() {
|
||||||
|
if let Err(err) = fs::create_dir_all(parent) {
|
||||||
|
eprintln!("Error: {err}");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = fs::write(&mode_file, &value) {
|
||||||
|
eprintln!(
|
||||||
|
"Error: Could not open file for writing: {}",
|
||||||
|
mode_file.display()
|
||||||
|
);
|
||||||
|
eprintln!("Error: {err}");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Successfully set greeting mode to: {name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_modes_to_names() {
|
||||||
|
assert_eq!(mode_name("0"), Some("NSFW"));
|
||||||
|
assert_eq!(mode_name("1"), Some("SFW"));
|
||||||
|
assert_eq!(mode_name("2"), Some("Questionable"));
|
||||||
|
assert_eq!(mode_name("x"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_and_reads_mode_file() {
|
||||||
|
let dir = env::temp_dir().join("gm-switch-test");
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
let file = dir.join("greeting_mode");
|
||||||
|
fs::write(&file, "1").unwrap();
|
||||||
|
assert_eq!(fs::read_to_string(&file).unwrap().trim(), "1");
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,7 +113,7 @@ export function AppShell() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<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">
|
<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">
|
<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">
|
<span className="rounded bg-ctp-mauve px-2 py-1 font-mono text-xs font-bold tracking-wide text-ctp-crust">
|
||||||
J621
|
J621
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ const ratingLabels: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function MediaCard({ item }: { item: MediaItem }) {
|
export function MediaCard({ item }: { item: MediaItem }) {
|
||||||
const preview = apiUrl(
|
// The thumbnail endpoint now builds real 480px previews for images too, so
|
||||||
item.kind === "video" ? item.thumbnail_url : item.raw_url,
|
// the grid no longer pulls full-size originals.
|
||||||
);
|
const preview = apiUrl(item.thumbnail_url);
|
||||||
const rating = item.display_rating;
|
const rating = item.display_rating;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
@@ -148,48 +148,65 @@ function BlacklistCloudPanel() {
|
|||||||
q.state.data?.status === "building" ? 2000 : false,
|
q.state.data?.status === "building" ? 2000 : false,
|
||||||
});
|
});
|
||||||
const cloud = query.data;
|
const cloud = query.data;
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
<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">
|
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||||
Blacklisted tags
|
Blacklisted tags
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-ctp-overlay0">
|
<span className="flex items-center gap-1.5 font-mono text-[11px] text-ctp-overlay0">
|
||||||
{cloud?.source === "user"
|
{cloud ? `${cloud.blacklist_count} entries` : "…"}
|
||||||
? "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" ? (
|
{cloud?.status === "building" ? (
|
||||||
<span className="ml-2 inline-flex items-center gap-1.5">
|
<Spinner className="h-3 w-3" />
|
||||||
<Spinner className="h-3 w-3" /> building…
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
{expanded ? (
|
||||||
</div>
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{query.isPending ? (
|
{expanded ? (
|
||||||
<div className="mt-3 flex justify-center py-4">
|
<>
|
||||||
<Spinner className="h-4 w-4" />
|
<p className="mt-2 text-xs text-ctp-overlay0">
|
||||||
</div>
|
{cloud?.source === "user"
|
||||||
) : cloud && cloud.tags.length > 0 ? (
|
? "From your e621 blacklist"
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
: "From e621's anonymous default blacklist"}
|
||||||
{cloud.tags.map(([tag, count]) => (
|
{cloud ? ` · ${cloud.posts} feed post(s) scanned` : ""}
|
||||||
<span
|
{cloud?.computed_at
|
||||||
key={tag}
|
? ` · computed ${formatDate(cloud.computed_at)}`
|
||||||
className="rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
|
: ""}
|
||||||
>
|
</p>
|
||||||
{tag} ({count})
|
{query.isPending ? (
|
||||||
</span>
|
<div className="mt-3 flex justify-center py-4">
|
||||||
))}
|
<Spinner className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : cloud && cloud.tags.length > 0 ? (
|
||||||
<p className="mt-3 text-xs text-ctp-overlay0">
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
No blacklisted tags in your feeds.
|
{cloud.tags.map(([tag, count]) => (
|
||||||
</p>
|
<span
|
||||||
)}
|
key={tag}
|
||||||
|
className="rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
|
||||||
|
>
|
||||||
|
{tag} ({count})
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-xs text-ctp-overlay0">
|
||||||
|
No blacklisted tags in your feeds.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ export default function OnlinePage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [draft, setDraft] = useState<string | null>(null);
|
const [draft, setDraft] = useState<string | null>(null);
|
||||||
const [blacklistDraft, setBlacklistDraft] = useState("");
|
const [blacklistDraft, setBlacklistDraft] = useState("");
|
||||||
|
const [showBlacklist, setShowBlacklist] = useState(false);
|
||||||
const tagInput = draft ?? tags;
|
const tagInput = draft ?? tags;
|
||||||
const zoom = useUi((state) => state.zoom);
|
const zoom = useUi((state) => state.zoom);
|
||||||
const perPage = useUi((state) => state.e621PerPage);
|
const perPage = useUi((state) => state.e621PerPage);
|
||||||
@@ -271,73 +272,91 @@ export default function OnlinePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
<button
|
||||||
Your blacklist
|
type="button"
|
||||||
</span>
|
onClick={() => setShowBlacklist((value) => !value)}
|
||||||
{credentials?.configured ? (
|
title={showBlacklist ? "Hide the blacklist" : "Show the blacklist"}
|
||||||
<>
|
className="flex items-center justify-between gap-2 text-left"
|
||||||
{blacklistEntries.length > 0 ? (
|
>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||||
{blacklistEntries.map(({ line, index }) => (
|
Your blacklist
|
||||||
<span
|
</span>
|
||||||
key={`${line}-${index}`}
|
<span className="flex items-center gap-1 font-mono text-[11px] text-ctp-overlay0">
|
||||||
className="inline-flex items-center gap-1 rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
|
{blacklistEntries.length} tag
|
||||||
>
|
{blacklistEntries.length === 1 ? "" : "s"}
|
||||||
{line}
|
{showBlacklist ? (
|
||||||
<button
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
type="button"
|
|
||||||
title={`Remove ${line}`}
|
|
||||||
disabled={blacklistMutation.isPending}
|
|
||||||
onClick={() => removeBlacklistEntry(index)}
|
|
||||||
className="text-ctp-red/70 transition hover:text-ctp-red disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-ctp-overlay0">
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
No blacklisted tags.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
<form
|
</span>
|
||||||
onSubmit={(event) => {
|
</button>
|
||||||
event.preventDefault();
|
{showBlacklist ? (
|
||||||
addBlacklistEntry();
|
credentials?.configured ? (
|
||||||
}}
|
<>
|
||||||
className="flex gap-2"
|
{blacklistEntries.length > 0 ? (
|
||||||
>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<input
|
{blacklistEntries.map(({ line, index }) => (
|
||||||
className={cn(inputClass, "font-mono")}
|
<span
|
||||||
placeholder="Add a tag…"
|
key={`${line}-${index}`}
|
||||||
value={blacklistDraft}
|
className="inline-flex items-center gap-1 rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
|
||||||
onChange={(event) => setBlacklistDraft(event.target.value)}
|
>
|
||||||
/>
|
{line}
|
||||||
<Button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
variant="secondary"
|
title={`Remove ${line}`}
|
||||||
disabled={!blacklistDraft.trim() || blacklistMutation.isPending}
|
disabled={blacklistMutation.isPending}
|
||||||
|
onClick={() => removeBlacklistEntry(index)}
|
||||||
|
className="text-ctp-red/70 transition hover:text-ctp-red disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-ctp-overlay0">
|
||||||
|
No blacklisted tags.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
addBlacklistEntry();
|
||||||
|
}}
|
||||||
|
className="flex gap-2"
|
||||||
>
|
>
|
||||||
{blacklistMutation.isPending ? (
|
<input
|
||||||
<Spinner className="h-3.5 w-3.5" />
|
className={cn(inputClass, "font-mono")}
|
||||||
) : (
|
placeholder="Add a tag…"
|
||||||
"Add"
|
value={blacklistDraft}
|
||||||
)}
|
onChange={(event) => setBlacklistDraft(event.target.value)}
|
||||||
</Button>
|
/>
|
||||||
</form>
|
<Button
|
||||||
<p className="text-[11px] text-ctp-overlay0">
|
type="submit"
|
||||||
Saved to your e621 account.
|
variant="secondary"
|
||||||
|
disabled={!blacklistDraft.trim() || blacklistMutation.isPending}
|
||||||
|
>
|
||||||
|
{blacklistMutation.isPending ? (
|
||||||
|
<Spinner className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
"Add"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="text-[11px] text-ctp-overlay0">
|
||||||
|
Saved to your e621 account.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-ctp-overlay0">
|
||||||
|
<Link to="/account" className="text-ctp-blue hover:underline">
|
||||||
|
Add e621 credentials
|
||||||
|
</Link>{" "}
|
||||||
|
to manage your blacklist.
|
||||||
</p>
|
</p>
|
||||||
</>
|
)
|
||||||
) : (
|
) : null}
|
||||||
<p className="text-xs text-ctp-overlay0">
|
|
||||||
<Link to="/account" className="text-ctp-blue hover:underline">
|
|
||||||
Add e621 credentials
|
|
||||||
</Link>{" "}
|
|
||||||
to manage your blacklist.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -388,6 +388,28 @@ export interface TempUpload {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One upload the pipeline indexed while the page was watching. */
|
||||||
|
export interface UploadCompletion {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
j_id: string;
|
||||||
|
resolution: string;
|
||||||
|
post_id: number | null;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response of POST /api/uploads/ — a pending row or an instant completion. */
|
||||||
|
export interface UploadStagingResult {
|
||||||
|
temp_id: string;
|
||||||
|
original_filename: string;
|
||||||
|
status: "pending" | "visual_match" | "completed" | "error";
|
||||||
|
resolution: string;
|
||||||
|
library_j_id: string | null;
|
||||||
|
preview_url: string | null;
|
||||||
|
e621_post_id?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Progress of the server-side staged-upload pipeline. */
|
/** Progress of the server-side staged-upload pipeline. */
|
||||||
export interface UploadStatus {
|
export interface UploadStatus {
|
||||||
status: "idle" | "running" | "paused" | "error";
|
status: "idle" | "running" | "paused" | "error";
|
||||||
@@ -400,6 +422,8 @@ export interface UploadStatus {
|
|||||||
error: string;
|
error: string;
|
||||||
outstanding: number;
|
outstanding: number;
|
||||||
waiting: { md5: number; visual: number; iqdb: number };
|
waiting: { md5: number; visual: number; iqdb: number };
|
||||||
|
/** Session notification feed: indexed uploads, newest first. */
|
||||||
|
recent_completions: UploadCompletion[];
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user