Compare commits
84
Commits
ext-1.0.9
...
725bf15f88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
725bf15f88 | ||
|
|
bc4eba636d | ||
|
|
dbc4e8b0c6 | ||
|
|
b14818303c | ||
|
|
08418d54a3 | ||
|
|
b1bd2531ad | ||
|
|
389afe2f7b | ||
|
|
b979062dd7 | ||
|
|
573228b9da | ||
|
|
d044e93bdb | ||
|
|
ed2b1adc2e | ||
|
|
5e1996e77f | ||
|
|
98b56330d0 | ||
|
|
6959e1220c | ||
|
|
2529b516e6 | ||
|
|
8f1ac0c96a | ||
|
|
5fd171a544 | ||
|
|
62583791d8 | ||
|
|
0a5bbe81dc | ||
|
|
6663e06aa6 | ||
|
|
63e0a423d7 | ||
|
|
499720d87e | ||
|
|
5e72076298 | ||
|
|
e21c9fdd34 | ||
|
|
6b3ec98fa8 | ||
|
|
bd24f4e876 | ||
|
|
1a941e900b | ||
|
|
2e01242381 | ||
|
|
41f2bec3af | ||
|
|
0e15c44c51 | ||
|
|
d38585ed94 | ||
|
|
a3071a7549 | ||
|
|
b6b9fd8287 | ||
|
|
bce894ba24 | ||
|
|
5771fd5770 | ||
|
|
6d98dfc0ec | ||
|
|
b3989d0224 | ||
|
|
cd0b0ff04a | ||
|
|
454eb3f973 | ||
|
|
7e065fed70 | ||
|
|
dee93faa37 | ||
|
|
d9aa5aa832 | ||
|
|
6c76f08b69 | ||
|
|
fb2c4d5b80 | ||
|
|
609bc82acc | ||
|
|
7a20c55441 | ||
|
|
0c43fa3eb2 | ||
|
|
cf06c81db9 | ||
|
|
7b1019ba82 | ||
|
|
0db38cc111 | ||
|
|
a7e626a67a | ||
|
|
fe48e77821 | ||
|
|
9eb946b21b | ||
|
|
5447a40e97 | ||
|
|
239b1ed8d9 | ||
|
|
cd5444e3ae | ||
|
|
5a0e1bbd03 | ||
|
|
1ac448d881 | ||
|
|
bfc5135f19 | ||
|
|
89155478a8 | ||
|
|
516521e7b0 | ||
|
|
ddf896078c | ||
|
|
2e0f8f8c61 | ||
|
|
2ce467e347 | ||
|
|
39cf81aea6 | ||
|
|
0d204e6837 | ||
|
|
11dd324f89 | ||
|
|
1c6452e10e | ||
|
|
597b91d29b | ||
|
|
8300029741 | ||
|
|
f9111c06a7 | ||
|
|
c37a180c3c | ||
|
|
8214afee1e | ||
|
|
306de50f61 | ||
|
|
b5b437ca80 | ||
|
|
57e52433d0 | ||
|
|
ce0dac3524 | ||
|
|
ec66ea5f83 | ||
|
|
e92570a31e | ||
|
|
a2d1ed935d | ||
|
|
05df51b749 | ||
|
|
099e1e664c | ||
|
|
c87f8a1bb3 | ||
|
|
666b3a2ec8 |
@@ -0,0 +1,370 @@
|
||||
|
||||
# TEMPORARY — milestone 328 steps 1-2. Delete once the baseline is stamped.
|
||||
#
|
||||
# Squashing 87 alembic revisions into one baseline has exactly one dangerous
|
||||
# failure: the generated baseline does not reproduce the schema the chain
|
||||
# produced, `alembic stamp` writes a version string anyway (it validates
|
||||
# NOTHING), and the divergence surfaces on the next real migration against the
|
||||
# operator's live data.
|
||||
#
|
||||
# So this workflow does the comparison in CI, where a pgvector Postgres already
|
||||
# gets built from the chain on every integration run, and nothing is at risk.
|
||||
# It answers one question: does `upgrade head` on the collapsed chain produce a
|
||||
# byte-identical schema to `upgrade head` on the 87-revision chain?
|
||||
#
|
||||
# The chain is read from git rather than from the working tree, so this keeps
|
||||
# working AFTER the old revisions are deleted — `chain_ref` names a commit that
|
||||
# still has them. That is what makes this the proof for step 1 and the
|
||||
# pre-flight for step 2, rather than a one-shot script.
|
||||
#
|
||||
# While the chain is still present it also autogenerates a candidate baseline
|
||||
# from the models and prints it. That is a starting point, NOT the answer:
|
||||
# autogenerate reads SQLAlchemy metadata, and three things here do not live
|
||||
# there —
|
||||
# * CREATE EXTENSION vector (0001)
|
||||
# * CREATE EXTENSION tsm_system_rows (0004)
|
||||
# * the HNSW index on image_record.siglip_embedding, which is raw SQL
|
||||
# because alembic's create_index cannot express `USING hnsw (...)` (0036)
|
||||
# plus any CHECK constraint or server_default that a migration added without
|
||||
# the model declaring it. Those must be hand-added, and the diff below is what
|
||||
# proves none were missed.
|
||||
name: Alembic baseline
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
chain_ref:
|
||||
description: 'Commit/tag carrying the full chain; blank = this ref (use a pinned commit only AFTER the collapse)'
|
||||
type: string
|
||||
default: ''
|
||||
mode:
|
||||
description: 'chain = compare against this tree''s migrations; models = compare against a schema built from the MODELS'
|
||||
type: string
|
||||
default: 'chain'
|
||||
|
||||
jobs:
|
||||
compare:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
env:
|
||||
DB_USER: fabledcurator
|
||||
DB_PASSWORD: ci_integration
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: fabledcurator_test
|
||||
SECRET_KEY: ci_integration_placeholder
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: fabledcurator
|
||||
POSTGRES_PASSWORD: ci_integration
|
||||
POSTGRES_DB: fabledcurator_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U fabledcurator"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history is the point: `chain_ref` is read out of git, so a
|
||||
# shallow clone would not have the revisions to compare against.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve the Postgres service and install deps
|
||||
run: |
|
||||
set -eux
|
||||
# Same service-IP dance as ci.yml's integration job; see the long
|
||||
# comment there for why the job name must stay separator-free.
|
||||
PG=$(docker ps --filter "name=compare" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
||||
test -n "$PG"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
test -n "$PG_IP"
|
||||
echo "PG_CONTAINER=$PG" >> "$GITHUB_ENV"
|
||||
echo "DB_HOST=$PG_IP" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 60); do
|
||||
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv pip install --system -r requirements.txt
|
||||
else
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
# DB 1: the 87-revision chain, read out of git at `chain_ref`.
|
||||
#
|
||||
# A git worktree rather than a checkout, so the current tree — which is
|
||||
# what we are testing — is left completely alone.
|
||||
- name: Build the schema the OLD chain produces
|
||||
env:
|
||||
CHAIN_REF: ${{ github.event.inputs.chain_ref }}
|
||||
THIS_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -eux
|
||||
docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_chain
|
||||
# Blank means "the chain in this ref", which is what you want while
|
||||
# the chain is still intact — comparing the models against a PINNED
|
||||
# older commit reports every migration written since as a difference.
|
||||
# Pin it only after the collapse, when the tree no longer has them.
|
||||
git worktree add /tmp/chain "${CHAIN_REF:-$THIS_SHA}"
|
||||
ls /tmp/chain/alembic/versions/*.py | wc -l
|
||||
cd /tmp/chain
|
||||
DB_NAME=fc_chain alembic upgrade head
|
||||
cd -
|
||||
docker exec "$PG_CONTAINER" pg_dump -U fabledcurator --schema-only \
|
||||
--no-owner --no-privileges -d fc_chain > chain.sql
|
||||
wc -l chain.sql
|
||||
# Emit the dump itself, checksummed, for local analysis. Reconciling
|
||||
# the models against the deployed schema (#3275) needs the ACTUAL
|
||||
# schema, not an inference from a diff — parsing table context out of
|
||||
# unified-diff hunks drops every table whose CREATE TABLE line falls
|
||||
# outside a hunk, which silently under-reports.
|
||||
#
|
||||
# base64 + sha256 for the same reason as the candidate: a plain cat
|
||||
# of a file this size was truncated mid-line by the runner with the
|
||||
# step still green (run 4964).
|
||||
set +x
|
||||
B64=$(base64 -w 120 chain.sql)
|
||||
echo "===== BEGIN CHAIN SCHEMA (base64) ====="
|
||||
echo "$B64"
|
||||
echo "===== END CHAIN SCHEMA ====="
|
||||
echo "chain-sha256: $(sha256sum chain.sql | cut -d' ' -f1)"
|
||||
echo "chain-bytes: $(wc -c < chain.sql)"
|
||||
set -x
|
||||
|
||||
# A candidate baseline, autogenerated from the models against an EMPTY
|
||||
# database so every table shows up as a create. Printed for a human to
|
||||
# finish — it will be missing the three raw-SQL items named at the top.
|
||||
#
|
||||
# Gated on the TREE, not on a workflow input. A `type: boolean` input
|
||||
# read back as `github.event.inputs.generate == 'true'` silently
|
||||
# evaluated false on this runner (run 4960 skipped this step entirely
|
||||
# with no diagnostic) — the same `github.event.inputs` typing quirk
|
||||
# build.yml already works around. The file count is the real question
|
||||
# anyway: there is nothing to generate once the chain is collapsed.
|
||||
- name: Autogenerate a candidate baseline
|
||||
run: |
|
||||
set -eux
|
||||
if [ "$(ls alembic/versions/*.py | wc -l)" -le 1 ]; then
|
||||
echo "already collapsed — nothing to generate"
|
||||
exit 0
|
||||
fi
|
||||
docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_gen
|
||||
# Hide the existing revisions so alembic sees an empty history and
|
||||
# emits the whole schema rather than a delta.
|
||||
mkdir -p /tmp/versions_held
|
||||
mv alembic/versions/*.py /tmp/versions_held/ 2>/dev/null || true
|
||||
DB_NAME=fc_gen alembic revision --autogenerate -m "baseline" || true
|
||||
# Printed rather than uploaded: ci-requirements.md records that this
|
||||
# runner cannot do actions/upload-artifact@v4+, and the repo dropped
|
||||
# the action entirely in 2026-05, so the job log is the retrieval
|
||||
# channel actually proven here.
|
||||
#
|
||||
# base64, not the raw file. A plain `cat` of the ~33KB candidate was
|
||||
# TRUNCATED MID-LINE by the runner on run 4964 — it stopped inside
|
||||
# `sa.Column('mime', sa.String(length=128)` and carried straight on
|
||||
# to the next traced command, with the step still green. A silent
|
||||
# cut in the middle of a schema definition is the worst possible
|
||||
# failure here, because the truncated text still looks like a
|
||||
# plausible file.
|
||||
#
|
||||
# base64 at a fixed narrow width gives many short lines instead of
|
||||
# few long ones, and — the actual point — a checksum and a line
|
||||
# count that make truncation DETECTABLE rather than invisible.
|
||||
set +x
|
||||
F=$(ls alembic/versions/*.py | head -1)
|
||||
B64=$(base64 -w 120 "$F")
|
||||
echo "===== BEGIN CANDIDATE BASELINE (base64) ====="
|
||||
echo "$B64"
|
||||
echo "===== END CANDIDATE BASELINE ====="
|
||||
echo "candidate-sha256: $(sha256sum "$F" | cut -d' ' -f1)"
|
||||
echo "candidate-bytes: $(wc -c < "$F")"
|
||||
echo "candidate-b64-lines: $(echo "$B64" | wc -l)"
|
||||
set -x
|
||||
mkdir -p /tmp/candidate
|
||||
cp alembic/versions/*.py /tmp/candidate/
|
||||
# Put the tree back exactly as it was; this job never mutates state.
|
||||
rm -f alembic/versions/*.py
|
||||
mv /tmp/versions_held/*.py alembic/versions/ 2>/dev/null || true
|
||||
|
||||
# DB 2: what the CURRENT tree produces.
|
||||
#
|
||||
# `mode: models` applies the candidate autogenerated from the MODELS
|
||||
# instead, which is what answers "do the models describe the schema?" —
|
||||
# the question #3275 exists because nobody had ever asked it. Under that
|
||||
# mode a clean diff means autogenerate is trustworthy again.
|
||||
#
|
||||
# The two extensions are created by hand first. They are database
|
||||
# objects, not table metadata, so no model can carry them and their
|
||||
# absence is not a model defect — it is simply outside what this
|
||||
# comparison is asking about.
|
||||
- name: Build the schema the CURRENT tree produces
|
||||
env:
|
||||
MODE: ${{ github.event.inputs.mode }}
|
||||
run: |
|
||||
set -eux
|
||||
docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_base
|
||||
if [ "${MODE:-chain}" = "models" ]; then
|
||||
docker exec "$PG_CONTAINER" psql -U fabledcurator -d fc_base \
|
||||
-c "CREATE EXTENSION IF NOT EXISTS vector" \
|
||||
-c "CREATE EXTENSION IF NOT EXISTS tsm_system_rows"
|
||||
mkdir -p /tmp/held
|
||||
mv alembic/versions/*.py /tmp/held/
|
||||
cp /tmp/candidate/*.py alembic/versions/
|
||||
# Autogenerate EMITS pgvector.sqlalchemy.vector.VECTOR(...) without
|
||||
# importing it, so the file it writes cannot run:
|
||||
# NameError: name 'pgvector' is not defined
|
||||
# Observed on run 4988, which is the proof rather than the theory.
|
||||
# This is a defect in the GENERATOR, not in the models, so it is
|
||||
# repaired here rather than counted as a schema difference — the
|
||||
# comparison is about whether the models describe the schema.
|
||||
sed -i '0,/^import sqlalchemy as sa$/s//import sqlalchemy as sa\nimport pgvector.sqlalchemy.vector/' alembic/versions/*.py
|
||||
grep -n 'import pgvector' alembic/versions/*.py
|
||||
# Second generator defect, same class as the missing import.
|
||||
#
|
||||
# base.py's naming convention includes %(constraint_name)s for ck,
|
||||
# which — unlike uq/fk/ix — means the convention is applied even to
|
||||
# a CheckConstraint that HAS a name. So a model declaring
|
||||
# name="singleton" correctly becomes ck_ml_settings_singleton in
|
||||
# the metadata. Autogenerate then writes that RENDERED name into
|
||||
# the migration, and running the migration applies the convention a
|
||||
# SECOND time: ck_ml_settings_ck_ml_settings_singleton.
|
||||
#
|
||||
# That is round-tripping damage done by the generator, not a claim
|
||||
# the models make, so it is repaired here rather than counted as a
|
||||
# schema difference. Undone by removing the ck_<table>_ prefix the
|
||||
# convention will re-add — the exact inverse, and it only fires on
|
||||
# a name that actually carries its own table's prefix.
|
||||
python3 - alembic/versions/*.py <<'PYEOF'
|
||||
import re, sys
|
||||
|
||||
table = None
|
||||
for path in sys.argv[1:]:
|
||||
out = []
|
||||
for line in open(path):
|
||||
m = re.search(r"op\.create_table\(\s*[\"']([A-Za-z0-9_]+)[\"']", line)
|
||||
if m:
|
||||
table = m.group(1)
|
||||
if table and "CheckConstraint" in line:
|
||||
prefix = f"ck_{table}_"
|
||||
line = re.sub(
|
||||
r"(name=[\"'])" + re.escape(prefix),
|
||||
r"\1",
|
||||
line,
|
||||
)
|
||||
out.append(line)
|
||||
open(path, "w").writelines(out)
|
||||
PYEOF
|
||||
grep -n 'CheckConstraint' alembic/versions/*.py || true
|
||||
ls alembic/versions/*.py
|
||||
DB_NAME=fc_base alembic upgrade head
|
||||
rm -f alembic/versions/*.py
|
||||
mv /tmp/held/*.py alembic/versions/
|
||||
else
|
||||
ls alembic/versions/*.py | wc -l
|
||||
DB_NAME=fc_base alembic upgrade head
|
||||
fi
|
||||
docker exec "$PG_CONTAINER" pg_dump -U fabledcurator --schema-only \
|
||||
--no-owner --no-privileges -d fc_base > baseline.sql
|
||||
wc -l baseline.sql
|
||||
|
||||
# The verdict.
|
||||
#
|
||||
# pg_dump orders dumpable objects by name within type, not by creation
|
||||
# order, so two schemas built by different routes are directly
|
||||
# comparable. Normalisation is deliberately minimal, because a filter
|
||||
# that hides a real difference is the one way this check passes when it
|
||||
# should fail — blank lines, SQL comments, trailing whitespace, and:
|
||||
#
|
||||
# \restrict / \unrestrict — a per-invocation RANDOM NONCE that newer
|
||||
# pg_dump emits to fence the dump against injection during restore. It
|
||||
# differs on every run by construction, so it is noise by definition,
|
||||
# not a schema difference. Measured on run 4960, the control: two dumps
|
||||
# of the SAME schema came back 1123 lines each and differed on exactly
|
||||
# these two lines and nothing else. That control is what licenses this
|
||||
# filter — it was observed to be the only false positive, rather than
|
||||
# assumed to be one.
|
||||
# Column ORDER inside a CREATE TABLE is compared separately from column
|
||||
# CONTENT, and only content is fatal.
|
||||
#
|
||||
# A table built by 87 migrations has its columns in ADD COLUMN order; the
|
||||
# same table built in one shot has them in declaration order. That is a
|
||||
# real and permanent difference which no baseline can erase — the
|
||||
# operator's existing database keeps chain order forever, a fresh install
|
||||
# gets model order — so a check that fails on it would never pass and
|
||||
# would teach nothing. FC reaches every column through the ORM by name,
|
||||
# and `SELECT *` ordering is not depended on anywhere.
|
||||
#
|
||||
# So the second pass SORTS the column lines within each CREATE TABLE
|
||||
# rather than DELETING them. That distinction is the whole point: sorting
|
||||
# cannot hide a column that exists on one side only, or one whose type,
|
||||
# nullability or default differs — those still land in the diff. A filter
|
||||
# could have hidden all three.
|
||||
#
|
||||
# Both diffs are reported. The ordered one is informational; the
|
||||
# order-insensitive one is the verdict.
|
||||
- name: Diff
|
||||
run: |
|
||||
set -eu
|
||||
norm() {
|
||||
grep -vE '^\s*(--|$)' "$1" \
|
||||
| grep -vE '^\\(un)?restrict ' \
|
||||
| sed 's/[[:space:]]*$//'
|
||||
}
|
||||
norm chain.sql > a.txt
|
||||
norm baseline.sql > b.txt
|
||||
echo "normalised: chain=$(wc -l < a.txt) lines, current=$(wc -l < b.txt) lines"
|
||||
|
||||
sort_table_columns() {
|
||||
python3 - "$1" <<'PYEOF'
|
||||
import re, sys
|
||||
|
||||
lines = open(sys.argv[1]).read().splitlines()
|
||||
out, block = [], None
|
||||
for line in lines:
|
||||
if block is not None:
|
||||
# ');' on its own closes the CREATE TABLE body.
|
||||
if line.strip() == ");":
|
||||
out.extend(sorted(block))
|
||||
out.append(line)
|
||||
block = None
|
||||
else:
|
||||
# Drop the list comma before sorting. Only the LAST
|
||||
# column lacks one, so keeping it would make every
|
||||
# reordering look like a content change as well — the
|
||||
# comma is punctuation, and carries no schema meaning.
|
||||
block.append(line.rstrip().rstrip(","))
|
||||
continue
|
||||
out.append(line)
|
||||
if re.match(r"CREATE TABLE .*\($", line):
|
||||
block = []
|
||||
if block is not None: # unterminated body: emit it rather than drop it
|
||||
out.extend(block)
|
||||
print("\n".join(out))
|
||||
PYEOF
|
||||
}
|
||||
sort_table_columns a.txt > a.sorted.txt
|
||||
sort_table_columns b.txt > b.sorted.txt
|
||||
test "$(wc -l < a.sorted.txt)" = "$(wc -l < a.txt)"
|
||||
test "$(wc -l < b.sorted.txt)" = "$(wc -l < b.txt)"
|
||||
|
||||
if diff -u a.txt b.txt > schema.diff; then
|
||||
echo "ORDERED DIFF: identical, column order included."
|
||||
else
|
||||
echo "ORDERED DIFF: $(grep -cE '^[+-]' schema.diff) changed lines (informational):"
|
||||
cat schema.diff
|
||||
fi
|
||||
echo
|
||||
echo "================================================================"
|
||||
echo
|
||||
if diff -u a.sorted.txt b.sorted.txt > sorted.diff; then
|
||||
echo "SCHEMAS MATCH — every difference above is column ORDER alone."
|
||||
else
|
||||
echo "SCHEMAS DIFFER — $(grep -cE '^[+-]' sorted.diff) changed lines that are NOT ordering:"
|
||||
cat sorted.diff
|
||||
echo
|
||||
echo "The baseline is wrong, not the database. Do not stamp."
|
||||
exit 1
|
||||
fi
|
||||
+1434
-125
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ name: CI
|
||||
|
||||
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
|
||||
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
|
||||
# - extension-version: the derived version resolves and is a shape AMO takes.
|
||||
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
|
||||
# - frontend-build: vitest unit + vite build.
|
||||
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
|
||||
@@ -34,13 +35,92 @@ jobs:
|
||||
- name: Ruff lint
|
||||
# agent/ included so the GPU-agent is linted before its image is built
|
||||
# (build.yml only `docker build`s it — this is where it gets checked).
|
||||
run: ruff check backend/ tests/ alembic/ agent/
|
||||
# scripts/ likewise: release_notes.py runs only on a tag push, so a
|
||||
# syntax or import error there would otherwise surface at the one
|
||||
# moment nobody wants to debug a workflow.
|
||||
run: ruff check backend/ tests/ alembic/ agent/ scripts/
|
||||
- name: Agent syntax check
|
||||
# The agent's runtime deps (torch/transformers/ultralytics) aren't in the
|
||||
# CI image, so we can't import it — but compileall parses every module,
|
||||
# catching syntax errors before the image build.
|
||||
run: python -m compileall -q agent/fc_agent
|
||||
|
||||
# The extension version is DERIVED, not hand-maintained (milestone 271 step
|
||||
# 4): build.yml computes it from the commit TIME of the newest packaged
|
||||
# extension change and stamps it into manifest.json / package.json at build
|
||||
# time. The guard that used to live here — "packaged files changed but nobody
|
||||
# bumped the version" — was therefore checking a fact that had stopped
|
||||
# existing. Worse than useless: it would have failed this lane on every real
|
||||
# extension change, demanding a bump that decides nothing. Retired 2026-08-27
|
||||
# rather than left running beside the new mechanism (rule 22).
|
||||
#
|
||||
# Two things are still worth asserting, and this is the only lane that can:
|
||||
# the extension.yml suite runs on node:24-slim, which is exactly why
|
||||
# version.spec.js sticks to packaging.sh's git-free subcommands.
|
||||
# 1. the derivation actually resolves on this commit
|
||||
# 2. the derived string is one AMO will accept, checked against Mozilla's
|
||||
# own published grammar rather than a loose "digits and dots"
|
||||
#
|
||||
# The MAJOR.MINOR-agreement check that used to be (2) is gone with milestone
|
||||
# 318 step 8: the committed version no longer seeds anything, so there is no
|
||||
# hand-set part left for the two files to disagree about.
|
||||
#
|
||||
# Deliberately NOT checked here: that the derived value beats what has already
|
||||
# been signed. That guard belongs in build.yml, where it compares against the
|
||||
# real ext-* releases. Comparing against origin/main here would be wrong —
|
||||
# dev legitimately derives a LOWER value whenever main is ahead on the
|
||||
# extension, and a lane that fails for being behind is a lane people learn to
|
||||
# ignore.
|
||||
extension-version:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The derivation needs real history: a depth-1 clone sees one commit
|
||||
# and produces a wrong, too-low value RATHER THAN FAILING. Checking
|
||||
# that here is half the point of the lane.
|
||||
fetch-depth: 0
|
||||
- name: Extension version derives cleanly
|
||||
run: |
|
||||
set -eu
|
||||
# busybox sh on the act_runner — no bashisms (family rule).
|
||||
VERSION=$(sh extension/scripts/packaging.sh version)
|
||||
echo "derived: $VERSION"
|
||||
|
||||
# Mozilla's published grammar for AMO, transcribed verbatim from
|
||||
# MDN's manifest.json/version page:
|
||||
#
|
||||
# ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
|
||||
#
|
||||
# Not the looser `^[0-9]+(\.[0-9]+)*$` this lane used to carry. That
|
||||
# one passes `2026.08.29.0201`, which AMO REJECTS — a segment must be
|
||||
# the single digit 0 or start 1-9 — and it also passes five segments,
|
||||
# where AMO allows four. Both would surface as a failed sign with the
|
||||
# version already burned: AMO 409s on re-signing, so a rejected value
|
||||
# cannot be reclaimed and cannot be reused. This lane is the cheap
|
||||
# place to find out. (#3138, milestone 318 step 8.)
|
||||
if ! echo "$VERSION" | grep -qE '^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$'; then
|
||||
echo "ERROR: derived version '$VERSION' is not a version AMO accepts."
|
||||
echo "AMO's grammar: ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$"
|
||||
echo "Most likely cause: a zero-padded segment (08, 0201). The rest"
|
||||
echo "of the family pads; the extension must not — see packaging.sh."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ...and the shape this project actually derives. AMO would happily
|
||||
# take `1.0.3500147` too, so the grammar check alone would not notice
|
||||
# a regression to the pre-318 shape — which orders BELOW everything
|
||||
# signed since, and is unrecoverable once Firefox has the higher one.
|
||||
if ! echo "$VERSION" | grep -qE '^20[0-9][0-9]\.[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,4}$'; then
|
||||
echo "ERROR: derived version '$VERSION' is not YYYY.M.D.HHMM."
|
||||
echo "Rule 148's CalVer is what build.yml signs; the old"
|
||||
echo "1.0.<minutes> shape would order below every ext-2026.* release."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: derived version $VERSION"
|
||||
|
||||
backend-lint-and-test:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
@@ -52,6 +132,13 @@ jobs:
|
||||
SECRET_KEY: ci_unit_test_placeholder
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history for tests/test_artifact_identity.py, which derives
|
||||
# each artifact's revision to check the identity scheme. On a
|
||||
# depth-1 clone that derivation either fails or returns the tip sha
|
||||
# — so the lane would go green while asserting nothing, which is
|
||||
# the one outcome worse than a red one.
|
||||
fetch-depth: 0
|
||||
|
||||
# Cache step removed 2026-05-26: act_runner's cache backend has been
|
||||
# broken on this homelab runner since 2026-05-15 (first as request-
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: extension
|
||||
# Lint-only workflow. The sign-and-publish dance moved into build.yml's
|
||||
# Lint + unit tests. The sign-and-publish dance moved into build.yml's
|
||||
# `sign-extension` job (2026-05-25) — `:latest` now always bundles the XPI
|
||||
# because sign-extension runs as a build-web dependency in the SAME workflow,
|
||||
# eliminating the prior race between build.yml and a separate extension.yml.
|
||||
@@ -10,10 +10,20 @@ on:
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- '.forgejo/workflows/extension.yml'
|
||||
# test/version.spec.js asserts things ABOUT the other two workflows —
|
||||
# that neither inlines the packaged-file set, and that build.yml derives
|
||||
# the shipped version rather than reading it out of the repo. A
|
||||
# workflow-only edit can therefore break this suite, so it has to trigger
|
||||
# it. build.yml joined the list at milestone 271 step 5, when the spec
|
||||
# started asserting against it.
|
||||
- '.forgejo/workflows/ci.yml'
|
||||
- '.forgejo/workflows/build.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'extension/**'
|
||||
- '.forgejo/workflows/ci.yml'
|
||||
- '.forgejo/workflows/build.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -23,7 +33,55 @@ jobs:
|
||||
image: node:24-bookworm-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install web-ext
|
||||
run: cd extension && npm install --no-save --no-audit --no-fund
|
||||
# Not --no-save: vitest and web-ext are both real devDependencies now,
|
||||
# and the suite needs vitest resolvable from node_modules.
|
||||
- name: Install dev dependencies
|
||||
run: cd extension && npm install --no-audit --no-fund
|
||||
- name: Lint
|
||||
run: cd extension && npm run lint
|
||||
# Pure-logic specs over lib/url.js and lib/platforms.js plus manifest /
|
||||
# package version-consistency checks. No browser, no network.
|
||||
- name: Unit tests
|
||||
run: cd extension && npm run test:unit
|
||||
|
||||
# Everything else about packaging is asserted against our own declaration
|
||||
# of what ships. This is the only check that asks web-ext what it ACTUALLY
|
||||
# put in the archive. Until now that was an unverified assumption about
|
||||
# glob semantics — and a fragile one: `test/**` reaches web-ext intact
|
||||
# only because callers `set -f` first, so losing that quoting would
|
||||
# silently start shipping dev files with no other signal.
|
||||
- name: Verify XPI contents
|
||||
run: |
|
||||
set -eu
|
||||
command -v unzip >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq unzip; }
|
||||
cd extension
|
||||
npm run build
|
||||
ZIP=$(ls web-ext-artifacts/*.zip | head -1)
|
||||
echo "=== packaged entries in $ZIP ==="
|
||||
unzip -Z1 "$ZIP" | sort
|
||||
echo "=== end ==="
|
||||
ENTRIES=$(unzip -Z1 "$ZIP")
|
||||
fail=0
|
||||
# Must NOT ship: repo infrastructure with no business in a user's browser.
|
||||
for pat in 'test/' 'scripts/' 'vitest.config.js' 'package.json' 'package-lock.json' 'README.md' 'node_modules/' 'web-ext-artifacts/'; do
|
||||
if echo "$ENTRIES" | grep -q "^$pat"; then
|
||||
echo "ERROR: '$pat' was packaged into the XPI but must not be"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
# Must ship: if an exclusion pattern ever over-matches, the extension
|
||||
# breaks at runtime rather than at build time, so assert presence too.
|
||||
for req in 'manifest.json' 'lib/url.js' 'lib/api.js' 'lib/platforms.js' 'lib/cookies.js'; do
|
||||
if ! echo "$ENTRIES" | grep -q "^$req$"; then
|
||||
echo "ERROR: '$req' is missing from the XPI"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
for dir in 'background/' 'popup/' 'options/' 'content/' 'icons/'; do
|
||||
if ! echo "$ENTRIES" | grep -q "^$dir"; then
|
||||
echo "ERROR: nothing from '$dir' was packaged"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
[ "$fail" -eq 0 ] || exit 1
|
||||
echo "XPI contents verified."
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Release
|
||||
|
||||
# A `v*` tag publishes a changelog. It does NOT build anything.
|
||||
#
|
||||
# Milestone 318 step 2 removed the tag trigger from build.yml: by the time
|
||||
# anyone tags a commit, `main` has already built and published it, and a
|
||||
# rebuild would re-push `:c-<sha>` — which rule 145 forbids even when the
|
||||
# source matches, since image configs carry timestamps and "same source" does
|
||||
# not mean "same manifest". That left the tag with no consequence at all.
|
||||
#
|
||||
# This is the consequence it has instead. Step 6 put the derived version in the
|
||||
# Settings footer, so an operator can say WHICH build they are running; this
|
||||
# says what is IN it that was not in the one they ran last month. Both halves
|
||||
# of one question (note #3127 §5).
|
||||
#
|
||||
# Nothing here runs on a schedule and nothing auto-tags on merge. Release tags
|
||||
# are bookmarks — cut one when you will want to point at that day by name,
|
||||
# otherwise don't (note #3127 §0). FC went twelve weeks between v26.06.04.0 and
|
||||
# the next one and nothing was wrong. A schedule would turn an optional
|
||||
# bookmark back into ceremony, which is the thing this milestone is removing.
|
||||
#
|
||||
# Cutting the tag is an explicit operator action under rule 2 ("`main` — never
|
||||
# without explicit request", which since 2026-08-28 covers PR, merge and tag
|
||||
# alike). This lane only decides what happens once they do.
|
||||
#
|
||||
# Requires repo secret RELEASE_TOKEN with the `write:release` scope — the same
|
||||
# PAT build.yml uses for the ext-<version> XPI asset cache.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
# So a release body can be regenerated after the fact — the publisher PATCHes
|
||||
# an existing release rather than falling through on a conflict, so re-running
|
||||
# this on a tag rewrites the body instead of silently keeping the first one
|
||||
# (note #3127 §6.7).
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to (re)publish notes for'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
changelog:
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Load-bearing twice over: the previous release is found by walking
|
||||
# ancestry back through the tag graph, and the cross-check against
|
||||
# the derived web version calls artifacts.sh, which reads commit
|
||||
# times. A shallow clone would find no previous tag and emit the
|
||||
# entire history as the changelog — plausible-looking and wrong.
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||
|
||||
# The `:c-<sha>` rollback refs are only real if `main` built this commit.
|
||||
# The script checks that against origin/main and downgrades the claim to
|
||||
# "unverified" when it cannot resolve one; fetching it here means that
|
||||
# downgrade stays an actual signal instead of firing on every release.
|
||||
- name: Make main's history resolvable
|
||||
run: git fetch --no-tags --quiet origin +main:refs/remotes/origin/main || true
|
||||
|
||||
# TAG goes through the environment, not through `${{ }}` inside the
|
||||
# run block. The value is operator-supplied, and an expression expanded
|
||||
# into a shell line is expanded BEFORE the shell sees it — there is no
|
||||
# quoting that makes that safe. On a tag push it is empty and the script
|
||||
# falls back to GITHUB_REF.
|
||||
- name: Publish the derived changelog
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
TAG: ${{ github.event.inputs.tag }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ -n "${TAG:-}" ]; then
|
||||
python3 scripts/release_notes.py "$TAG"
|
||||
else
|
||||
python3 scripts/release_notes.py
|
||||
fi
|
||||
@@ -0,0 +1,77 @@
|
||||
# Contributing
|
||||
|
||||
FabledCurator is developed by a single maintainer for their own use, and
|
||||
published because it may be useful to others. That shapes what contribution
|
||||
looks like here.
|
||||
|
||||
**Issues are welcome** — bug reports, and questions about running it, are
|
||||
genuinely useful and often the fastest way to find out that something is
|
||||
broken outside the one environment it was built in.
|
||||
|
||||
**Open an issue before writing a pull request.** Not as a formality: the
|
||||
project has opinions that are not obvious from the code, and it is unpleasant
|
||||
for everyone when a finished patch turns out to conflict with one. A short
|
||||
issue first costs you nothing and may save you an evening.
|
||||
|
||||
**Contributions are licensed under the AGPL-3.0**, like the rest of the
|
||||
project. By submitting one you agree it ships under that licence. There is no
|
||||
CLA and no copyright assignment.
|
||||
|
||||
## Running it for development
|
||||
|
||||
```bash
|
||||
docker compose up -d # UI on http://localhost:8080
|
||||
```
|
||||
|
||||
The dev override (`docker-compose.override.yml`) is auto-merged and builds the
|
||||
app images locally from source, so this needs no `.env` and no registry
|
||||
access. Postgres and Redis ports are exposed on the host.
|
||||
|
||||
## What CI checks
|
||||
|
||||
Every push runs these, and they are the definition of done for a change:
|
||||
|
||||
```bash
|
||||
ruff check backend/ tests/ alembic/ agent/ scripts/ # lint (and import order)
|
||||
pytest tests/ -m "not integration" # backend unit tests
|
||||
pytest tests/ -m integration # needs pgvector + redis
|
||||
cd frontend && npm run test:unit && npm run build # frontend
|
||||
```
|
||||
|
||||
The integration lane builds its schema by running the real migrations
|
||||
(`alembic upgrade head`), never from ORM metadata — so a migration that does
|
||||
not apply cleanly fails CI rather than being discovered later.
|
||||
|
||||
Note for the linter: ruff's isort runs with `order-by-type`, which sorts
|
||||
ALL-CAPS names ahead of CamelCase. `from sqlalchemy import JSON, DateTime, ...`
|
||||
is correct; putting `JSON` alphabetically between `Integer` and `String` is
|
||||
not. This catches people out.
|
||||
|
||||
## Database changes
|
||||
|
||||
The ORM models and the migration chain must agree. This is enforced, and it is
|
||||
enforced because they silently diverged for a long time and nobody noticed
|
||||
until they were compared: the models were missing indexes, defaults and
|
||||
uniqueness guarantees that only ever existed inside a migration, which made
|
||||
`alembic revision --autogenerate` actively unsafe to run.
|
||||
|
||||
So: if you change a model, write the migration; if you write a migration,
|
||||
change the model to match. Both, in the same commit.
|
||||
|
||||
Adding a value to a CHECK-constrained column means swapping the constraint in
|
||||
the same change — the constraint is not documentation, and a new value without
|
||||
it fails at insert time.
|
||||
|
||||
## Branch model
|
||||
|
||||
`dev` is where work happens. `main` is production and is only reached by a
|
||||
merge from `dev`, never pushed to directly. If you are sending a pull request,
|
||||
target `dev`.
|
||||
|
||||
## Style
|
||||
|
||||
Match the surrounding code. The one convention worth stating explicitly is
|
||||
that comments here explain *why*, especially where a choice looks wrong at a
|
||||
glance — a comment recording which migration a constraint came from, or why a
|
||||
default is a `text()` rather than a string, is the kind that has repeatedly
|
||||
turned out to be worth its space.
|
||||
+23
@@ -47,6 +47,29 @@ RUN chmod +x entrypoint.sh
|
||||
|
||||
COPY --from=frontend-builder /build/dist ./frontend/dist
|
||||
|
||||
# Which channel this image belongs to — `dev` or `main` (milestone 271 step 7).
|
||||
# build.yml passes it; /api/extension/manifest reports it beside the version so
|
||||
# an operator can tell which channel an install came from without the channel
|
||||
# ever touching the version string.
|
||||
#
|
||||
# Empty by default, deliberately: a locally-built image then reports NO channel
|
||||
# rather than claiming to be one, and the manifest omits the field entirely —
|
||||
# indistinguishable from an image built before the field existed, which is
|
||||
# exactly the shape every reader already has to handle.
|
||||
#
|
||||
# Declared LAST on purpose. An ARG/ENV invalidates every layer below it, and
|
||||
# these are the values that differ between builds of otherwise identical
|
||||
# source — put them any earlier and the two channels could never share a
|
||||
# cached pip install.
|
||||
#
|
||||
# FC_VERSION is what the instance reports about itself in the UI. Since
|
||||
# milestone 318 stopped publishing version image tags, that self-report is
|
||||
# the only answer to "which build is this?" — nothing else names it.
|
||||
ARG FC_CHANNEL=""
|
||||
ENV FC_CHANNEL=${FC_CHANNEL}
|
||||
ARG FC_VERSION=""
|
||||
ENV FC_VERSION=${FC_VERSION}
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -6,7 +6,50 @@ Combines what was [ImageRepo](https://git.fabledsword.com/bvandeusen/ImageRepo)
|
||||
|
||||
## Status
|
||||
|
||||
Pre-v1. Not yet functional.
|
||||
In production. `main` is continuously deployed — every merge to `main` builds
|
||||
and publishes `:latest` images, so whatever is on `main` is what is running.
|
||||
Day-to-day work happens on `dev`, which publishes `:dev` images.
|
||||
|
||||
## Versions and tags
|
||||
|
||||
Three image tags exist, and no others:
|
||||
|
||||
| Tag | Branch | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `:latest` | `main` | Production. Moves on every merge. |
|
||||
| `:c-<sha>` | `main` | Immutable — the rollback unit, all three images together. |
|
||||
| `:dev` | `dev` | The rolling test channel. Moves on every push. |
|
||||
|
||||
There are deliberately **no version tags**. Nothing pins one, and a per-build
|
||||
name nobody reads is upkeep for a model FC does not run (family rule 145; the
|
||||
reasoning is note #3127 §5). Rolling back is `docker pull …:c-<sha>`.
|
||||
|
||||
Each artifact still has a version, derived rather than chosen: the commit time
|
||||
of the newest change to that artifact's *own* shipped files, as
|
||||
`YYYY.MM.DD.HHMM` UTC (rule 148). Four artifacts, four independent versions —
|
||||
a push touching only `agent/` re-versions the agent and leaves web and ml
|
||||
alone, and CI skips the builds whose content did not move.
|
||||
|
||||
Because no registry name carries it, the running instance's own report is the
|
||||
only answer to "which build is this?". The foot of Settings shows
|
||||
`FabledCurator 2026.08.29.0201 · dev`, and `/api/health` returns the same two
|
||||
fields.
|
||||
|
||||
Release tags are optional bookmarks — FC went twelve weeks without one and
|
||||
nothing was wrong. Pushing `v<version>` publishes a Forgejo release listing the
|
||||
commits since the previous tag; it builds no image.
|
||||
|
||||
## What's in here
|
||||
|
||||
Five deployable pieces, built by `.forgejo/workflows/build.yml`:
|
||||
|
||||
| Piece | Built from | Image | Role |
|
||||
| --- | --- | --- | --- |
|
||||
| **Web / workers** | `Dockerfile` | `fabledcurator` | Quart API + the built Vue SPA in one image. `entrypoint.sh` picks the role: `web`, `worker`, `scheduler`. The `maintenance-long` service is a second `worker` pinned to the long-running maintenance queue. |
|
||||
| **ML worker** | `Dockerfile.ml` | `fabledcurator-ml` | Same app, plus `requirements-ml.txt` — tagging and embedding models that run in-container. |
|
||||
| **GPU agent** | `agent/Dockerfile` | `fabledcurator-agent` | Optional desktop-GPU worker (`agent/`). Leases jobs over **HTTP only** — never touches the database or Redis. Run it for a burst, stop it to reclaim the card. See `agent/README.md`. |
|
||||
| **Firefox extension** | `extension/` | signed XPI | MV3 extension: pushes platform session cookies into FC and adds a creator as a Source in one click. AMO-signed on both `dev` and `main` (one signature per extension change, shared by the two channels), bundled into that channel's web image and served from Settings → Maintenance. See `extension/README.md`. |
|
||||
| **Data** | — | `pgvector/pgvector:pg16`, `redis:7-alpine` | Postgres with pgvector for embeddings; Redis as the Celery broker. |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -26,25 +69,69 @@ or a `.env` file (see `.env.example` for the variable names) and use:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml up -d
|
||||
# (skips the override so containers pull registry images)
|
||||
# (skips the dev override, so containers pull published :latest images)
|
||||
```
|
||||
|
||||
`-f` is doing real work there: it tells Compose to use *only* that file, which
|
||||
skips `docker-compose.override.yml` and its local builds. What you get is the
|
||||
`:latest` images — the stable channel, built from `main`. This is the install
|
||||
path, and it is the one to use if you are running FabledCurator rather than
|
||||
working on it.
|
||||
|
||||
`:dev` is the other channel: rebuilt from the `dev` branch several times a day,
|
||||
bleeding edge, no stability promise. Nothing in this repo points an installer at
|
||||
it, and nothing should.
|
||||
|
||||
The GPU agent is deployed separately, on the machine with the card —
|
||||
`agent/docker-compose.yml`, not this stack.
|
||||
|
||||
## Deployment posture
|
||||
|
||||
FabledCurator is designed to run inside a self-hosted homelab environment over plain HTTP. If you want TLS, terminate it at your reverse proxy. The app does not generate certificates, redirect to HTTPS, or set HSTS.
|
||||
|
||||
## CI / Forgejo setup
|
||||
|
||||
The repo's workflows expect:
|
||||
Four workflows: `ci.yml` (lint, extension-version check, backend unit tests,
|
||||
frontend build, integration), `extension.yml` (extension lint, vitest, XPI
|
||||
content verification), `build.yml` (sign + publish), and `release.yml`, which
|
||||
runs only on a `v*` tag and publishes a changelog without building anything.
|
||||
|
||||
- **Runner label `python-ci`** — a Forgejo runner with Python 3.14, ruff, and Node 22 pre-installed. Both `ci.yml` and `build.yml` use this label. The runner image (`runner-base:python-ci`) is built from `CI-Runner/CI-python/` in the operator's workspace; `make push` from that directory builds and pushes a new image when toolchain pins change.
|
||||
- **Repo secret `RELEASE_TOKEN`** — a Forgejo PAT with the following scopes:
|
||||
**The toolchain each job runs in is its `container.image`, not its `runs-on`
|
||||
label.** `runs-on: python-ci` only schedules the job onto a runner; every job
|
||||
then names the image it actually wants. `ci-requirements.md` is the current,
|
||||
authoritative list of images and per-job installs — read that rather than a
|
||||
copy here, so the two can't drift.
|
||||
|
||||
The repo expects one secret:
|
||||
|
||||
- **`RELEASE_TOKEN`** — a Forgejo PAT with:
|
||||
- `write:package` + `read:package` — for `docker push` to `git.fabledsword.com`
|
||||
- `write:release` — for future release-cutting workflows
|
||||
- `write:issue` — for future issue-management automation
|
||||
- `write:release` — for the `ext-<version>` releases that cache the signed XPI
|
||||
- `write:issue` — for issue-management automation
|
||||
|
||||
Generate at https://git.fabledsword.com/user/settings/applications. The injected `GITHUB_TOKEN` cannot be used because it lacks `write:package`.
|
||||
|
||||
AMO signing additionally needs `MOZILLA_AMO_JWT_KEY` / `MOZILLA_AMO_JWT_SECRET`.
|
||||
It runs on **both** channels and is cached per version: because the version is
|
||||
derived from commit time, `dev` and `main` derive the same number for the same
|
||||
source, so `main` finds `dev`'s signature already cached and makes no second AMO
|
||||
call. That cache is why signing must be one-shot — AMO rejects a re-signed
|
||||
version.
|
||||
|
||||
## License
|
||||
|
||||
Personal project; use at your own discretion.
|
||||
**GNU Affero General Public License v3.0** — see [LICENSE](LICENSE).
|
||||
|
||||
You may run, study, modify and redistribute this software. The condition is
|
||||
reciprocity: if you distribute a modified version, or **run one as a network
|
||||
service that other people use**, you must offer those users the corresponding
|
||||
source under the same licence. That second clause (AGPL §13) is the reason this
|
||||
licence rather than the GPL — for a self-hosted web application, "distribution"
|
||||
otherwise never happens, and the obligation would never bite.
|
||||
|
||||
Running an unmodified copy for yourself, your household or your organisation
|
||||
carries no obligation at all. Neither does modifying it privately. The licence
|
||||
asks something of you only when you hand your modified version to others.
|
||||
|
||||
Contributions ship under the same licence — see [CONTRIBUTING](CONTRIBUTING.md).
|
||||
Security reports: [SECURITY.md](SECURITY.md).
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not put vulnerability details in a public issue.**
|
||||
|
||||
This project has no private disclosure channel yet. Until it does, open an
|
||||
issue on the repository that says only that you have a security report — no
|
||||
reproduction steps, no affected endpoint, no payload — and a maintainer will
|
||||
reply with a private contact to send the details to.
|
||||
|
||||
That is a deliberately awkward first step, and it exists because the
|
||||
alternative is worse: an issue tracker is public the moment it is written to,
|
||||
and every self-hosted instance stays vulnerable until its operator has had a
|
||||
chance to update.
|
||||
|
||||
Please include, once you have a private channel:
|
||||
|
||||
- what an attacker can do, and what access they need to start
|
||||
- the version or commit you tested
|
||||
- reproduction steps
|
||||
|
||||
## Scope — what this software actually handles
|
||||
|
||||
FabledCurator is self-hosted and holds things worth stating plainly, because
|
||||
they shape what counts as a serious bug here:
|
||||
|
||||
- **Platform credentials.** The app captures and stores session cookies for
|
||||
third-party subscription sites (Patreon, SubscribeStar, Pixiv) so it can
|
||||
download on the operator's behalf. These are live credentials for accounts
|
||||
that usually carry a payment method. Anything that discloses them, decrypts
|
||||
them, or lets one user of a shared instance read another's is high severity.
|
||||
- **An extension API key.** The Firefox extension authenticates to the backend
|
||||
with a shared key. Anything that leaks it or lets it be bypassed is a way in.
|
||||
- **A multi-user sharing ACL.** Instances can be shared. A bug that lets one
|
||||
account see content another has not shared is an access-control failure, not
|
||||
a cosmetic one.
|
||||
- **Arbitrary media from the internet.** Downloaded files are decoded, hashed,
|
||||
thumbnailed and fed to ML models. Anything that turns a hostile file into
|
||||
code execution is in scope.
|
||||
|
||||
## Deployment posture — read this before reporting
|
||||
|
||||
FabledCurator is designed to run **inside a private network, over plain HTTP**.
|
||||
It does not terminate TLS, redirect to HTTPS, or set HSTS; if you want
|
||||
transport security, terminate it at your reverse proxy. This is a documented
|
||||
design decision, not an oversight.
|
||||
|
||||
Reports that reduce to "the application is served over HTTP" or "there is no
|
||||
HSTS header" describe that decision rather than a vulnerability. Reports that
|
||||
an authenticated operator can cause the software to do something destructive
|
||||
are usually also by design — the operator is the administrator of their own
|
||||
instance.
|
||||
|
||||
What remains in scope is everything that crosses a boundary the software is
|
||||
supposed to hold: between one user and another, between an unauthenticated
|
||||
visitor and any of it, and between untrusted downloaded content and the host.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Fixes land on the `main` branch and reach the `:latest` image. There are no
|
||||
maintained release branches — the supported version is the current one, and
|
||||
the remedy for a security issue is to update.
|
||||
@@ -21,7 +21,7 @@ log = logging.getLogger("fc_agent.app")
|
||||
# Bump on every agent change. The page embeds this and /status reports it; the UI
|
||||
# warns to reload when they differ — so a stale browser-cached page can't be
|
||||
# mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.)
|
||||
VERSION = "2026-07-02.6 · sleep mode: an empty queue sheds to one downloader and backs the lease poll off to 15 min"
|
||||
VERSION = "2026-07-17.1 · idle model-unload: after ~5 min idle the GPU models release their VRAM and reload on the next job (env IDLE_UNLOAD_SECONDS, 0=off) · sleep mode sheds to one downloader"
|
||||
|
||||
logbuf.install()
|
||||
cfg = Config.from_env()
|
||||
@@ -334,9 +334,12 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
|
||||
waited.textContent=s.transient||0
|
||||
// Instantaneous pool state → demoted to the sub-line, where its jumpiness reads
|
||||
// as live churn rather than a "broken" headline metric.
|
||||
// '=== false' (not falsy) so a stale page that doesn't send models_loaded shows
|
||||
// nothing; when the idle monitor unloads, the VRAM meter drops alongside this.
|
||||
pipe.textContent='downloaders '+(s.downloaders!=null?s.downloaders:'—')+' · consumers '+(s.consumers!=null?s.consumers:'—')+' · on GPU '+(s.active||0)
|
||||
+' · net '+(s.net_mb_s!=null?s.net_mb_s.toFixed(1):'—')+' MB/s'
|
||||
+(s.bandwidth_limit_mb_s>0?(' / cap '+s.bandwidth_limit_mb_s):'')
|
||||
+(s.models_loaded===false?' · GPU models unloaded (idle — reload on next job)':'')
|
||||
if(document.activeElement!==bw && s.bandwidth_limit_mb_s!=null) bw.value=s.bandwidth_limit_mb_s
|
||||
// Buffer occupancy bar (also driven here so it tracks the /status cadence).
|
||||
if(s.buffer!=null && s.buffer_max){ const p=Math.round(100*s.buffer/s.buffer_max)
|
||||
|
||||
@@ -51,6 +51,12 @@ class Config:
|
||||
bandwidth_limit_mb_s: float # aggregate download cap in MEGABYTES/s across
|
||||
# all downloaders + video streams (0 = unlimited);
|
||||
# tunable live from the agent UI
|
||||
idle_unload_seconds: float # after this long with the GPU idle (nothing in
|
||||
# flight, queue empty or Stopped), unload the
|
||||
# SigLIP embedder + YOLO proposers to free their
|
||||
# VRAM; they reload lazily on the next job. A
|
||||
# 24/7 agent otherwise squats on ~5GB doing
|
||||
# nothing. 0 disables (keep models warm forever).
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Config:
|
||||
@@ -87,4 +93,8 @@ class Config:
|
||||
# link to ~1-1.5 MB/s per stream, browser included). Raise it (or 0)
|
||||
# from the agent UI on wired/faster networks.
|
||||
bandwidth_limit_mb_s=float(os.environ.get("BANDWIDTH_LIMIT_MB_S", "8")),
|
||||
# 5 min: long enough that a lull between job bursts doesn't thrash the
|
||||
# (few-second) reload, short enough that an agent left running with an
|
||||
# empty queue hands its VRAM back promptly.
|
||||
idle_unload_seconds=float(os.environ.get("IDLE_UNLOAD_SECONDS", "300")),
|
||||
)
|
||||
|
||||
@@ -170,6 +170,13 @@ class YoloProposer:
|
||||
))
|
||||
return out
|
||||
|
||||
def unload(self) -> None:
|
||||
"""Drop the loaded YOLO so its VRAM can be reclaimed; detect() reloads it
|
||||
lazily on the next job. Leaves _ok untouched — a healthy proposer comes
|
||||
back, but one that self-disabled on a fault stays off."""
|
||||
with self._lock:
|
||||
self._model = None
|
||||
|
||||
|
||||
class Proposers:
|
||||
"""The agent's proposer set, built from config. Each detector is optional —
|
||||
@@ -216,3 +223,11 @@ class Proposers:
|
||||
|
||||
def panels(self, image):
|
||||
return self._top(self._panel, image, self.cfg.max_panels)
|
||||
|
||||
def unload(self) -> None:
|
||||
"""Release every loaded proposer's YOLO (idle VRAM reclaim). The worker
|
||||
also drops its reference to this Proposers and rebuilds a fresh one via
|
||||
_proposers_for on the next job, so this is belt-and-braces."""
|
||||
for p in (self._person, self._anatomy, self._panel):
|
||||
if p is not None:
|
||||
p.unload()
|
||||
|
||||
@@ -75,3 +75,18 @@ class CropEmbedder:
|
||||
pooled = out.pooler_output if hasattr(out, "pooler_output") else out
|
||||
arr = pooled.float().cpu().numpy().astype(np.float32)
|
||||
return [row.reshape(-1).tolist() for row in arr]
|
||||
|
||||
def unload(self) -> bool:
|
||||
"""Drop the loaded model so its VRAM can be reclaimed — the idle monitor
|
||||
calls this after a spell with no work so an idle agent doesn't squat on
|
||||
the card; the next embed() reloads it lazily (a few seconds). Held under
|
||||
BOTH the load and inference locks so it can never race a concurrent load
|
||||
or an in-flight forward pass. Returns True if a model was actually
|
||||
released (the caller then runs one empty_cache() to hand the freed blocks
|
||||
back to the driver)."""
|
||||
with self._load_lock, self._infer_lock:
|
||||
if self._model is None:
|
||||
return False
|
||||
self._model = None
|
||||
self._processor = None
|
||||
return True
|
||||
|
||||
@@ -57,6 +57,15 @@ MAX_BACKOFF_SECONDS = 60.0
|
||||
# up on their own.
|
||||
IDLE_POLL_MAX_SECONDS = 900.0
|
||||
|
||||
# Idle VRAM reclaim (operator 2026-07-17): the SigLIP embedder + YOLO proposers
|
||||
# load lazily and then stay warm for fast job bursts — but a 24/7 agent with an
|
||||
# empty queue would otherwise squat on that VRAM (~5GB on the operator's card)
|
||||
# indefinitely while doing nothing. So a monitor unloads them after
|
||||
# cfg.idle_unload_seconds with the GPU genuinely idle (nothing in flight, buffer
|
||||
# drained); they reload lazily on the next job. This is just how often the
|
||||
# monitor wakes to check — it bounds how soon past the threshold the unload fires.
|
||||
IDLE_UNLOAD_CHECK_INTERVAL = 30.0
|
||||
|
||||
# A job whose fetch dies transiently this many times IN ONE SESSION stops being
|
||||
# handed back and is failed instead. Transient handbacks (release) burn no
|
||||
# attempts on the server, so a poisoned transfer — an original that stalls the
|
||||
@@ -268,6 +277,11 @@ class Worker:
|
||||
self._proposers_sig = None # detector-config signature the current
|
||||
# proposers were built for (#134)
|
||||
self._proposers_lock = threading.Lock()
|
||||
# Monotonic time of the last GPU activity (a consumer finishing a job).
|
||||
# The idle monitor unloads the warm models once this goes stale by
|
||||
# cfg.idle_unload_seconds — see _idle_unload_loop.
|
||||
self._last_gpu_activity = time.monotonic()
|
||||
threading.Thread(target=self._idle_unload_loop, daemon=True).start()
|
||||
|
||||
# --- held-lease bookkeeping --------------------------------------------
|
||||
def _hold(self, job_ids) -> None:
|
||||
@@ -608,6 +622,9 @@ class Worker:
|
||||
"net_mb_s": round(self._net_mb_s, 1), # observed aggregate rate
|
||||
"bw_capped": self._bw_capped, # autoscaler holding at the cap (UI hint)
|
||||
"idle": self._idle, # queue empty → poll backed off (UI hint)
|
||||
# Whether the GPU models are currently resident (False after an idle
|
||||
# unload freed their VRAM) — a plain bool read, UI hint only.
|
||||
"models_loaded": self._embedder is not None or self._proposers is not None,
|
||||
}
|
||||
|
||||
def _bump(self, *, processed=0, downloaded=0, errors=0, active=0, transient=0):
|
||||
@@ -788,6 +805,9 @@ class Worker:
|
||||
self._bump(processed=1)
|
||||
finally:
|
||||
self._bump(active=-1)
|
||||
# Mark the GPU busy-until-now so the idle monitor starts its
|
||||
# unload countdown from when work actually stopped, not before.
|
||||
self._last_gpu_activity = time.monotonic()
|
||||
|
||||
def _ensure_embedder(self, model_name: str):
|
||||
if self._embedder is not None:
|
||||
@@ -845,6 +865,61 @@ class Worker:
|
||||
self._proposers_sig = sig
|
||||
return self._proposers
|
||||
|
||||
def _unload_models(self) -> bool:
|
||||
"""Release the GPU-resident models (SigLIP embedder + YOLO proposers) so an
|
||||
idle agent hands their VRAM back instead of squatting on the card. They
|
||||
reload lazily on the next job (_ensure_embedder / _proposers_for) — a
|
||||
few seconds' cost paid only when work actually resumes. Dropping the
|
||||
shared instances under their build locks means a concurrent job either
|
||||
sees the old instance (before) or rebuilds a fresh one (after); the idle
|
||||
monitor only calls this with nothing in flight, so no inference is using
|
||||
them. Returns True if anything was released."""
|
||||
released = False
|
||||
with self._embedder_lock:
|
||||
if self._embedder is not None:
|
||||
self._embedder.unload()
|
||||
self._embedder = None
|
||||
released = True
|
||||
with self._proposers_lock:
|
||||
if self._proposers is not None:
|
||||
self._proposers.unload()
|
||||
self._proposers = None
|
||||
self._proposers_sig = None
|
||||
released = True
|
||||
if released:
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
# torch's caching allocator holds freed blocks; hand them back
|
||||
# to the driver so nvidia-smi actually reflects the drop.
|
||||
torch.cuda.empty_cache()
|
||||
except Exception: # noqa: BLE001 — torch absent / CPU-only → nothing to free
|
||||
pass
|
||||
return released
|
||||
|
||||
def _idle_unload_loop(self) -> None:
|
||||
"""Unload the warm GPU models after a stretch of inactivity so a 24/7
|
||||
agent with an empty queue doesn't hold ~5GB of VRAM doing nothing. Fires
|
||||
only when nothing is in flight (active == 0 AND the buffer is drained) and
|
||||
no job has completed for cfg.idle_unload_seconds — a window long enough
|
||||
that a brief lull between bursts doesn't thrash reload/unload. Covers BOTH
|
||||
sleep mode (queue empty, pipeline still running) and a full Stop; the
|
||||
models reload lazily on the next job. idle_unload_seconds <= 0 disables it."""
|
||||
idle_after = self.cfg.idle_unload_seconds
|
||||
if idle_after <= 0:
|
||||
return
|
||||
while True:
|
||||
time.sleep(IDLE_UNLOAD_CHECK_INTERVAL)
|
||||
if self._embedder is None and self._proposers is None:
|
||||
continue # nothing loaded → nothing to free
|
||||
if self._active != 0 or not self._buffer.empty():
|
||||
continue # work in flight → keep them warm
|
||||
if time.monotonic() - self._last_gpu_activity < idle_after:
|
||||
continue # not idle long enough yet
|
||||
if self._unload_models():
|
||||
log.info("idle %.0fs — unloaded GPU models, freed VRAM "
|
||||
"(reload on next job)", idle_after)
|
||||
|
||||
def _consume(self, job: dict, frames: list, stop_evt: threading.Event) -> bool:
|
||||
"""Detect + embed the decoded frames and submit the result. Returns True
|
||||
when the job was completed (→ count it processed), False otherwise: a
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Reconcile the database with what the models have always claimed (#3275).
|
||||
|
||||
Milestone 328 discovered ~130 places where the ORM models and the deployed
|
||||
schema disagreed. Almost all of them were the MODEL being wrong — missing
|
||||
`server_default`s, indexes and CHECK constraints that only ever existed in a
|
||||
migration — and those are fixed in the model files with no DDL at all, because
|
||||
the database already had them.
|
||||
|
||||
This migration carries the remainder — the two places where DDL is actually
|
||||
needed, because the database is what is wrong.
|
||||
|
||||
`tag.fandom_id` is declared `index=True` on the model, but no migration ever
|
||||
created that index. Every autogenerate run since would have proposed adding
|
||||
it; nobody ran one, so the model and the database simply drifted apart and
|
||||
stayed that way.
|
||||
|
||||
Deliberately NOT in this migration: anything about `image_record.sha256`. An
|
||||
earlier draft of this file claimed sha256 was not unique in the database and
|
||||
that duplicate rows were therefore possible. That was WRONG, and it was wrong
|
||||
because it was read off `op.create_index("ix_image_record_sha256", ...)` at
|
||||
0001 line 151 without reading line 149 two lines above it:
|
||||
|
||||
sa.UniqueConstraint("sha256", name="uq_image_record_sha256"),
|
||||
|
||||
Uniqueness has been enforced since the initial schema. The database simply
|
||||
expresses it as a CONSTRAINT plus a separate non-unique lookup index, where
|
||||
the model expressed it as one `unique=True, index=True` column — the same
|
||||
guarantee built from different objects, which is why the two schemas did not
|
||||
line up. The model now declares the constraint and the plain index separately,
|
||||
so it describes what is actually there. No DDL is needed for it.
|
||||
|
||||
Also here: six CHECK constraints whose names carry their table prefix TWICE.
|
||||
|
||||
`base.py`'s naming convention is `ck_%(table_name)s_%(constraint_name)s`, and
|
||||
unlike the uq/fk/ix entries it applies even to a constraint that already has a
|
||||
name. Six migrations passed an already-prefixed name, so the convention
|
||||
prefixed it again:
|
||||
|
||||
ck_external_link_ck_external_link_host
|
||||
ck_external_link_ck_external_link_status
|
||||
ck_import_settings_ck_import_settings_singleton
|
||||
ck_ml_settings_ck_ml_settings_singleton
|
||||
ck_post_ck_post_translation_override
|
||||
ck_tag_ck_tag_fandom_requires_character
|
||||
|
||||
Nothing reads a CHECK constraint by name, so this has never done any harm —
|
||||
but it is exactly the development-era residue the collapsed baseline exists to
|
||||
leave behind, and a public schema should not ship it. The models now declare
|
||||
bare names, which the convention renders into the single-prefix form; this
|
||||
renames the deployed constraints to match.
|
||||
|
||||
RENAME CONSTRAINT is a catalog-only operation: no table scan, no rewrite, no
|
||||
validation of existing rows. It takes a brief ACCESS EXCLUSIVE lock and
|
||||
returns. That is why this is safe to do on `post` and `tag`, which are the two
|
||||
large tables in the schema.
|
||||
|
||||
Revision ID: 0088
|
||||
Revises: 0087
|
||||
Create Date: 2026-08-30
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0088"
|
||||
down_revision: Union[str, None] = "0087"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# (table, doubled name, single-prefix name)
|
||||
#
|
||||
# Six, not the four a first read of the migrations turned up. The list that
|
||||
# settles it is the one extracted from the chain's pg_dump by matching
|
||||
# `ck_(\w+?)_ck_\1_` — reading the migrations by eye missed external_link
|
||||
# twice over, in the same way an earlier pass missed a UNIQUE constraint two
|
||||
# lines above the index it was looking at (see the sha256 note above).
|
||||
DOUBLED_CHECKS = (
|
||||
("external_link", "ck_external_link_ck_external_link_host",
|
||||
"ck_external_link_host"),
|
||||
("external_link", "ck_external_link_ck_external_link_status",
|
||||
"ck_external_link_status"),
|
||||
("import_settings", "ck_import_settings_ck_import_settings_singleton",
|
||||
"ck_import_settings_singleton"),
|
||||
("ml_settings", "ck_ml_settings_ck_ml_settings_singleton",
|
||||
"ck_ml_settings_singleton"),
|
||||
("post", "ck_post_ck_post_translation_override",
|
||||
"ck_post_translation_override"),
|
||||
("tag", "ck_tag_ck_tag_fandom_requires_character",
|
||||
"ck_tag_fandom_requires_character"),
|
||||
)
|
||||
|
||||
|
||||
def _rename_check(table: str, old: str, new: str) -> None:
|
||||
# Guarded on pg_constraint rather than run bare: a database built from the
|
||||
# models (a fresh install, or the CI integration schema) already has the
|
||||
# single-prefix name, and this migration must be a no-op there rather than
|
||||
# an error. Same reasoning as the CREATE INDEX IF NOT EXISTS below.
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = '{old}' AND conrelid = '{table}'::regclass
|
||||
) THEN
|
||||
ALTER TABLE {table} RENAME CONSTRAINT {old} TO {new};
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# IF NOT EXISTS because the index is what the model already asks for: any
|
||||
# database built from metadata rather than from this chain will have it,
|
||||
# and this migration must be a no-op there rather than an error.
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_tag_fandom_id ON tag (fandom_id)")
|
||||
|
||||
for table, old, new in DOUBLED_CHECKS:
|
||||
_rename_check(table, old, new)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table, old, new in DOUBLED_CHECKS:
|
||||
_rename_check(table, new, old)
|
||||
|
||||
op.execute("DROP INDEX IF EXISTS ix_tag_fandom_id")
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Index the seven unindexed FKs; drop the seven redundant indexes (#3300, #3301).
|
||||
|
||||
Found by a structural sweep of the deployed schema done AFTER 0088 brought the
|
||||
models and the migration chain into exact agreement. That agreement is what
|
||||
0088 achieved, and it is worth being precise about what it does NOT prove: a
|
||||
models-vs-chain diff shows the two describe the same schema. It says nothing
|
||||
about whether that schema is right. Everything here was wrong in BOTH, which is
|
||||
exactly the class of problem the reconciliation could not see.
|
||||
|
||||
## Added: seven FK indexes
|
||||
|
||||
`image_tag.tag_id` is the one that matters. The table's only index is
|
||||
PRIMARY KEY (image_record_id, tag_id), which leads with the wrong column for
|
||||
the two hottest things done with it:
|
||||
|
||||
* the gallery's tag filter — services/tag_query.py builds
|
||||
`image_tag.c.tag_id == tid` (and `.in_(tids)`) on every tag-scoped browse;
|
||||
* ON DELETE CASCADE from `tag` — deleting or merging a tag makes Postgres
|
||||
find that tag's rows before it can remove them.
|
||||
|
||||
Both had to scan the largest table in the schema. The other six are the same
|
||||
shape on much smaller tables; `presentation_review.tag_id` is the notable one,
|
||||
since it also CASCADEs.
|
||||
|
||||
## Dropped: seven redundant indexes
|
||||
|
||||
`ix_image_record_sha256` was an exact duplicate. A UNIQUE constraint builds its
|
||||
own index, so `uq_image_record_sha256` already covered the column and
|
||||
`image_record` carried two btrees on `sha256` — on the highest-insert-rate
|
||||
table in the system.
|
||||
|
||||
The other six are single-column indexes that a later composite superseded
|
||||
without the narrow one being retired. A btree on (a, b) already serves lookups
|
||||
on `a`, so each was pure write amplification. `task_run` and `backup_run` are
|
||||
append-heavy operational logs, which is where that cost lands hardest.
|
||||
|
||||
Note for anyone reading 0088 next to this: 0088 deliberately taught the models
|
||||
to declare BOTH sha256 indexes, so they would describe reality. That was right.
|
||||
This migration changes the reality instead, and the models change with it.
|
||||
|
||||
## CONCURRENTLY, and why this migration has no transaction
|
||||
|
||||
`CREATE INDEX` takes an ACCESS EXCLUSIVE lock for the whole build, which on
|
||||
`image_tag` means stalling every write for as long as it takes. CONCURRENTLY
|
||||
builds without blocking writers, at the cost of two table passes and an
|
||||
inability to run inside a transaction — hence `autocommit_block()`.
|
||||
|
||||
The consequence to know about: this migration is NOT atomic. If it fails
|
||||
partway, the work already done stays done. Every statement is therefore written
|
||||
IF NOT EXISTS / IF EXISTS so that re-running it after a failure is safe rather
|
||||
than an error.
|
||||
|
||||
A failed CONCURRENTLY build also leaves an INVALID index behind — it is not
|
||||
used by the planner and not repaired automatically. Find them with:
|
||||
|
||||
SELECT c.relname FROM pg_index i
|
||||
JOIN pg_class c ON c.oid = i.indexrelid
|
||||
WHERE NOT i.indisvalid;
|
||||
|
||||
Drop what that returns and re-run; nothing else is needed.
|
||||
|
||||
Revision ID: 0089
|
||||
Revises: 0088
|
||||
Create Date: 2026-08-31
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0089"
|
||||
down_revision: Union[str, None] = "0088"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# (index name, table, column) — names match what the models render under
|
||||
# base.py's naming convention, so autogenerate stays quiet after this.
|
||||
MISSING_FK_INDEXES = (
|
||||
("ix_image_tag_tag_id", "image_tag", "tag_id"),
|
||||
("ix_presentation_review_tag_id", "presentation_review", "tag_id"),
|
||||
("ix_presentation_review_conflict_tag_id", "presentation_review", "conflict_tag_id"),
|
||||
("ix_import_task_result_image_id", "import_task", "result_image_id"),
|
||||
("ix_external_link_attachment_id", "external_link", "attachment_id"),
|
||||
("ix_character_prototype_region_id", "character_prototype", "region_id"),
|
||||
("ix_backup_run_restored_from_id", "backup_run", "restored_from_id"),
|
||||
)
|
||||
|
||||
# (index name, table, column) — redundant; the second element of each pair in
|
||||
# the docstring is what still covers the column after the drop.
|
||||
REDUNDANT_INDEXES = (
|
||||
("ix_image_record_sha256", "image_record", "sha256"),
|
||||
("ix_backup_run_kind", "backup_run", "kind"),
|
||||
("ix_backup_run_status", "backup_run", "status"),
|
||||
("ix_task_run_queue", "task_run", "queue"),
|
||||
("ix_task_run_status", "task_run", "status"),
|
||||
("ix_task_run_task_name", "task_run", "task_name"),
|
||||
("ix_external_link_post_id", "external_link", "post_id"),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
for name, table, column in MISSING_FK_INDEXES:
|
||||
op.execute(
|
||||
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {name} "
|
||||
f"ON {table} ({column})"
|
||||
)
|
||||
for name, _table, _column in REDUNDANT_INDEXES:
|
||||
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.get_context().autocommit_block():
|
||||
for name, table, column in REDUNDANT_INDEXES:
|
||||
op.execute(
|
||||
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {name} "
|
||||
f"ON {table} ({column})"
|
||||
)
|
||||
for name, _table, _column in MISSING_FK_INDEXES:
|
||||
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
|
||||
@@ -459,6 +459,22 @@ async def trigger_prune_missing_files():
|
||||
return _queued(async_result)
|
||||
|
||||
|
||||
@admin_bp.route("/maintenance/reclaim-attachments", methods=["POST"])
|
||||
async def trigger_reclaim_attachments():
|
||||
"""Reclaim orphaned attachments (#3068). Body {"dry_run": bool}: dry_run
|
||||
(the DEFAULT here) projects the orphan rows and unreferenced store blobs
|
||||
without touching either; dry_run=false deletes the rows then unlinks every
|
||||
blob no surviving row references. Maintenance queue; operator-triggered
|
||||
only — never an unattended sweep, since the apply unlinks files. Returns the
|
||||
Celery task id — poll /maintenance/task-result/<id> for the summary."""
|
||||
from ..tasks.admin import reclaim_orphaned_attachments_task
|
||||
|
||||
body = await request.get_json(silent=True) or {}
|
||||
dry_run = bool(body.get("dry_run", True)) # default to the SAFE preview
|
||||
async_result = reclaim_orphaned_attachments_task.delay(dry_run=dry_run)
|
||||
return _queued(async_result)
|
||||
|
||||
|
||||
@admin_bp.route("/maintenance/dedup-videos", methods=["POST"])
|
||||
async def trigger_dedup_videos():
|
||||
"""Tier-1 video dedup (#871). Body {"dry_run": bool}: dry_run=true previews
|
||||
|
||||
@@ -6,12 +6,14 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..build_info import FC_CHANNEL as _FC_CHANNEL
|
||||
from ..extensions import get_session
|
||||
from ..models import AppSetting
|
||||
from ..services.extension_service import (
|
||||
@@ -30,6 +32,15 @@ XPI_DIR = Path("/app/frontend/dist/extension")
|
||||
|
||||
_XPI_VERSION_RE = re.compile(r"fabledcurator-(?P<version>[\w.-]+)\.xpi$")
|
||||
|
||||
# Which channel this image belongs to — "dev" or "main" — baked in at build
|
||||
# time (milestone 271 step 7). Read from build_info rather than the environment
|
||||
# a second time: /api/health reports the same value, and two independent
|
||||
# `os.environ.get` calls are two things that can drift.
|
||||
#
|
||||
# Still bound as a module-level name here, so tests monkeypatch
|
||||
# `extension.FC_CHANNEL` exactly as they did before, same as XPI_DIR above.
|
||||
FC_CHANNEL = _FC_CHANNEL
|
||||
|
||||
|
||||
async def _ext_key_required(session) -> bool:
|
||||
"""Unlike /api/credentials (which accepts the browser path with no
|
||||
@@ -41,7 +52,15 @@ async def _ext_key_required(session) -> bool:
|
||||
stored = (await session.execute(
|
||||
select(AppSetting.value).where(AppSetting.key == "extension_api_key")
|
||||
)).scalar_one_or_none()
|
||||
return stored is not None and supplied == stored
|
||||
if stored is None:
|
||||
return False
|
||||
# compare_digest, not `==`: the stored key is a shared secret, and a
|
||||
# short-circuiting compare leaks its prefix through timing. Costs nothing
|
||||
# here — it is not that this route is exposed (#3072). Compared as BYTES:
|
||||
# compare_digest's str form rejects non-ASCII with TypeError, and this
|
||||
# header is attacker-supplied, so a str compare would turn a junk key into
|
||||
# a 500 instead of a 403.
|
||||
return hmac.compare_digest(supplied.encode("utf-8"), stored.encode("utf-8"))
|
||||
|
||||
|
||||
def _extract_version(xpi_name: str) -> str:
|
||||
@@ -124,13 +143,30 @@ def _read_manifest_sync() -> dict | None:
|
||||
return None
|
||||
versioned.sort(key=lambda p: p.stat().st_mtime)
|
||||
latest = versioned[-1]
|
||||
return {
|
||||
info = {
|
||||
"installed": True,
|
||||
"version": _extract_version(latest.name),
|
||||
"xpi_url": f"/extension/{latest.name}",
|
||||
"latest_url": "/extension/fabledcurator-latest.xpi",
|
||||
"sha256": _sha256(latest),
|
||||
}
|
||||
# The channel goes BESIDE the version, never inside it. A `-dev` suffix is
|
||||
# what silently disabled the dev channel in the sibling project this design
|
||||
# comes from: the comparator returned nothing for a non-integer segment, so
|
||||
# every dev version compared equal and "no update available" became
|
||||
# indistinguishable from "I cannot read this version".
|
||||
#
|
||||
# Omitted rather than defaulted when unset. Absence already has a meaning
|
||||
# every reader must handle — an image built before this field existed says
|
||||
# exactly the same thing by not having the key — so a blank channel reuses
|
||||
# that path instead of inventing a second "unknown" spelling.
|
||||
#
|
||||
# Reported verbatim, not validated against {"dev", "main"}: if an image
|
||||
# declares something else, showing what it actually claims is more useful
|
||||
# to whoever is debugging it than dropping the value on the floor.
|
||||
if FC_CHANNEL:
|
||||
info["channel"] = FC_CHANNEL
|
||||
return info
|
||||
|
||||
|
||||
@extension_bp.route("/manifest", methods=["GET"])
|
||||
|
||||
@@ -256,9 +256,7 @@ async def lease():
|
||||
if not await _agent_authed(session):
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
jobs = await GpuJobService(session).lease(agent_id, batch_size=batch)
|
||||
ml = (
|
||||
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||
).scalar_one()
|
||||
ml = await MLSettings.load(session)
|
||||
# image rows for url/mime in one shot
|
||||
ids = [j.image_record_id for j in jobs]
|
||||
imgs = {
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
"""Health endpoint — no DB or Redis touch; just liveness."""
|
||||
"""Health endpoint — no DB or Redis touch; liveness, plus the build's identity.
|
||||
|
||||
The identity rides here rather than on a route of its own because it answers
|
||||
at the same cost: two module constants, no I/O, nothing that can be slow or
|
||||
fail. It is also already fetched app-wide — TopNav calls `refreshHealth` on
|
||||
mount — so a separate endpoint would mean a second request for two strings.
|
||||
|
||||
Both fields are OMITTED when unset rather than sent empty. See build_info.
|
||||
"""
|
||||
|
||||
from ..build_info import FC_CHANNEL, FC_VERSION
|
||||
|
||||
|
||||
async def get_health():
|
||||
return {"status": "ok"}, 200
|
||||
body = {"status": "ok"}
|
||||
if FC_VERSION:
|
||||
body["version"] = FC_VERSION
|
||||
if FC_CHANNEL:
|
||||
body["channel"] = FC_CHANNEL
|
||||
return body, 200
|
||||
|
||||
+17
-43
@@ -4,6 +4,7 @@ from quart import Blueprint, jsonify, request
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import MLSettings
|
||||
from ..services.ml.heads import AUTO_APPLY_THRESHOLD_MAX, AUTO_APPLY_THRESHOLD_MIN
|
||||
|
||||
ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
|
||||
|
||||
@@ -83,48 +84,21 @@ async def embedder_models():
|
||||
|
||||
@ml_admin_bp.route("/settings", methods=["GET"])
|
||||
async def get_settings():
|
||||
from sqlalchemy import select
|
||||
|
||||
async with get_session() as session:
|
||||
s = (
|
||||
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||
).scalar_one()
|
||||
return jsonify(
|
||||
{
|
||||
"cpu_embed_enabled": s.cpu_embed_enabled,
|
||||
"video_frame_interval_seconds": s.video_frame_interval_seconds,
|
||||
"video_max_frames": s.video_max_frames,
|
||||
"embedder_model_version": s.embedder_model_version,
|
||||
"head_min_positives": s.head_min_positives,
|
||||
"head_auto_apply_precision": s.head_auto_apply_precision,
|
||||
"head_auto_apply_enabled": s.head_auto_apply_enabled,
|
||||
"head_auto_apply_min_positives": s.head_auto_apply_min_positives,
|
||||
"ccip_match_threshold": s.ccip_match_threshold,
|
||||
"ccip_auto_apply_enabled": s.ccip_auto_apply_enabled,
|
||||
"ccip_auto_apply_threshold": s.ccip_auto_apply_threshold,
|
||||
"presentation_auto_apply_enabled": s.presentation_auto_apply_enabled,
|
||||
"presentation_auto_apply_threshold": s.presentation_auto_apply_threshold,
|
||||
"presentation_conflict_threshold": s.presentation_conflict_threshold,
|
||||
"process_auto_apply_enabled": s.process_auto_apply_enabled,
|
||||
"process_auto_apply_threshold": s.process_auto_apply_threshold,
|
||||
"process_conflict_threshold": s.process_conflict_threshold,
|
||||
"embedder_model_name": s.embedder_model_name,
|
||||
**{f: getattr(s, f) for f in _DETECTOR_FIELDS},
|
||||
}
|
||||
)
|
||||
s = await MLSettings.load(session)
|
||||
# Table-driven off _EDITABLE (which PATCH also writes) so a new settings field
|
||||
# can never be silently absent from GET — the split that historically dropped
|
||||
# fields. _EDITABLE already includes *_DETECTOR_FIELDS.
|
||||
return jsonify({f: getattr(s, f) for f in _EDITABLE})
|
||||
|
||||
|
||||
@ml_admin_bp.route("/settings", methods=["PATCH"])
|
||||
async def patch_settings():
|
||||
from sqlalchemy import select
|
||||
|
||||
body = await request.get_json()
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"error": "body must be an object"}), 400
|
||||
async with get_session() as session:
|
||||
s = (
|
||||
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||
).scalar_one()
|
||||
s = await MLSettings.load(session)
|
||||
|
||||
# Merge the patch over current values, then validate the result as a
|
||||
# whole — the store-floor invariant couples three fields, so they
|
||||
@@ -154,24 +128,24 @@ def _validate(p: dict) -> str | None:
|
||||
# Head training (#114).
|
||||
if int(p["head_min_positives"]) < 1:
|
||||
return "head_min_positives must be >= 1"
|
||||
if not (0.5 <= float(p["head_auto_apply_precision"]) <= 0.999):
|
||||
return "head_auto_apply_precision must be between 0.5 and 0.999"
|
||||
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["head_auto_apply_precision"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||
return f"head_auto_apply_precision must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
|
||||
if int(p["head_auto_apply_min_positives"]) < 1:
|
||||
return "head_auto_apply_min_positives must be >= 1"
|
||||
if not (0.5 <= float(p["ccip_match_threshold"]) <= 0.999):
|
||||
return "ccip_match_threshold must be between 0.5 and 0.999"
|
||||
if not (0.5 <= float(p["ccip_auto_apply_threshold"]) <= 0.999):
|
||||
return "ccip_auto_apply_threshold must be between 0.5 and 0.999"
|
||||
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_match_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||
return f"ccip_match_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
|
||||
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["ccip_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||
return f"ccip_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
|
||||
# Presentation chrome auto-hide (#141). Auto-apply runs high (hiding is
|
||||
# consequential); the conflict cut is a plain probability [0,1].
|
||||
if not (0.5 <= float(p["presentation_auto_apply_threshold"]) <= 0.999):
|
||||
return "presentation_auto_apply_threshold must be between 0.5 and 0.999"
|
||||
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["presentation_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||
return f"presentation_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
|
||||
if not (0.0 <= float(p["presentation_conflict_threshold"]) <= 1.0):
|
||||
return "presentation_conflict_threshold must be between 0 and 1"
|
||||
# Process auto-apply (#1464). wip/editor stay VISIBLE so a false apply is
|
||||
# low-harm (excludes-from-training + a review flag), but keep the same bar.
|
||||
if not (0.5 <= float(p["process_auto_apply_threshold"]) <= 0.999):
|
||||
return "process_auto_apply_threshold must be between 0.5 and 0.999"
|
||||
if not (AUTO_APPLY_THRESHOLD_MIN <= float(p["process_auto_apply_threshold"]) <= AUTO_APPLY_THRESHOLD_MAX):
|
||||
return f"process_auto_apply_threshold must be between {AUTO_APPLY_THRESHOLD_MIN} and {AUTO_APPLY_THRESHOLD_MAX}"
|
||||
if not (0.0 <= float(p["process_conflict_threshold"]) <= 1.0):
|
||||
return "process_conflict_threshold must be between 0 and 1"
|
||||
# Embedder model swap (#1190): both must be non-empty. Changing them means a
|
||||
|
||||
@@ -66,34 +66,9 @@ _EXTDL_TOGGLE_FIELDS = (
|
||||
async def get_import_settings():
|
||||
async with get_session() as session:
|
||||
row = await ImportSettings.load(session)
|
||||
return jsonify({
|
||||
"min_width": row.min_width,
|
||||
"min_height": row.min_height,
|
||||
"skip_transparent": row.skip_transparent,
|
||||
"transparency_threshold": row.transparency_threshold,
|
||||
"skip_single_color": row.skip_single_color,
|
||||
"single_color_threshold": row.single_color_threshold,
|
||||
"single_color_tolerance": row.single_color_tolerance,
|
||||
"phash_threshold": row.phash_threshold,
|
||||
"download_rate_limit_seconds": row.download_rate_limit_seconds,
|
||||
"download_validate_files": row.download_validate_files,
|
||||
"download_schedule_default_seconds": row.download_schedule_default_seconds,
|
||||
"download_event_retention_days": row.download_event_retention_days,
|
||||
"download_failure_warning_threshold": row.download_failure_warning_threshold,
|
||||
"series_suggest_enabled": row.series_suggest_enabled,
|
||||
"series_suggest_threshold": row.series_suggest_threshold,
|
||||
"extdl_mega_enabled": row.extdl_mega_enabled,
|
||||
"extdl_gdrive_enabled": row.extdl_gdrive_enabled,
|
||||
"extdl_mediafire_enabled": row.extdl_mediafire_enabled,
|
||||
"extdl_dropbox_enabled": row.extdl_dropbox_enabled,
|
||||
"extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled,
|
||||
"translation_enabled": row.translation_enabled,
|
||||
"interpreter_base_url": row.interpreter_base_url,
|
||||
"translation_target_lang": row.translation_target_lang,
|
||||
"translation_min_confidence": row.translation_min_confidence,
|
||||
"wip_title_tagging_enabled": row.wip_title_tagging_enabled,
|
||||
"wip_soft_title_tagging_enabled": row.wip_soft_title_tagging_enabled,
|
||||
})
|
||||
# Table-driven off _EDITABLE_FIELDS (which PATCH also writes) so a new field
|
||||
# can't be silently absent from GET.
|
||||
return jsonify({f: getattr(row, f) for f in _EDITABLE_FIELDS})
|
||||
|
||||
|
||||
@settings_bp.route("/settings/import", methods=["PATCH"])
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""What this build IS — stamped at image build time, not configurable.
|
||||
|
||||
Deliberately separate from `config.py`. Those are operator settings, read from
|
||||
the environment and meant to be changed. These describe the artifact itself and
|
||||
are baked in by CI (the `FC_VERSION` / `FC_CHANNEL` build args); an operator
|
||||
setting them by hand is not a supported thing to do, it is just how a value
|
||||
gets from the build into the running process.
|
||||
|
||||
**Absent rather than empty when unknown.** A locally-built image has no version,
|
||||
and neither did any image predating the field — one spelling of "cannot say",
|
||||
which every reader already has to handle, instead of a second one to
|
||||
special-case (note #3127 §7).
|
||||
|
||||
**Why this matters more than it used to.** Milestone 318 stopped publishing
|
||||
version image tags, so a running instance's self-report is now the *only*
|
||||
answer to "which build is this?" — there is no registry name left to check it
|
||||
against. A wrong value here has nothing to contradict it. That is why the UI
|
||||
renders `unknown` rather than a blank or a plausible default: an empty footer
|
||||
reads as "no version", which is a different and false claim.
|
||||
|
||||
The channel lives BESIDE the version and is never folded into it (rule 149).
|
||||
A `-dev` suffix would be parsed by the extension's comparator as a segment
|
||||
worth 0, making every dev build compare equal to every other — issue #2993's
|
||||
exact failure.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
FC_VERSION = os.environ.get("FC_VERSION", "").strip()
|
||||
FC_CHANNEL = os.environ.get("FC_CHANNEL", "").strip()
|
||||
@@ -27,7 +27,7 @@ from .patreon_seen_media import PatreonSeenMedia
|
||||
from .pixiv_failed_media import PixivFailedMedia
|
||||
from .pixiv_seen_media import PixivSeenMedia
|
||||
from .post import Post
|
||||
from .post_attachment import PostAttachment
|
||||
from .post_attachment import PostAttachment, attachment_download_url
|
||||
from .presentation_review import PresentationReview
|
||||
from .series_chapter import SeriesChapter
|
||||
from .series_page import SeriesPage
|
||||
@@ -58,6 +58,7 @@ __all__ = [
|
||||
"SubscribeStarSeenMedia",
|
||||
"Post",
|
||||
"PostAttachment",
|
||||
"attachment_download_url",
|
||||
"PresentationReview",
|
||||
"SeriesChapter",
|
||||
"SeriesPage",
|
||||
|
||||
@@ -27,10 +27,10 @@ class Artist(Base):
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# True once a Source is attached; flips false if all sources removed.
|
||||
is_subscription: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_subscription: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
|
||||
# Per-artist scheduling overrides; null means "use global default".
|
||||
auto_check: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
auto_check: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
check_interval_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -20,7 +20,7 @@ feedback_check_existing_enums):
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, BigInteger, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import JSON, BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -29,10 +29,21 @@ from .base import Base
|
||||
class BackupRun(Base):
|
||||
__tablename__ = "backup_run"
|
||||
|
||||
|
||||
__table_args__ = (
|
||||
# alembic 0017: reporting indexes, never declared on the model (#3275).
|
||||
Index("ix_backup_run_kind_started", "kind", text("started_at DESC")),
|
||||
Index("ix_backup_run_status_finished", "status", text("finished_at DESC")),
|
||||
Index("ix_backup_run_tag_partial", "tag", postgresql_where=text("tag IS NOT NULL")),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||
# No index=True: ix_backup_run_kind_started (above) already leads with
|
||||
# `kind`, so a single-column index on it was pure write cost (#3301).
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="pending", index=True,
|
||||
# No index=True — ix_backup_run_status_finished leads with `status`.
|
||||
String(16), nullable=False, default="pending",
|
||||
server_default="pending",
|
||||
)
|
||||
tag: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
triggered_by: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
@@ -49,7 +60,9 @@ class BackupRun(Base):
|
||||
manifest: Mapped[dict] = mapped_column(
|
||||
JSON, nullable=False, default=dict, server_default="{}",
|
||||
)
|
||||
# Self-referential FK, unindexed until 0089 (#3300): SET NULL has to find
|
||||
# the rows pointing at a deleted run before it can null them.
|
||||
restored_from_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("backup_run.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
nullable=True, index=True,
|
||||
)
|
||||
|
||||
@@ -40,8 +40,10 @@ class CharacterPrototype(Base):
|
||||
)
|
||||
# Provenance: the region this vector was copied from. SET NULL so pruning a
|
||||
# region doesn't delete the prototype mid-cycle (the next refresh reconciles).
|
||||
# index=True added in 0089 — the FK was unindexed (#3300).
|
||||
region_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("image_region.id", ondelete="SET NULL"), nullable=True
|
||||
ForeignKey("image_region.id", ondelete="SET NULL"), nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ class DownloadEvent(Base):
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
bytes_downloaded: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
files_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
bytes_downloaded: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
|
||||
files_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
metadata_: Mapped[dict] = mapped_column(
|
||||
"metadata", JSONB, nullable=False, default=dict,
|
||||
|
||||
@@ -16,6 +16,7 @@ doesn't delete the link record).
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
@@ -38,15 +39,33 @@ STATUSES = ("pending", "downloading", "downloaded", "failed", "skipped", "dead")
|
||||
class ExternalLink(Base):
|
||||
__tablename__ = "external_link"
|
||||
__table_args__ = (
|
||||
# alembic 0028 enum CHECKs. Rule 36 territory: a new host or status value
|
||||
# needs its constraint swapped in the same migration (#3275).
|
||||
CheckConstraint(
|
||||
"host IN ('mega', 'gdrive', 'mediafire', 'dropbox', 'pixeldrain')",
|
||||
# Bare name: Base.metadata's naming convention prepends
|
||||
# ck_<table>_. Pre-prefixing it here doubles the prefix — see
|
||||
# alembic 0088, which renames the four constraints that shipped
|
||||
# that way (#3275).
|
||||
name="host",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('pending', 'downloading', 'downloaded', 'failed', 'skipped', 'dead')",
|
||||
name="status",
|
||||
),
|
||||
# One row per (post, url). The full url (incl. #fragment) is the identity
|
||||
# — the same file linked twice in a post collapses to one row.
|
||||
Index("uq_external_link_post_url", "post_id", "url", unique=True),
|
||||
Index("ix_external_link_status", "status"),
|
||||
# Unindexed FK (#3300).
|
||||
Index("ix_external_link_attachment_id", "attachment_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
# No index=True: uq_external_link_post_url (post_id, url) already leads
|
||||
# with post_id (#3301).
|
||||
post_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
ForeignKey("post.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
artist_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
|
||||
@@ -50,7 +50,8 @@ class GpuJob(Base):
|
||||
# What to compute, e.g. 'ccip' (detect figures + CCIP-embed) or 'siglip_region'.
|
||||
task: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="pending", index=True
|
||||
String(16), nullable=False, default="pending", index=True,
|
||||
server_default="pending",
|
||||
)
|
||||
# pending | leased | done | error
|
||||
lease_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
@@ -60,7 +61,7 @@ class GpuJob(Base):
|
||||
lease_expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Triage verdict for an ERRORED job (#125): NULL = not yet probed;
|
||||
# 'defect' = the integrity probe says the FILE itself is bad (surfaced for
|
||||
|
||||
@@ -24,10 +24,11 @@ class HeadAutoApplyRun(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
# dry_run=True is a PREVIEW: scores + counts what WOULD apply, writes nothing
|
||||
# (preview/apply parity, rule 93).
|
||||
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="running", index=True
|
||||
String(16), nullable=False, default="running", index=True,
|
||||
server_default="running",
|
||||
)
|
||||
# running | ready | error
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -24,9 +24,9 @@ class HeadMetric(Base):
|
||||
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
# An auto-applied (source='head_auto') tag the operator later REMOVED.
|
||||
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
# A tag with a head that the operator added by HAND (the head missed it).
|
||||
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -19,8 +19,14 @@ class HeadMetricsSnapshot(Base):
|
||||
__tablename__ = "head_metrics_snapshot"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
tag_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tag.id", ondelete="CASCADE"), index=True
|
||||
# Nullable, matching alembic 0060, which declared this column without
|
||||
# `nullable=False`. The model had it as `Mapped[int]` — NOT NULL — which
|
||||
# was simply never true of the database (#3275). Left nullable rather than
|
||||
# tightened: a snapshot of a tag that is later hard-deleted is a row worth
|
||||
# keeping, and the FK is ON DELETE CASCADE, so tightening it would only
|
||||
# change behaviour, not correct a bug.
|
||||
tag_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("tag.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
# Denormalized so a snapshot stays readable even if the tag is later renamed.
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
@@ -28,9 +34,9 @@ class HeadMetricsSnapshot(Base):
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
|
||||
)
|
||||
# Current count of source='head_auto' applications still standing.
|
||||
n_auto_applied: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
n_auto_applied: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
# The head's measured quality at snapshot time (null if no head exists).
|
||||
ap: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
precision_cv: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
@@ -24,7 +24,8 @@ class HeadTrainingRun(Base):
|
||||
# Training parameters: {min_positives, neg_ratio, precision_target, ...}.
|
||||
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="running", index=True
|
||||
String(16), nullable=False, default="running", index=True,
|
||||
server_default="running",
|
||||
)
|
||||
# running | ready | error
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -47,8 +47,15 @@ class ImageProvenance(Base):
|
||||
# attachment on the post. NULL for loose downloads and pre-backfill rows.
|
||||
# SET NULL so deleting the archive attachment never destroys the (image,
|
||||
# post) edge — it just forgets which archive it came from.
|
||||
# FK named explicitly: the convention renders this
|
||||
# `fk_image_provenance_from_attachment_id_post_attachment`, but alembic
|
||||
# 0055 created it as `fk_image_provenance_from_attachment` (#3275).
|
||||
from_attachment_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("post_attachment.id", ondelete="SET NULL"),
|
||||
ForeignKey(
|
||||
"post_attachment.id",
|
||||
ondelete="SET NULL",
|
||||
name="fk_image_provenance_from_attachment",
|
||||
),
|
||||
nullable=True, index=True,
|
||||
)
|
||||
captured_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
@@ -14,10 +14,13 @@ from sqlalchemy import (
|
||||
Enum,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -29,11 +32,38 @@ ORIGIN_CHOICES = ("downloaded", "imported_filesystem", "uploaded")
|
||||
class ImageRecord(Base):
|
||||
__tablename__ = "image_record"
|
||||
|
||||
|
||||
__table_args__ = (
|
||||
# alembic 0001. The database enforces sha256 uniqueness with a
|
||||
# CONSTRAINT and carries a SEPARATE non-unique btree index; the model
|
||||
# said `unique=True, index=True`, which collapses both into a single
|
||||
# UNIQUE index under a different name. Same guarantee either way, but
|
||||
# not the same objects, so autogenerate saw a drop and an add (#3275).
|
||||
UniqueConstraint("sha256", name="uq_image_record_sha256"),
|
||||
# alembic 0036, and the last thing in this schema that lived only in a
|
||||
# migration. SQLAlchemy CAN express an hnsw index with an operator
|
||||
# class, so there is no reason for it to be invisible to the models —
|
||||
# and its absence was the quietest failure of the lot: everything
|
||||
# works, similarity search just silently stops using an index.
|
||||
Index(
|
||||
"ix_image_record_siglip_hnsw",
|
||||
"siglip_embedding",
|
||||
postgresql_using="hnsw",
|
||||
postgresql_ops={"siglip_embedding": "vector_cosine_ops"},
|
||||
),
|
||||
# alembic 0035/0071: the date-ordered browse indexes (#3275).
|
||||
Index("ix_image_record_effective_date", text("effective_date DESC"), text("id DESC")),
|
||||
Index("ix_image_record_earliest_post_date", text("earliest_post_date DESC"), text("id DESC")),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
# On-disk identity
|
||||
path: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
sha256: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
# Neither unique= nor index=: uq_image_record_sha256 in __table_args__
|
||||
# above creates its own index, and the separate ix_image_record_sha256
|
||||
# that 0001 also built was an exact duplicate of it — dropped in 0089
|
||||
# (#3301). Lookups by sha256 use the constraint's index.
|
||||
sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
phash: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
mime: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
@@ -47,7 +77,8 @@ class ImageRecord(Base):
|
||||
# Integrity verification status. FC-2e populates this; FC-2a leaves rows at 'unknown'.
|
||||
# Values: 'unknown' (default), 'ok', 'corrupt', 'failed_verification'.
|
||||
integrity_status: Mapped[str] = mapped_column(
|
||||
String(24), nullable=False, default="unknown", index=True
|
||||
String(24), nullable=False, default="unknown", index=True,
|
||||
server_default="unknown",
|
||||
)
|
||||
|
||||
# Thumbnail (populated by FC-2)
|
||||
@@ -72,8 +103,15 @@ class ImageRecord(Base):
|
||||
)
|
||||
# FC-2d-vii-c: canonical per-image artist (the single source of truth
|
||||
# for attribution; provenance posts remain lineage detail).
|
||||
# FK named explicitly: the naming convention renders this
|
||||
# `fk_image_record_artist_id_artist`, but alembic 0008 created it as
|
||||
# `fk_image_record_artist_id` (#3275).
|
||||
artist_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
ForeignKey(
|
||||
"artist.id", ondelete="SET NULL", name="fk_image_record_artist_id"
|
||||
),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# ML fields (populated by the ml-worker / GPU agent). 1152 = SigLIP-so400m
|
||||
|
||||
@@ -21,17 +21,17 @@ class ImportBatch(Base):
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
total_files: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
imported: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
attachments: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
total_files: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
imported: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
attachments: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
# Deep-scan only: count of already-imported files whose sidecar metadata
|
||||
# got re-applied this run (post/source/provenance upsert). Stays 0 on
|
||||
# quick-scan batches. See `Importer.import_one(deep_scan=True)`.
|
||||
refreshed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
refreshed: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running", index=True)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running", index=True, server_default="running")
|
||||
# running | complete | cancelled
|
||||
|
||||
tasks = relationship("ImportTask", back_populates="batch", cascade="all, delete-orphan")
|
||||
|
||||
@@ -4,7 +4,15 @@ Enforced as a single row via a CHECK (id = 1) constraint. The application
|
||||
always SELECTs id=1 and never inserts/deletes after the initial migration.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, CheckConstraint, Float, Integer, Text, select
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Float,
|
||||
Integer,
|
||||
Text,
|
||||
select,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -14,63 +22,79 @@ class ImportSettings(Base):
|
||||
__tablename__ = "import_settings"
|
||||
# Bare constraint name — Base.metadata's naming convention applies the
|
||||
# ck_<table>_<name> prefix, producing the final ck_import_settings_singleton.
|
||||
# Bare name — Base.metadata's naming convention prepends ck_<table>_,
|
||||
# producing ck_import_settings_singleton. The chain shipped the DOUBLED
|
||||
# ck_import_settings_ck_import_settings_singleton, because the migration
|
||||
# pre-prefixed the name and the convention prefixed it again; alembic
|
||||
# 0088 renames it to what this line has always produced (#3275).
|
||||
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
import_scan_path: Mapped[str] = mapped_column(Text, nullable=False, default="/import")
|
||||
import_scan_path: Mapped[str] = mapped_column(Text, nullable=False, default="/import", server_default="/import")
|
||||
|
||||
min_width: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
min_height: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
min_width: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
min_height: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
|
||||
skip_transparent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
transparency_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.9)
|
||||
skip_transparent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
transparency_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.9, server_default="0.9")
|
||||
|
||||
skip_single_color: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
single_color_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.95)
|
||||
single_color_tolerance: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
|
||||
skip_single_color: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
single_color_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.95, server_default="0.95")
|
||||
single_color_tolerance: Mapped[int] = mapped_column(Integer, nullable=False, default=30, server_default="30")
|
||||
|
||||
phash_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
|
||||
phash_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=10, server_default="10")
|
||||
|
||||
# FC-3c downloader knobs
|
||||
download_rate_limit_seconds: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=3.0
|
||||
Float, nullable=False, default=3.0,
|
||||
server_default="3",
|
||||
)
|
||||
download_validate_files: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
Boolean, nullable=False, default=True,
|
||||
server_default="true",
|
||||
)
|
||||
|
||||
# FC-3d scheduling knobs
|
||||
download_schedule_default_seconds: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=28800
|
||||
Integer, nullable=False, default=28800,
|
||||
server_default="28800",
|
||||
)
|
||||
download_event_retention_days: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=90
|
||||
Integer, nullable=False, default=90,
|
||||
server_default="90",
|
||||
)
|
||||
download_failure_warning_threshold: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=5
|
||||
Integer, nullable=False, default=5,
|
||||
server_default="5",
|
||||
)
|
||||
|
||||
# FC-3h backup knobs.
|
||||
backup_db_nightly_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False,
|
||||
server_default="false",
|
||||
)
|
||||
backup_db_nightly_hour_utc: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=3,
|
||||
server_default="3",
|
||||
)
|
||||
backup_db_keep_last_n: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=14,
|
||||
server_default="14",
|
||||
)
|
||||
backup_images_keep_last_n: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=3,
|
||||
server_default="3",
|
||||
)
|
||||
|
||||
# FC-6.3 series continuation matcher. enabled gates the rescan; threshold is
|
||||
# the weighted-score cut-off (0..1) above which a pending suggestion is made.
|
||||
series_suggest_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True,
|
||||
server_default="true",
|
||||
)
|
||||
series_suggest_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.5,
|
||||
server_default="0.5",
|
||||
)
|
||||
|
||||
# #830 off-platform file-host downloads — per-host enable lever (default on,
|
||||
@@ -113,7 +137,9 @@ class ImportSettings(Base):
|
||||
# English (e.g. "… WIP Part 1") as a European language at ~0.86. CJK stays
|
||||
# trusted regardless (script-detected). Per-post overrides handle the misses.
|
||||
translation_min_confidence: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.9, server_default="0.9",
|
||||
# text() because alembic 0084 used sa.text(); see ml_settings for why
|
||||
# the form matters and why it is per-column (#3275).
|
||||
Float, nullable=False, default=0.9, server_default=text("0.9"),
|
||||
)
|
||||
|
||||
# Title-based WIP auto-tagging (task #1458). When a freshly-imported post's
|
||||
|
||||
@@ -13,10 +13,12 @@ from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -26,6 +28,12 @@ from .base import Base
|
||||
class ImportTask(Base):
|
||||
__tablename__ = "import_task"
|
||||
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_import_task_created_at_desc", text("created_at DESC")),
|
||||
# Unindexed FK (#3300).
|
||||
Index("ix_import_task_result_image_id", "result_image_id"),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
batch_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("import_batch.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
@@ -33,14 +41,14 @@ class ImportTask(Base):
|
||||
|
||||
source_path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
task_type: Mapped[str] = mapped_column(String(16), nullable=False) # media|archive
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True, server_default="pending")
|
||||
|
||||
# Poison-pill circuit breaker (alembic 0026). recovery_count tracks
|
||||
# how many times the stuck-task sweep has re-queued this row; after
|
||||
# the cap it's failed with a diagnostic instead of looping. refetched
|
||||
# bounds the one-shot re-download remediation to a single attempt.
|
||||
recovery_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
refetched: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
recovery_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
refetched: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
||||
|
||||
result_image_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("image_record.id", ondelete="SET NULL"), nullable=True
|
||||
|
||||
@@ -8,7 +8,7 @@ reads it and routes through cleanup_service.delete_images.
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text, func
|
||||
from sqlalchemy import DateTime, Integer, String, Text, func, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -23,6 +23,7 @@ class LibraryAuditRun(Base):
|
||||
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="running", index=True,
|
||||
server_default="running",
|
||||
)
|
||||
# running | ready | applied | cancelled | error
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
@@ -31,14 +32,16 @@ class LibraryAuditRun(Base):
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
scanned_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
matched_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
matched_ids: Mapped[list[int]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
scanned_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
matched_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
matched_ids: Mapped[list[int]] = mapped_column(
|
||||
JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb")
|
||||
)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Chunked-scan state (alembic 0039): keyset cursor the next chunk resumes
|
||||
# from, and the last time a chunk made progress (so the recovery sweep can
|
||||
# tell a progressing multi-chunk audit from a stuck one).
|
||||
resume_after_id: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
resume_after_id: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
last_progress_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,8 @@ from sqlalchemy import (
|
||||
Integer,
|
||||
String,
|
||||
func,
|
||||
select,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -19,7 +21,10 @@ from .base import Base
|
||||
class MLSettings(Base):
|
||||
__tablename__ = "ml_settings"
|
||||
# Bare name — Base.metadata's naming convention prepends ck_<table>_,
|
||||
# producing the final ck_ml_settings_singleton (matches migration 0003).
|
||||
# producing ck_ml_settings_singleton. The chain shipped the DOUBLED
|
||||
# ck_ml_settings_ck_ml_settings_singleton, because the migration
|
||||
# pre-prefixed the name and the convention prefixed it again; alembic
|
||||
# 0088 renames it to what this line has always produced (#3275).
|
||||
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
@@ -30,17 +35,20 @@ class MLSettings(Base):
|
||||
# queueing embed work nothing will consume (the daily GPU 'embed' backfill
|
||||
# covers those images instead).
|
||||
cpu_embed_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
Boolean, nullable=False, default=True,
|
||||
server_default="true",
|
||||
)
|
||||
# Video embedding (#747). Sample one frame every N seconds (fixed CADENCE, not
|
||||
# a fixed count) so coverage reflects real screen time regardless of length;
|
||||
# cap the total so a long video can't explode into hundreds of embeds. The
|
||||
# per-frame SigLIP embeddings are mean-pooled. Operator-tunable.
|
||||
video_frame_interval_seconds: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=4.0
|
||||
Float, nullable=False, default=4.0,
|
||||
server_default="4",
|
||||
)
|
||||
video_max_frames: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=64
|
||||
Integer, nullable=False, default=64,
|
||||
server_default="64",
|
||||
)
|
||||
# Tagging-v2 head training (#114). The head is the suggestion source that
|
||||
# LEARNS from the operator's tags (replacing Camie + centroid). A concept
|
||||
@@ -48,10 +56,12 @@ class MLSettings(Base):
|
||||
# head_auto_apply_precision is the precision bar a head must clear (at some
|
||||
# operating point) to "graduate" into earned auto-apply. Operator-tunable.
|
||||
head_min_positives: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=8
|
||||
Integer, nullable=False, default=8,
|
||||
server_default="8",
|
||||
)
|
||||
head_auto_apply_precision: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.97
|
||||
Float, nullable=False, default=0.97,
|
||||
server_default="0.97",
|
||||
)
|
||||
# Earned auto-apply (#114). A graduated head fires (tags images without a
|
||||
# human) when this master switch is on AND the head has at least
|
||||
@@ -60,29 +70,34 @@ class MLSettings(Base):
|
||||
# default (operator-asked 2026-06-29: opt-OUT, not opt-in); the support +
|
||||
# measured-precision gates keep it safe, and every auto-tag is reversible.
|
||||
head_auto_apply_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
Boolean, nullable=False, default=True,
|
||||
server_default="true",
|
||||
)
|
||||
head_auto_apply_min_positives: Mapped[int] = mapped_column(
|
||||
# Support floor raised 30→50 (operator-asked 2026-07-06): a head needs
|
||||
# more human labels before it may fire without a human.
|
||||
Integer, nullable=False, default=50
|
||||
Integer, nullable=False, default=50,
|
||||
server_default="30",
|
||||
)
|
||||
# CCIP character-match cosine cut (#114). 0.85 default — the v1 flat 0.75
|
||||
# over-fired (high-reference characters matched a scatter of images); 0.85
|
||||
# keeps the confident single-character matches. Tunable from the agent card.
|
||||
ccip_match_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.85
|
||||
Float, nullable=False, default=0.85,
|
||||
server_default="0.85",
|
||||
)
|
||||
# CCIP auto-apply (#114). Confident matches (>= ccip_auto_apply_threshold,
|
||||
# above the suggest cut) auto-tag on a daily sweep. ON by default (opt-out);
|
||||
# single-character references + the high bar keep it safe, every tag reversible.
|
||||
ccip_auto_apply_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
Boolean, nullable=False, default=True,
|
||||
server_default="true",
|
||||
)
|
||||
ccip_auto_apply_threshold: Mapped[float] = mapped_column(
|
||||
# Raised 0.92→0.95 (operator-asked 2026-07-06) so only very confident
|
||||
# character matches auto-tag.
|
||||
Float, nullable=False, default=0.95
|
||||
Float, nullable=False, default=0.95,
|
||||
server_default="0.92",
|
||||
)
|
||||
# -- Presentation chrome auto-hide (#141) -------------------------------
|
||||
# `banner` (chrome — clusters on UI, not content) auto-applies on the sweep
|
||||
@@ -94,13 +109,21 @@ class MLSettings(Base):
|
||||
# (opt-out); every auto-tag is reversible. NOTE (#1464): `wip` + `editor
|
||||
# screenshot` are no longer chrome — they went to the PROCESS path below.
|
||||
presentation_auto_apply_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
Boolean, nullable=False, default=True,
|
||||
server_default="true",
|
||||
)
|
||||
presentation_auto_apply_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.90
|
||||
Float, nullable=False, default=0.90,
|
||||
# text(), not a string, because alembic 0082 used sa.text(): a bare
|
||||
# string renders DEFAULT '0.90'::double precision while text() renders
|
||||
# DEFAULT 0.90, and the chain is MIXED — some migrations used one,
|
||||
# some the other. Same value, different stored expression, so each
|
||||
# column here mirrors whichever form its own migration used (#3275).
|
||||
server_default=text("0.90"),
|
||||
)
|
||||
presentation_conflict_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.50
|
||||
Float, nullable=False, default=0.50,
|
||||
server_default=text("0.50"),
|
||||
)
|
||||
# -- Process auto-apply (#1464) ----------------------------------------
|
||||
# `wip` / `editor screenshot` are PROCESS art — unfinished pieces + program
|
||||
@@ -114,24 +137,29 @@ class MLSettings(Base):
|
||||
# (PresentationReview, mode='process') rather than silently marked. OFF by
|
||||
# default — a new whole-library auto-tagger is opt-in; every auto-tag reversible.
|
||||
process_auto_apply_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
Boolean, nullable=False, default=False,
|
||||
server_default="false",
|
||||
)
|
||||
process_auto_apply_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.90
|
||||
Float, nullable=False, default=0.90,
|
||||
server_default="0.90",
|
||||
)
|
||||
process_conflict_threshold: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.50
|
||||
Float, nullable=False, default=0.50,
|
||||
server_default="0.50",
|
||||
)
|
||||
# Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
|
||||
# existing libraries keep their stored value until the operator re-embeds.
|
||||
embedder_model_version: Mapped[str] = mapped_column(
|
||||
String(128), nullable=False, default="siglip2-so400m-patch16-512"
|
||||
String(128), nullable=False, default="siglip2-so400m-patch16-512",
|
||||
server_default="siglip2-so400m-patch16-512",
|
||||
)
|
||||
# The HF model NAME the embedder loads (server CPU embed + announced to the
|
||||
# GPU agent in the lease). Operator-settable so the embedder is a choice, not
|
||||
# a hardcode (#1190): set name + version together, then re-embed + retrain.
|
||||
embedder_model_name: Mapped[str] = mapped_column(
|
||||
String(128), nullable=False, default="google/siglip2-so400m-patch16-512"
|
||||
String(128), nullable=False, default="google/siglip2-so400m-patch16-512",
|
||||
server_default="google/siglip2-so400m-patch16-512",
|
||||
)
|
||||
# -- Crop proposers / detectors (#1202, #134) --------------------------
|
||||
# WHERE-to-crop YOLO detectors feeding the crop→SigLIP bag + CCIP. Config
|
||||
@@ -144,20 +172,24 @@ class MLSettings(Base):
|
||||
# person: general COCO figure detector for Western/realistic art the anime
|
||||
# person-detector misses → NMS-merged with imgutils → CCIP + concept.
|
||||
detector_person_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
Boolean, nullable=False, default=True,
|
||||
server_default="true",
|
||||
)
|
||||
detector_person_weights: Mapped[str] = mapped_column(
|
||||
String(512), nullable=False, default="yolo11n.pt"
|
||||
String(512), nullable=False, default="yolo11n.pt",
|
||||
server_default="yolo11n.pt",
|
||||
)
|
||||
detector_person_conf: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.35
|
||||
Float, nullable=False, default=0.35,
|
||||
server_default=text("0.35"),
|
||||
)
|
||||
# anatomy: booru_yolo anime/furry/NSFW torso components → concept crops.
|
||||
# Default = yolov11m_aa22 (26 classes, best mAP50-95 0.96), committed in the
|
||||
# upstream repo so the URL resolves. License UNSTATED — fine for a private
|
||||
# homelab (operator accepted #1202).
|
||||
detector_anatomy_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
Boolean, nullable=False, default=True,
|
||||
server_default="true",
|
||||
)
|
||||
detector_anatomy_weights: Mapped[str] = mapped_column(
|
||||
String(512), nullable=False,
|
||||
@@ -165,37 +197,47 @@ class MLSettings(Base):
|
||||
"https://github.com/aperveyev/booru_yolo/raw/main/models/"
|
||||
"yolov11m_aa22.pt"
|
||||
),
|
||||
server_default="https://github.com/aperveyev/booru_yolo/raw/main/models/yolov11m_aa22.pt",
|
||||
)
|
||||
detector_anatomy_conf: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.30
|
||||
Float, nullable=False, default=0.30,
|
||||
server_default=text("0.30"),
|
||||
)
|
||||
# panel: comic page → panel regions → concept crops (Apache-2.0, YOLOv12x).
|
||||
detector_panel_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True
|
||||
Boolean, nullable=False, default=True,
|
||||
server_default="true",
|
||||
)
|
||||
detector_panel_weights: Mapped[str] = mapped_column(
|
||||
String(512), nullable=False,
|
||||
default="mosesb/best-comic-panel-detection::best.pt",
|
||||
server_default="mosesb/best-comic-panel-detection::best.pt",
|
||||
)
|
||||
detector_panel_conf: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.30
|
||||
Float, nullable=False, default=0.30,
|
||||
server_default=text("0.30"),
|
||||
)
|
||||
# Per-frame caps bound the crop→embed explosion; max_regions is the hard
|
||||
# per-job backstop; dedupe_iou drops near-duplicate crops before the embed.
|
||||
detector_max_figures: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=8
|
||||
Integer, nullable=False, default=8,
|
||||
server_default="8",
|
||||
)
|
||||
detector_max_components: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=8
|
||||
Integer, nullable=False, default=8,
|
||||
server_default="8",
|
||||
)
|
||||
detector_max_panels: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=8
|
||||
Integer, nullable=False, default=8,
|
||||
server_default="8",
|
||||
)
|
||||
detector_max_regions: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=128
|
||||
Integer, nullable=False, default=128,
|
||||
server_default="128",
|
||||
)
|
||||
detector_dedupe_iou: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.85
|
||||
Float, nullable=False, default=0.85,
|
||||
server_default=text("0.85"),
|
||||
)
|
||||
# -- CCIP character prototypes (#1317) ---------------------------------
|
||||
# The per-character reference set is precomputed + refreshed INCREMENTALLY
|
||||
@@ -207,8 +249,20 @@ class MLSettings(Base):
|
||||
String(128), nullable=True
|
||||
)
|
||||
ccip_prototype_cap: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=64
|
||||
Integer, nullable=False, default=64,
|
||||
server_default="64",
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def load(cls, session) -> MLSettings:
|
||||
"""The singleton settings row (id=1), via an async session. Mirrors
|
||||
ImportSettings.load — the shared singleton-loader pattern."""
|
||||
return (await session.execute(select(cls).where(cls.id == 1))).scalar_one()
|
||||
|
||||
@classmethod
|
||||
def load_sync(cls, session) -> MLSettings:
|
||||
"""The singleton settings row (id=1), via a sync session."""
|
||||
return session.execute(select(cls).where(cls.id == 1)).scalar_one()
|
||||
|
||||
@@ -35,7 +35,7 @@ class PatreonFailedMedia(Base):
|
||||
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
first_failed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
|
||||
@@ -35,7 +35,7 @@ class PixivFailedMedia(Base):
|
||||
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
first_failed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
|
||||
@@ -13,11 +13,13 @@ from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -27,6 +29,10 @@ from .base import Base
|
||||
class Post(Base):
|
||||
__tablename__ = "post"
|
||||
__table_args__ = (
|
||||
# alembic 0030. The comment above described this index; nothing declared
|
||||
# it, so autogenerate proposed dropping it (#3275).
|
||||
Index("uq_post_artist_external_id_null_source", "artist_id", "external_post_id",
|
||||
unique=True, postgresql_where=text("source_id IS NULL")),
|
||||
# Source-bound dedup. Postgres treats NULL != NULL so rows
|
||||
# with source_id IS NULL aren't deduped by this constraint;
|
||||
# the partial unique index `uq_post_artist_external_id_null_source`
|
||||
@@ -35,7 +41,11 @@ class Post(Base):
|
||||
UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"),
|
||||
CheckConstraint(
|
||||
"translation_override IN ('auto', 'force', 'original')",
|
||||
name="ck_post_translation_override",
|
||||
# Bare name: Base.metadata's naming convention prepends
|
||||
# ck_<table>_. Pre-prefixing it here doubles the prefix — see
|
||||
# alembic 0088, which renames the four constraints that shipped
|
||||
# that way (#3275).
|
||||
name="translation_override",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -65,3 +65,15 @@ class PostAttachment(Base):
|
||||
captured_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
def attachment_download_url(attachment_id: int) -> str:
|
||||
"""The path that streams this attachment's bytes.
|
||||
|
||||
Both serializers that expose an attachment to the frontend
|
||||
(`provenance_service`, `post_feed_service`) built this literal themselves,
|
||||
so changing the route in `api/attachments.py` meant two edits and only one
|
||||
would be remembered (#3072). `test_attachment_download_url` pins it against
|
||||
the app's registered rule, so the drift is caught rather than trusted to.
|
||||
"""
|
||||
return f"/api/attachments/{attachment_id}/download"
|
||||
|
||||
@@ -11,7 +11,7 @@ are pruned by retention.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, String, func
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Index, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -20,6 +20,14 @@ from .base import Base
|
||||
class PresentationReview(Base):
|
||||
__tablename__ = "presentation_review"
|
||||
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_presentation_review_resolved_at", "resolved_at"),
|
||||
# Both FKs to tag were unindexed (#3300); tag_id CASCADEs, so a tag
|
||||
# delete had to scan this table to find its rows.
|
||||
Index("ix_presentation_review_tag_id", "tag_id"),
|
||||
Index("ix_presentation_review_conflict_tag_id", "conflict_tag_id"),
|
||||
)
|
||||
image_record_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("image_record.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
|
||||
@@ -16,7 +16,14 @@ title is the optional chapter name; stated_part is the optional operator-facing
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text, func
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -25,14 +32,26 @@ from .base import Base
|
||||
class SeriesChapter(Base):
|
||||
__tablename__ = "series_chapter"
|
||||
|
||||
__table_args__ = (
|
||||
# alembic 0047 named the UNIQUE `uq_series_chapter_anchor_page`, not
|
||||
# the `uq_series_chapter_anchor_page_id` a bare `unique=True` would
|
||||
# render (#3275).
|
||||
UniqueConstraint("anchor_page_id", name="uq_series_chapter_anchor_page"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
series_tag_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# Both the UNIQUE (above) and the FK carry the names 0047 gave them; the
|
||||
# convention would render the FK `fk_series_chapter_anchor_page_id_series_page`.
|
||||
anchor_page_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("series_page.id", ondelete="CASCADE"),
|
||||
ForeignKey(
|
||||
"series_page.id",
|
||||
ondelete="CASCADE",
|
||||
name="fk_series_chapter_anchor_page",
|
||||
),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
stated_part: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
@@ -14,7 +14,14 @@ number parsed from the source post, nullable when unknown.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -23,14 +30,22 @@ from .base import Base
|
||||
class SeriesPage(Base):
|
||||
__tablename__ = "series_page"
|
||||
|
||||
__table_args__ = (
|
||||
# alembic 0005 named this `uq_series_page_image`; a bare `unique=True`
|
||||
# on the column renders `uq_series_page_image_id` under the naming
|
||||
# convention, which is a different object from the one the database
|
||||
# has (#3275).
|
||||
UniqueConstraint("image_id", name="uq_series_page_image"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
series_tag_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# UNIQUE lives in __table_args__ above, under the name 0005 gave it.
|
||||
image_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("image_record.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
# 'placed' = in the series-global run (page_number set); 'pending' = staged
|
||||
# from a post awaiting the operator's sort (page_number NULL). (#789 P2)
|
||||
|
||||
@@ -5,7 +5,16 @@ Multiple sources per artist support creators with cross-platform presence.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .base import Base
|
||||
@@ -14,13 +23,27 @@ from .base import Base
|
||||
class Source(Base):
|
||||
__tablename__ = "source"
|
||||
|
||||
__table_args__ = (
|
||||
# alembic 0010. One row per (artist, platform, url): re-adding a source
|
||||
# the artist already has is an update, not a second row. The model had
|
||||
# never declared it (#3275), so autogenerate would have proposed
|
||||
# DROPPING it — the guarantee existed only in the migration chain.
|
||||
#
|
||||
# Named explicitly because the naming convention would render this
|
||||
# `uq_source_artist_id` (uq keys off column_0_name), which is both
|
||||
# wrong about the shape and not what the database actually has.
|
||||
UniqueConstraint(
|
||||
"artist_id", "platform", "url", name="uq_source_artist_platform_url"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
artist_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("artist.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
platform: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
|
||||
|
||||
config_overrides: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
@@ -32,7 +55,7 @@ class Source(Base):
|
||||
# by _update_source_health alongside last_error; cleared on 'ok'.
|
||||
error_type: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
check_interval_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
|
||||
# alembic 0031: sticky deep-scan budget. When > 0, the next N download
|
||||
# runs use gallery-dl's full-walk config (skip: True + 1800s timeout);
|
||||
|
||||
@@ -34,7 +34,7 @@ class SubscribeStarFailedMedia(Base):
|
||||
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
first_failed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
|
||||
@@ -15,11 +15,13 @@ from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Table,
|
||||
false,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy import (
|
||||
Enum as SQLEnum,
|
||||
@@ -67,17 +69,31 @@ image_tag = Table(
|
||||
primary_key=True,
|
||||
),
|
||||
Column("tag_id", ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("source", String(32), nullable=False, default="manual"),
|
||||
Column("source", String(32), nullable=False, default="manual", server_default="manual"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
# The PK is (image_record_id, tag_id), which leads with the WRONG column
|
||||
# for the two things that matter most here (#3300): the gallery's tag
|
||||
# filter (tag_query.py builds `image_tag.c.tag_id == tid`) and the
|
||||
# ON DELETE CASCADE from tag, which has to find a tag's rows to remove
|
||||
# them. Without this index both scan the largest table in the schema.
|
||||
Index("ix_image_tag_tag_id", "tag_id"),
|
||||
)
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = "tag"
|
||||
__table_args__ = (
|
||||
# alembic 0002. An EXPRESSION index — COALESCE cannot be expressed as a
|
||||
# UniqueConstraint, which is why it only ever existed in a migration (#3275).
|
||||
Index("uq_tag_name_kind_fandom", "name", "kind", text("COALESCE(fandom_id, 0)"),
|
||||
unique=True),
|
||||
CheckConstraint(
|
||||
"(fandom_id IS NULL) OR (kind = 'character')",
|
||||
name="ck_tag_fandom_requires_character",
|
||||
# Bare name: Base.metadata's naming convention prepends
|
||||
# ck_<table>_. Pre-prefixing it here doubles the prefix — see
|
||||
# alembic 0088, which renames the four constraints that shipped
|
||||
# that way (#3275).
|
||||
name="fandom_requires_character",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -87,6 +103,7 @@ class Tag(Base):
|
||||
SQLEnum(TagKind, name="tag_kind", values_callable=lambda e: [m.value for m in e]),
|
||||
nullable=False,
|
||||
default=TagKind.general,
|
||||
server_default="general",
|
||||
)
|
||||
fandom_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("tag.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
|
||||
@@ -5,7 +5,7 @@ in image_prediction stay unmolested.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -14,10 +14,17 @@ from .base import Base
|
||||
class TagAlias(Base):
|
||||
__tablename__ = "tag_alias"
|
||||
|
||||
|
||||
__table_args__ = (
|
||||
# Named explicitly: the database calls this ix_tag_alias_canonical, while
|
||||
# a bare index=True on the column would generate ix_tag_alias_canonical_tag_id
|
||||
# and silently propose a drop+create on the next autogenerate (#3275).
|
||||
Index("ix_tag_alias_canonical", "canonical_tag_id"),
|
||||
)
|
||||
alias_string: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||
alias_category: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
canonical_tag_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
|
||||
@@ -5,7 +5,7 @@ Prevents re-suggestion AND prevents allowlist auto-apply on that image.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -14,11 +14,24 @@ from .base import Base
|
||||
class TagSuggestionRejection(Base):
|
||||
__tablename__ = "tag_suggestion_rejection"
|
||||
|
||||
|
||||
__table_args__ = (
|
||||
# Named explicitly; see tag_alias for why (#3275).
|
||||
Index("ix_tag_suggestion_rejection_tag", "tag_id"),
|
||||
)
|
||||
# Both FKs named explicitly. alembic 0003 used a hand-shortened `tsr`
|
||||
# prefix; the convention would render the full table name (#3275).
|
||||
image_record_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("image_record.id", ondelete="CASCADE"), primary_key=True
|
||||
ForeignKey(
|
||||
"image_record.id",
|
||||
ondelete="CASCADE",
|
||||
name="fk_tsr_image_record_id_image_record",
|
||||
),
|
||||
primary_key=True,
|
||||
)
|
||||
tag_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True, index=True
|
||||
ForeignKey("tag.id", ondelete="CASCADE", name="fk_tsr_tag_id_tag"),
|
||||
primary_key=True,
|
||||
)
|
||||
rejected_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
|
||||
@@ -15,7 +15,7 @@ backend.app.tasks.maintenance.recover_stalled_task_runs (Beat 5 min).
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy import DateTime, Index, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import Base
|
||||
@@ -24,12 +24,21 @@ from .base import Base
|
||||
class TaskRun(Base):
|
||||
__tablename__ = "task_run"
|
||||
|
||||
|
||||
__table_args__ = (
|
||||
# alembic 0016: the three task-history indexes (#3275).
|
||||
Index("ix_task_run_name_started", "task_name", text("started_at DESC")),
|
||||
Index("ix_task_run_queue_started", "queue", text("started_at DESC")),
|
||||
Index("ix_task_run_status_started", "status", text("started_at DESC")),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
celery_task_id: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, index=True,
|
||||
)
|
||||
queue: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||
task_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
# Neither carries index=True: ix_task_run_queue_started and
|
||||
# ix_task_run_name_started already lead with these columns (#3301).
|
||||
queue: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
task_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
target_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True,
|
||||
@@ -39,7 +48,9 @@ class TaskRun(Base):
|
||||
)
|
||||
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="running", index=True,
|
||||
# No index=True — ix_task_run_status_started leads with `status`.
|
||||
String(16), nullable=False, default="running",
|
||||
server_default="running",
|
||||
)
|
||||
error_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -48,6 +48,47 @@ log = logging.getLogger(__name__)
|
||||
_VIDEO_DURATION_UNKNOWN = -1.0
|
||||
|
||||
|
||||
# -- artist-cascade predicates (rule 93: ONE definition, preview + apply) ---
|
||||
# project_artist_cascade (preview) and delete_artist_cascade (apply) both build
|
||||
# their queries from these. The preview used to re-derive its own — which is how
|
||||
# it came to count images and stay silent about posts and attachments while the
|
||||
# apply destroyed both. Same failure shape as the 2026-06-08 fandom-tag
|
||||
# deletion, where a re-implemented delete predicate diverged from the preview's.
|
||||
# Returned as condition LISTS spread into `.where(*conds)`, matching
|
||||
# _unused_tag_conditions / _bare_post_conditions below.
|
||||
|
||||
|
||||
def _artist_images_conditions(artist_id: int) -> list:
|
||||
"""Images the cascade deletes (rows AND their on-disk files)."""
|
||||
return [ImageRecord.artist_id == artist_id]
|
||||
|
||||
|
||||
def _artist_posts_conditions(artist_id: int) -> list:
|
||||
"""Posts the cascade destroys. The apply never names these — post.artist_id
|
||||
is ondelete=CASCADE, so Postgres takes them when the artist row goes — which
|
||||
is exactly why the preview has to name them: an artist whose posts are
|
||||
body-only (no images) otherwise previews as `images: 0` and reads as an
|
||||
empty artist, while every captured body/description/external-link set is
|
||||
destroyed."""
|
||||
return [Post.artist_id == artist_id]
|
||||
|
||||
|
||||
def _artist_attachments_conditions(artist_id: int) -> list:
|
||||
"""Attachments the cascade deletes. Matched by artist_id OR by the owning
|
||||
post's artist: artist_id is nullable (_capture_attachment leaves it NULL
|
||||
when no artist resolved), so neither arm alone covers every row. The
|
||||
sha-addressed blobs are NOT unlinked (one blob backs many rows) — these are
|
||||
row counts, and the bytes are not part of this operation's footprint."""
|
||||
return [
|
||||
or_(
|
||||
PostAttachment.artist_id == artist_id,
|
||||
PostAttachment.post_id.in_(
|
||||
select(Post.id).where(*_artist_posts_conditions(artist_id))
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def project_artist_cascade(session: Session, *, slug: str) -> dict:
|
||||
"""Read-only projection of what delete_artist_cascade would touch.
|
||||
|
||||
@@ -56,12 +97,17 @@ def project_artist_cascade(session: Session, *, slug: str) -> dict:
|
||||
"artist": {"id": int, "name": str, "slug": str},
|
||||
"projected": {
|
||||
"images": int,
|
||||
"posts": int, # hard-deleted by the post.artist_id CASCADE
|
||||
"attachments": int, # rows deleted; the sha-addressed blobs stay
|
||||
"sources": int,
|
||||
"thumbs": int, # images with a thumbnail_path set
|
||||
"import_tasks": int, # ImportTask rows referencing the artist's images
|
||||
"bytes_on_disk": int, # SUM(image_record.size_bytes) — column is NOT NULL
|
||||
},
|
||||
}
|
||||
Every count is built from the shared `_artist_*_conditions` predicates the
|
||||
apply uses, so the two halves cannot drift (rule 93).
|
||||
|
||||
Raises LookupError if slug not found. No mutations.
|
||||
"""
|
||||
from ..models.import_task import ImportTask
|
||||
@@ -73,36 +119,49 @@ def project_artist_cascade(session: Session, *, slug: str) -> dict:
|
||||
if artist is None:
|
||||
raise LookupError(f"artist slug not found: {slug!r}")
|
||||
|
||||
images_conds = _artist_images_conditions(artist.id)
|
||||
|
||||
images_count = session.execute(
|
||||
select(func.count(ImageRecord.id))
|
||||
.where(ImageRecord.artist_id == artist.id)
|
||||
select(func.count(ImageRecord.id)).where(*images_conds)
|
||||
).scalar_one()
|
||||
posts_count = session.execute(
|
||||
select(func.count(Post.id))
|
||||
.where(*_artist_posts_conditions(artist.id))
|
||||
).scalar_one()
|
||||
attachments_count = session.execute(
|
||||
select(func.count(PostAttachment.id))
|
||||
.where(*_artist_attachments_conditions(artist.id))
|
||||
).scalar_one()
|
||||
# Sources have no shared predicate: the apply never queries them either, it
|
||||
# gets them from the Artist.sources ORM cascade. Counted directly here.
|
||||
sources_count = session.execute(
|
||||
select(func.count(Source.id))
|
||||
.where(Source.artist_id == artist.id)
|
||||
).scalar_one()
|
||||
thumbs_count = session.execute(
|
||||
select(func.count(ImageRecord.id))
|
||||
.where(ImageRecord.artist_id == artist.id)
|
||||
.where(*images_conds)
|
||||
.where(ImageRecord.thumbnail_path.is_not(None))
|
||||
).scalar_one()
|
||||
import_tasks_count = session.execute(
|
||||
select(func.count(ImportTask.id))
|
||||
.where(
|
||||
ImportTask.result_image_id.in_(
|
||||
select(ImageRecord.id).where(ImageRecord.artist_id == artist.id)
|
||||
select(ImageRecord.id).where(*images_conds)
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
bytes_on_disk = session.execute(
|
||||
select(func.coalesce(func.sum(ImageRecord.size_bytes), 0))
|
||||
.where(ImageRecord.artist_id == artist.id)
|
||||
.where(*images_conds)
|
||||
).scalar_one()
|
||||
|
||||
return {
|
||||
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
|
||||
"projected": {
|
||||
"images": images_count,
|
||||
"posts": posts_count,
|
||||
"attachments": attachments_count,
|
||||
"sources": sources_count,
|
||||
"thumbs": thumbs_count,
|
||||
"import_tasks": import_tasks_count,
|
||||
@@ -277,6 +336,10 @@ def delete_artist_cascade(
|
||||
series_page / tag_suggestion_rejection from ImageRecord delete,
|
||||
and source / post / download_event / etc. from Artist delete
|
||||
(via Artist.sources cascade="all, delete-orphan").
|
||||
|
||||
The artist's post_attachment rows are cleared EXPLICITLY before the
|
||||
artist row goes — see the comment at that step; leaving them to the
|
||||
cascade aborts the whole delete on a unique violation.
|
||||
"""
|
||||
artist = session.get(Artist, artist_id)
|
||||
if artist is None:
|
||||
@@ -287,11 +350,22 @@ def delete_artist_cascade(
|
||||
"files_deleted": 0,
|
||||
"thumbs_deleted": 0,
|
||||
"import_tasks_nulled": 0,
|
||||
"posts_deleted": 0,
|
||||
"attachments_deleted": 0,
|
||||
"files_failed": 0,
|
||||
},
|
||||
}
|
||||
artist_info = {"id": artist.id, "name": artist.name, "slug": artist.slug}
|
||||
|
||||
# Counted BEFORE the delete: Postgres takes these via the post.artist_id
|
||||
# CASCADE when the artist row goes, so afterwards there is nothing left to
|
||||
# count. Reported so the summary can be checked against the preview's
|
||||
# `posts` — the parity rule 93 asks for is only testable if both halves
|
||||
# actually state the number.
|
||||
posts_deleted = session.execute(
|
||||
select(func.count(Post.id)).where(*_artist_posts_conditions(artist.id))
|
||||
).scalar_one()
|
||||
|
||||
images_deleted = 0
|
||||
files_deleted = 0
|
||||
thumbs_deleted = 0
|
||||
@@ -300,7 +374,7 @@ def delete_artist_cascade(
|
||||
while True:
|
||||
rows = session.execute(
|
||||
select(ImageRecord)
|
||||
.where(ImageRecord.artist_id == artist.id)
|
||||
.where(*_artist_images_conditions(artist.id))
|
||||
.limit(500)
|
||||
).scalars().all()
|
||||
if not rows:
|
||||
@@ -323,6 +397,28 @@ def delete_artist_cascade(
|
||||
# source_path_prefix matching that's out of scope here.
|
||||
import_tasks_nulled = 0
|
||||
|
||||
# Clear the artist's attachments BEFORE the artist row, or the delete below
|
||||
# aborts. Deleting an artist CASCADEs to Post (post.artist_id is
|
||||
# ondelete=CASCADE), which SET NULLs post_attachment.post_id — and
|
||||
# `uq_post_attachment_null_post_sha` is a partial UNIQUE on sha256 ALONE
|
||||
# WHERE post_id IS NULL, so any two of this artist's attachments sharing a
|
||||
# sha collapse onto one another and raise. That is an ORDINARY shape, not a
|
||||
# corrupt one: _capture_attachment deliberately writes one row per post over
|
||||
# a single sha-addressed blob (a creator who attaches the same pdf to two
|
||||
# posts has two rows), and a pre-existing filesystem-import row with the same
|
||||
# sha and a NULL post_id collides on its own. Migration 0043 reasoned only
|
||||
# about upgrade-time safety and never about this later SET NULL.
|
||||
# _repoint_post_links guards the identical collision class in the reconcile
|
||||
# path; this is its artist-cascade counterpart.
|
||||
#
|
||||
# Which rows count as the artist's — and why the blobs are left on disk —
|
||||
# is _artist_attachments_conditions, shared with the preview.
|
||||
attachments_deleted = session.execute(
|
||||
delete(PostAttachment)
|
||||
.where(*_artist_attachments_conditions(artist.id))
|
||||
).rowcount or 0
|
||||
session.commit()
|
||||
|
||||
session.delete(artist)
|
||||
session.commit()
|
||||
|
||||
@@ -333,6 +429,8 @@ def delete_artist_cascade(
|
||||
"files_deleted": files_deleted,
|
||||
"thumbs_deleted": thumbs_deleted,
|
||||
"import_tasks_nulled": import_tasks_nulled,
|
||||
"posts_deleted": posts_deleted,
|
||||
"attachments_deleted": attachments_deleted,
|
||||
"files_failed": files_failed,
|
||||
},
|
||||
}
|
||||
@@ -1494,3 +1592,155 @@ def purge_gated_previews(
|
||||
"ledger_cleared": ledger_cleared,
|
||||
"posts_deleted": posts_deleted,
|
||||
}
|
||||
|
||||
|
||||
# -- orphaned attachment reclamation ---------------------------------------
|
||||
# PostAttachment's two FKs are both ON DELETE SET NULL, so a deleted post or
|
||||
# artist leaves the row behind rather than taking it. Nothing ever pruned those
|
||||
# rows, and nothing has ever unlinked a file under the attachment store — so
|
||||
# both rows and bytes accumulated permanently and were invisible to every
|
||||
# existing diagnostic.
|
||||
#
|
||||
# Why this is a DISK->DB reconciliation rather than a row sweep: the store is
|
||||
# sha-addressed and idempotent (attachment_store.store), so ONE blob backs MANY
|
||||
# rows. Deleting a row therefore does not free its blob, and — since the artist
|
||||
# cascade now deletes its attachment rows outright — a freed blob has no DB
|
||||
# pointer left to find it by. Walking the store and asking "does any row still
|
||||
# reference this sha?" catches orphans from every cause, including ones no
|
||||
# future delete path will think to report.
|
||||
|
||||
# A blob is written by attachment_store.store BEFORE its row is inserted and
|
||||
# committed, so a just-stored file legitimately has no referencing row for a
|
||||
# moment. Same guard, same reasoning as ORPHAN_TEMP_MIN_AGE_HOURS in
|
||||
# tasks/maintenance.py: never judge a file younger than this.
|
||||
_ATTACHMENT_ORPHAN_MIN_AGE_HOURS = 6
|
||||
|
||||
# Wall-clock budget for the store walk (rule 89). A library with a large
|
||||
# attachment store shouldn't be able to run this past its soft time limit; on
|
||||
# exhaustion it reports partial=True and the operator re-runs to finish.
|
||||
_ATTACHMENT_RECLAIM_BUDGET_SECONDS = 900
|
||||
|
||||
# The store names files `<sha256><ext>`. Parse the sha as the first 64 chars
|
||||
# rather than via Path.stem: store() takes the extension straight from the
|
||||
# source filename, and a URL-encoded basename yields a multi-dot "suffix"
|
||||
# (see [[reference_url_encoded_basename_suffix]]) that would make stem eat part
|
||||
# of the sha. Validating the 64 chars as hex also skips anything else in the
|
||||
# tree that isn't a stored blob.
|
||||
_SHA256_HEX_LEN = 64
|
||||
|
||||
|
||||
def _orphan_attachment_conditions() -> list:
|
||||
"""PostAttachment rows belonging to nothing: both FKs nulled by a deleted
|
||||
post AND a deleted artist. A row with post_id NULL but an artist_id is the
|
||||
deliberate filesystem-import case (importer._capture_attachment writes it
|
||||
that way) and is NOT an orphan — it is still attributed."""
|
||||
return [
|
||||
PostAttachment.post_id.is_(None),
|
||||
PostAttachment.artist_id.is_(None),
|
||||
]
|
||||
|
||||
|
||||
def _is_sha_named(name: str) -> bool:
|
||||
"""True when `name` starts with a 64-char lowercase-hex sha256."""
|
||||
if len(name) < _SHA256_HEX_LEN:
|
||||
return False
|
||||
head = name[:_SHA256_HEX_LEN]
|
||||
return all(c in "0123456789abcdef" for c in head)
|
||||
|
||||
|
||||
def reclaim_orphaned_attachments(
|
||||
session: Session, *, images_root: Path, dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""Prune unattributed PostAttachment rows, then unlink store blobs that no
|
||||
surviving row references.
|
||||
|
||||
Returns (same discovery keys either way, so the UI renders one shape):
|
||||
{"rows": int, # orphan rows found / deleted
|
||||
"files": int, # unreferenced blobs found / unlinked
|
||||
"bytes": int, # their total size
|
||||
"scanned": int, # blobs examined
|
||||
"skipped_recent": int, # blobs under the min-age guard
|
||||
"files_failed": int, # unlink raised (apply only)
|
||||
"partial": bool} # walk hit the time budget
|
||||
|
||||
dry_run computes exactly what the apply would do and mutates nothing — the
|
||||
surviving-sha set is derived by NEGATING the same orphan predicate the
|
||||
delete uses, so the preview cannot disagree with the apply (rule 93).
|
||||
"""
|
||||
started = time.monotonic()
|
||||
orphan_conds = _orphan_attachment_conditions()
|
||||
|
||||
if dry_run:
|
||||
rows = session.execute(
|
||||
select(func.count(PostAttachment.id)).where(*orphan_conds)
|
||||
).scalar_one()
|
||||
else:
|
||||
rows = session.execute(
|
||||
delete(PostAttachment).where(*orphan_conds)
|
||||
).rowcount or 0
|
||||
session.commit()
|
||||
|
||||
# Shas that still have a home. In the apply path the orphan rows are already
|
||||
# gone, so `NOT orphan` is redundant but harmless; in the dry-run path it is
|
||||
# what makes the projection honest about blobs the delete would free. One
|
||||
# predicate, one query, both modes.
|
||||
surviving_shas = set(session.execute(
|
||||
select(PostAttachment.sha256).where(~and_(*orphan_conds)).distinct()
|
||||
).scalars())
|
||||
|
||||
root = Path(images_root) / "attachments"
|
||||
cutoff = (
|
||||
datetime.now(UTC).timestamp()
|
||||
- _ATTACHMENT_ORPHAN_MIN_AGE_HOURS * 3600
|
||||
)
|
||||
files = 0
|
||||
freed_bytes = 0
|
||||
scanned = 0
|
||||
skipped_recent = 0
|
||||
files_failed = 0
|
||||
partial = False
|
||||
|
||||
if root.is_dir():
|
||||
for path in root.rglob("*"):
|
||||
if time.monotonic() - started >= _ATTACHMENT_RECLAIM_BUDGET_SECONDS:
|
||||
partial = True
|
||||
break
|
||||
# .partial staging files belong to cleanup_orphaned_temp_files —
|
||||
# leave them alone rather than racing an in-flight store().
|
||||
if path.suffix in (".part", ".partial") or not path.is_file():
|
||||
continue
|
||||
if not _is_sha_named(path.name):
|
||||
continue
|
||||
scanned += 1
|
||||
sha = path.name[:_SHA256_HEX_LEN]
|
||||
if sha in surviving_shas:
|
||||
continue
|
||||
try:
|
||||
st = path.stat()
|
||||
if st.st_mtime >= cutoff:
|
||||
skipped_recent += 1
|
||||
continue
|
||||
size = st.st_size
|
||||
if not dry_run:
|
||||
path.unlink()
|
||||
files += 1
|
||||
freed_bytes += size
|
||||
except OSError as exc:
|
||||
files_failed += 1
|
||||
log.warning("reclaim_orphaned_attachments: %s: %s", path, exc)
|
||||
|
||||
if not dry_run and (rows or files):
|
||||
log.info(
|
||||
"attachment reclaim: %d orphan row(s) deleted, %d blob(s) unlinked "
|
||||
"(%d bytes), %d failed, partial=%s",
|
||||
rows, files, freed_bytes, files_failed, partial,
|
||||
)
|
||||
return {
|
||||
"rows": rows,
|
||||
"files": files,
|
||||
"bytes": freed_bytes,
|
||||
"scanned": scanned,
|
||||
"skipped_recent": skipped_recent,
|
||||
"files_failed": files_failed,
|
||||
"partial": partial,
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ def _augment_cookies(platform: str, netscape: str) -> str:
|
||||
"""Delegate to the platform's `augment_cookies` hook if one is
|
||||
registered (subscribestar, hentaifoundry, etc. — see
|
||||
`services/platforms/<name>.py`). No-op when the platform doesn't
|
||||
register a hook (Patreon, DeviantArt). Centralizing the
|
||||
register a hook (Patreon, Discord). Centralizing the
|
||||
quirks-per-platform in the platforms package means adding a new
|
||||
platform's cookie quirks doesn't require touching this file."""
|
||||
info = PLATFORMS.get(platform)
|
||||
|
||||
@@ -31,9 +31,8 @@ from .pixiv_ingester import PixivIngester
|
||||
from .subscribestar_ingester import SubscribeStarIngester
|
||||
|
||||
# Platforms whose download + verify go through the native ingester rather than
|
||||
# gallery-dl. gallery-dl still serves the rest (hentaifoundry, discord,
|
||||
# deviantart — the latter slated for retirement, not migration) until they
|
||||
# migrate too.
|
||||
# gallery-dl. gallery-dl still serves the rest (hentaifoundry, discord) until
|
||||
# they migrate too.
|
||||
NATIVE_INGESTER_PLATFORMS = frozenset({"patreon", "subscribestar", "pixiv"})
|
||||
|
||||
# Mirrors patreon_resolver._CAMPAIGNS_URL — surfaced in resolution-failure
|
||||
|
||||
@@ -55,12 +55,6 @@ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||
r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P<slug>[^/?#]+)",
|
||||
re.IGNORECASE,
|
||||
)),
|
||||
("deviantart", re.compile(
|
||||
r"^https?://(?:www\.)?deviantart\.com/"
|
||||
r"(?!home$|watch\b|tag\b|browse\b)"
|
||||
r"(?P<slug>[^/?#]+)/?$",
|
||||
re.IGNORECASE,
|
||||
)),
|
||||
("pixiv", re.compile(
|
||||
r"^https?://(?:www\.)?pixiv\.net/(?:en/)?users/(?P<slug>\d+)",
|
||||
re.IGNORECASE,
|
||||
|
||||
@@ -299,8 +299,9 @@ class GalleryDLService:
|
||||
# (services/patreon_ingester.py), not gallery-dl.
|
||||
PLATFORM_DEFAULTS = {
|
||||
# subscribestar removed — native-ingester platform now (#71); pixiv
|
||||
# removed likewise (#129). The remaining entries are the gallery-dl
|
||||
# platforms not yet migrated.
|
||||
# removed likewise (#129); deviantart removed at #3069 as a dropped
|
||||
# platform, not a migrated one. The remaining entries are the
|
||||
# gallery-dl platforms not yet migrated.
|
||||
"hentaifoundry": {
|
||||
"content_types": ["all"],
|
||||
"directory": [],
|
||||
@@ -316,15 +317,6 @@ class GalleryDLService:
|
||||
"reactions": False,
|
||||
"threads": True,
|
||||
},
|
||||
"deviantart": {
|
||||
"content_types": ["all"],
|
||||
"directory": [],
|
||||
"filename": "{index:>03}_{title[:50]}.{extension}",
|
||||
"flat": True,
|
||||
"original": True,
|
||||
"mature": True,
|
||||
"metadata": True,
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Bulk, idempotent writes to the ``image_tag`` association table.
|
||||
|
||||
Three writers attach tags to images in bulk: the WIP-title backfill
|
||||
(`wip_title.apply_wip_image_tags`), the concept-head auto-apply sweep and the
|
||||
system-tag auto-apply sweep (both in `ml/heads.py`). The two sweeps used to
|
||||
issue ONE INSERT PER ROW from inside their per-image loop — fine in steady
|
||||
state, but a first pass over a back-catalogue is tens of thousands of
|
||||
individual round-trips (#3072). All three share this one chunked multi-row
|
||||
insert now.
|
||||
|
||||
Sync only: every caller runs on a sync ``Session`` (the Celery task path). No
|
||||
async service writes image_tag in bulk, so there is no async sibling to keep in
|
||||
step — unlike `db_helpers.get_or_create`, which does have one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.tag import image_tag
|
||||
|
||||
# 5000 rows x 3 bound params = 15000, comfortably inside Postgres' 65535-param
|
||||
# ceiling for a single statement. Raising this past ~21000 rows would exceed it.
|
||||
INSERT_CHUNK = 5000
|
||||
|
||||
|
||||
def insert_image_tags(
|
||||
session: Session, rows: list[dict], *, chunk: int = INSERT_CHUNK
|
||||
) -> None:
|
||||
"""Attach ``rows`` to their images, skipping any tag already on one.
|
||||
|
||||
Each row is ``{"image_record_id": int, "tag_id": int, "source": str}``.
|
||||
Does NOT commit — the caller owns the transaction.
|
||||
|
||||
ON CONFLICT DO NOTHING against the (image_record_id, tag_id) primary key,
|
||||
so an existing tag keeps its ORIGINAL ``source``: re-running a sweep can
|
||||
never re-stamp a tag the operator applied by hand as machine-applied.
|
||||
|
||||
Returns nothing on purpose. psycopg reports ``rowcount`` -1 for a multi-row
|
||||
ON CONFLICT DO NOTHING insert (it runs via an executemany path), so a count
|
||||
taken from the statement would be a lie rather than an approximation.
|
||||
Callers that need an accurate count derive it themselves — see
|
||||
`wip_title.apply_wip_image_tags`' pre-SELECT, and the sweeps' `skip` sets.
|
||||
"""
|
||||
for start in range(0, len(rows), chunk):
|
||||
session.execute(
|
||||
pg_insert(image_tag)
|
||||
.values(rows[start:start + chunk])
|
||||
.on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"])
|
||||
)
|
||||
@@ -150,9 +150,7 @@ def refresh_character_prototypes(
|
||||
"""Incrementally refresh the prototype store. `full=True` rebuilds every
|
||||
character regardless of the gate/fingerprints (nightly reconcile). Returns
|
||||
{skipped, rebuilt, removed}; commits."""
|
||||
settings = session.execute(
|
||||
select(MLSettings).where(MLSettings.id == 1)
|
||||
).scalar_one()
|
||||
settings = MLSettings.load_sync(session)
|
||||
sig = _global_signature(session)
|
||||
if not full and settings.ccip_ref_signature == sig:
|
||||
return {"skipped": True, "rebuilt": 0, "removed": 0}
|
||||
@@ -204,9 +202,7 @@ def retract_auto_applied_ccip(session: Session) -> int:
|
||||
n_retracted."""
|
||||
import numpy as np
|
||||
|
||||
settings = session.execute(
|
||||
select(MLSettings).where(MLSettings.id == 1)
|
||||
).scalar_one()
|
||||
settings = MLSettings.load_sync(session)
|
||||
if not settings.ccip_auto_apply_enabled:
|
||||
return 0
|
||||
thr = float(settings.ccip_auto_apply_threshold)
|
||||
|
||||
@@ -23,6 +23,7 @@ from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, exists, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -40,8 +41,10 @@ from ...models import (
|
||||
TagSuggestionRejection,
|
||||
)
|
||||
from ...models.tag import CHROME_SYSTEM_TAGS, PROCESS_SYSTEM_TAGS, image_tag
|
||||
from ..image_tag_apply import insert_image_tags
|
||||
from .training_data import (
|
||||
_AUTO_SOURCES,
|
||||
_applied_or_rejected,
|
||||
_auto_apply_point,
|
||||
_hygiene_excluded_ids,
|
||||
_ids_with_tag,
|
||||
@@ -61,6 +64,14 @@ MIN_POSITIVES_FLOOR = 8 # hard floor; settings.head_min_positives can raise
|
||||
_UNLABELED_POOL = 4000
|
||||
_EXAMPLES_MIN = 8 # need at least this many embedded +/- to fit a head
|
||||
|
||||
# Auto-apply / match confidence operating range. Every graduated auto-apply or
|
||||
# CCIP-match threshold the operator can set lives in this band, and the head
|
||||
# precision target is clamped to it: below 0.5 "auto-apply" is meaningless, and
|
||||
# 1.0 is unachievable so 0.999 is the ceiling. One source shared by the service
|
||||
# clamp (_normalize_params) and the API validator (ml_admin._validate).
|
||||
AUTO_APPLY_THRESHOLD_MIN = 0.5
|
||||
AUTO_APPLY_THRESHOLD_MAX = 0.999
|
||||
|
||||
# Only these tag kinds get heads (the surfaced suggestion categories).
|
||||
_HEAD_KINDS = (TagKind.general, TagKind.character)
|
||||
# tag.kind -> the suggestion category the rail groups under.
|
||||
@@ -78,6 +89,38 @@ _CATEGORY = {TagKind.general: "general", TagKind.character: "character"}
|
||||
_SYSTEM_TAG_SUGGEST_FLOOR = 0.65
|
||||
|
||||
|
||||
def _sigmoid(z, np):
|
||||
"""Logistic sigmoid 1/(1+e^-z): the head score→probability transform. One home
|
||||
for what was inlined at every scoring site (suggest, both sweeps, retract)."""
|
||||
return 1.0 / (1.0 + np.exp(-z))
|
||||
|
||||
|
||||
def _conflict_scores(Xn, Wc, bc, np):
|
||||
"""The presentation conflict signal (#141): per row, the MAX content-head
|
||||
probability and WHICH head produced it. Shared by the system-tag sweep's guard-2
|
||||
and the soft-wip audit — both ask "does this ALSO look like real content?"."""
|
||||
cprobs = _sigmoid(Xn @ Wc.T + bc, np)
|
||||
return cprobs.max(axis=1), cprobs.argmax(axis=1)
|
||||
|
||||
|
||||
def _insert_presentation_review(
|
||||
session, *, image_record_id, tag_id, conflict_tag_id, conflict_score, mode,
|
||||
):
|
||||
"""Single-source the ring-loud PresentationReview row shape so the two writers
|
||||
(system-tag sweep guard-2 + soft-wip audit) can't drift on columns or `mode` —
|
||||
they share the (image_record_id, tag_id) composite PK, so a divergent `mode`
|
||||
would be a silent first-writer-wins bug."""
|
||||
session.execute(
|
||||
pg_insert(PresentationReview)
|
||||
.values(
|
||||
image_record_id=image_record_id, tag_id=tag_id,
|
||||
conflict_tag_id=conflict_tag_id, conflict_score=conflict_score,
|
||||
mode=mode,
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
)
|
||||
|
||||
|
||||
class HeadTrainingAlreadyRunning(Exception):
|
||||
"""Raised by start_head_training_run when a run is already in flight."""
|
||||
|
||||
@@ -103,9 +146,7 @@ def start_head_training_run(session: Session, params: dict[str, Any]) -> int:
|
||||
|
||||
|
||||
def _settings(session: Session) -> MLSettings:
|
||||
return session.execute(
|
||||
select(MLSettings).where(MLSettings.id == 1)
|
||||
).scalar_one()
|
||||
return MLSettings.load_sync(session)
|
||||
|
||||
|
||||
def _normalize_params(session: Session, params: dict[str, Any] | None) -> dict[str, Any]:
|
||||
@@ -124,7 +165,7 @@ def _normalize_params(session: Session, params: dict[str, Any] | None) -> dict[s
|
||||
except (TypeError, ValueError):
|
||||
cv_folds = DEFAULT_CV_FOLDS
|
||||
try:
|
||||
precision_target = min(max(float(params.get("precision_target", s.head_auto_apply_precision)), 0.5), 0.999)
|
||||
precision_target = min(max(float(params.get("precision_target", s.head_auto_apply_precision)), AUTO_APPLY_THRESHOLD_MIN), AUTO_APPLY_THRESHOLD_MAX)
|
||||
except (TypeError, ValueError):
|
||||
precision_target = s.head_auto_apply_precision
|
||||
return {
|
||||
@@ -536,7 +577,7 @@ async def score_image(
|
||||
norms[norms == 0] = 1.0
|
||||
Xn = X / norms
|
||||
Z = Xn @ heads["W"].T + heads["b"] # (B, H)
|
||||
probs_bag = 1.0 / (1.0 + np.exp(-Z)) # (B, H)
|
||||
probs_bag = _sigmoid(Z, np) # (B, H)
|
||||
probs = probs_bag.max(axis=0) # (H,) best over the bag
|
||||
# ARGMAX beside the max: WHICH bag row won each head → the region that grounds
|
||||
# the tag (bag_meta[win]); None when the whole-image vector won (#1206).
|
||||
@@ -614,9 +655,7 @@ async def ground_applied_tag(
|
||||
|
||||
|
||||
async def _settings_async(session: AsyncSession) -> MLSettings:
|
||||
return (
|
||||
await session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||
).scalar_one()
|
||||
return await MLSettings.load(session)
|
||||
|
||||
|
||||
# --- Earned auto-apply (sync, ml worker) ---------------------------------
|
||||
@@ -687,7 +726,6 @@ def auto_apply_sweep(
|
||||
embeddings in chunks; commits per chunk on a real run. Returns
|
||||
{n_applied, concepts:[{tag_id,name,applied,scanned,threshold}]}."""
|
||||
import numpy as np
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
settings = _settings(session)
|
||||
rows = _auto_apply_heads(
|
||||
@@ -704,18 +742,7 @@ def auto_apply_sweep(
|
||||
names = [r.name for r in rows]
|
||||
|
||||
# Skip images that already carry, or have rejected, each tag.
|
||||
skip = {tid: set() for tid in tag_ids}
|
||||
for tid in tag_ids:
|
||||
for (iid,) in session.execute(
|
||||
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
|
||||
):
|
||||
skip[tid].add(iid)
|
||||
for (iid,) in session.execute(
|
||||
select(TagSuggestionRejection.image_record_id).where(
|
||||
TagSuggestionRejection.tag_id == tid
|
||||
)
|
||||
):
|
||||
skip[tid].add(iid)
|
||||
skip = _applied_or_rejected(session, tag_ids)
|
||||
|
||||
applied = [0] * len(rows)
|
||||
scanned = 0
|
||||
@@ -729,8 +756,12 @@ def auto_apply_sweep(
|
||||
if not cids:
|
||||
continue
|
||||
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
||||
probs = 1.0 / (1.0 + np.exp(-(Xn @ W.T + b))) # (N, H)
|
||||
probs = _sigmoid(Xn @ W.T + b, np) # (N, H)
|
||||
scanned += len(cids)
|
||||
# Collected across every head, then written as ONE insert below. Was an
|
||||
# insert per applied tag from inside this loop, which on a first sweep
|
||||
# over a back-catalogue is tens of thousands of round-trips (#3072).
|
||||
pending: list[dict] = []
|
||||
for h in range(len(rows)):
|
||||
tid = tag_ids[h]
|
||||
for idx in np.where(probs[:, h] >= thr[h])[0]:
|
||||
@@ -740,12 +771,12 @@ def auto_apply_sweep(
|
||||
skip[tid].add(iid)
|
||||
applied[h] += 1
|
||||
if not dry_run:
|
||||
session.execute(
|
||||
pg_insert(image_tag)
|
||||
.values(image_record_id=iid, tag_id=tid, source="head_auto")
|
||||
.on_conflict_do_nothing()
|
||||
)
|
||||
pending.append({
|
||||
"image_record_id": iid, "tag_id": tid,
|
||||
"source": "head_auto",
|
||||
})
|
||||
if not dry_run:
|
||||
insert_image_tags(session, pending)
|
||||
session.commit()
|
||||
run.last_progress_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
@@ -840,7 +871,6 @@ def system_tag_auto_apply_sweep(
|
||||
enabled flag is set. numpy-only (no sklearn). Returns {n_applied, n_flagged,
|
||||
concepts}."""
|
||||
import numpy as np
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
cfg = _SWEEP_MODES[mode]
|
||||
settings = _settings(session)
|
||||
@@ -869,18 +899,7 @@ def system_tag_auto_apply_sweep(
|
||||
valued = _valued_image_ids(session)
|
||||
|
||||
# Skip images that already carry, or have rejected, each presentation tag.
|
||||
skip = {tid: set() for tid in pres_tag_ids}
|
||||
for tid in pres_tag_ids:
|
||||
for (iid,) in session.execute(
|
||||
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
|
||||
):
|
||||
skip[tid].add(iid)
|
||||
for (iid,) in session.execute(
|
||||
select(TagSuggestionRejection.image_record_id).where(
|
||||
TagSuggestionRejection.tag_id == tid
|
||||
)
|
||||
):
|
||||
skip[tid].add(iid)
|
||||
skip = _applied_or_rejected(session, pres_tag_ids)
|
||||
|
||||
applied = [0] * len(pres)
|
||||
n_flagged = 0
|
||||
@@ -895,12 +914,15 @@ def system_tag_auto_apply_sweep(
|
||||
if not cids:
|
||||
continue
|
||||
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
||||
probs = 1.0 / (1.0 + np.exp(-(Xn @ Wp.T + bp))) # (N, P)
|
||||
probs = _sigmoid(Xn @ Wp.T + bp, np) # (N, P)
|
||||
if Wc is not None:
|
||||
cprobs = 1.0 / (1.0 + np.exp(-(Xn @ Wc.T + bc))) # (N, C)
|
||||
max_c = cprobs.max(axis=1)
|
||||
arg_c = cprobs.argmax(axis=1)
|
||||
max_c, arg_c = _conflict_scores(Xn, Wc, bc, np) # (N,), (N,)
|
||||
scanned += len(cids)
|
||||
# Same batching as auto_apply_sweep (#3072): collect the chunk's rows
|
||||
# and write them once, below. The PresentationReview rows stay per-row —
|
||||
# they FK to image_record/tag, not to image_tag, so writing the tags
|
||||
# after them is safe, and a flagged conflict is rare by construction.
|
||||
pending: list[dict] = []
|
||||
for p in range(len(pres)):
|
||||
tid = pres_tag_ids[p]
|
||||
for idx in np.where(probs[:, p] >= thr)[0]:
|
||||
@@ -910,31 +932,25 @@ def system_tag_auto_apply_sweep(
|
||||
skip[tid].add(iid)
|
||||
applied[p] += 1
|
||||
if not dry_run:
|
||||
session.execute(
|
||||
pg_insert(image_tag)
|
||||
.values(
|
||||
image_record_id=iid, tag_id=tid,
|
||||
source=source,
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
)
|
||||
pending.append({
|
||||
"image_record_id": iid, "tag_id": tid,
|
||||
"source": source,
|
||||
})
|
||||
# Guard 2: also looks like real content → still apply, but flag it
|
||||
# for the review strip instead of silently marking (chrome hides,
|
||||
# process stays visible — either way the operator gets a heads-up).
|
||||
if Wc is not None and float(max_c[idx]) >= conflict_thr:
|
||||
n_flagged += 1
|
||||
if not dry_run:
|
||||
session.execute(
|
||||
pg_insert(PresentationReview)
|
||||
.values(
|
||||
image_record_id=iid, tag_id=tid,
|
||||
conflict_tag_id=conf_tag_ids[int(arg_c[idx])],
|
||||
conflict_score=float(max_c[idx]),
|
||||
mode=mode,
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
_insert_presentation_review(
|
||||
session,
|
||||
image_record_id=iid, tag_id=tid,
|
||||
conflict_tag_id=conf_tag_ids[int(arg_c[idx])],
|
||||
conflict_score=float(max_c[idx]),
|
||||
mode=mode,
|
||||
)
|
||||
if not dry_run:
|
||||
insert_image_tags(session, pending)
|
||||
session.commit()
|
||||
|
||||
concepts = [
|
||||
@@ -956,7 +972,6 @@ def soft_wip_conflict_audit(session: Session, dry_run: bool = False) -> dict:
|
||||
NOT remove the tag; the operator decides. No-op when there are no content heads.
|
||||
numpy-only. Returns {n_scanned, n_flagged}."""
|
||||
import numpy as np
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from ..wip_title import WIP_TITLE_SOFT_SOURCE, resolve_wip_tag_id
|
||||
|
||||
@@ -993,22 +1008,17 @@ def soft_wip_conflict_audit(session: Session, dry_run: bool = False) -> dict:
|
||||
continue
|
||||
scanned += len(cids)
|
||||
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
||||
cprobs = 1.0 / (1.0 + np.exp(-(Xn @ Wc.T + bc)))
|
||||
max_c = cprobs.max(axis=1)
|
||||
arg_c = cprobs.argmax(axis=1)
|
||||
max_c, arg_c = _conflict_scores(Xn, Wc, bc, np)
|
||||
for k in range(len(cids)):
|
||||
if float(max_c[k]) >= conflict_thr:
|
||||
n_flagged += 1
|
||||
if not dry_run:
|
||||
session.execute(
|
||||
pg_insert(PresentationReview)
|
||||
.values(
|
||||
image_record_id=cids[k], tag_id=wip_id,
|
||||
conflict_tag_id=conf_tag_ids[int(arg_c[k])],
|
||||
conflict_score=float(max_c[k]),
|
||||
mode="process",
|
||||
)
|
||||
.on_conflict_do_nothing()
|
||||
_insert_presentation_review(
|
||||
session,
|
||||
image_record_id=cids[k], tag_id=wip_id,
|
||||
conflict_tag_id=conf_tag_ids[int(arg_c[k])],
|
||||
conflict_score=float(max_c[k]),
|
||||
mode="process",
|
||||
)
|
||||
if not dry_run:
|
||||
session.commit()
|
||||
@@ -1062,7 +1072,7 @@ def retract_auto_applied_heads(session: Session) -> int:
|
||||
continue
|
||||
Xn = _l2norm(np.vstack([emb[i] for i in cids]).astype(np.float32), np)
|
||||
w = np.asarray(weights, dtype=np.float32)
|
||||
probs = 1.0 / (1.0 + np.exp(-(Xn @ w + float(bias))))
|
||||
probs = _sigmoid(Xn @ w + float(bias), np)
|
||||
below = [cids[k] for k in np.where(probs < float(thr))[0]]
|
||||
for iid in below:
|
||||
session.execute(
|
||||
|
||||
@@ -94,6 +94,24 @@ def _rejected_ids(session: Session, tag_id: int) -> list[int]:
|
||||
]
|
||||
|
||||
|
||||
def _applied_or_rejected(session: Session, tag_ids) -> dict[int, set[int]]:
|
||||
"""Per-tag skip set for the auto-apply sweeps: every image that ALREADY carries
|
||||
the tag (ANY source — not just training positives) OR has rejected it. A sweep
|
||||
never re-applies to these. Shared by auto_apply_sweep + system_tag_auto_apply_sweep
|
||||
(heads.py) and scheduled_ccip_auto_apply (tasks/ml.py). Callers mutate the returned
|
||||
sets in-place to also dedupe within a single run."""
|
||||
skip: dict[int, set[int]] = {}
|
||||
for tid in tag_ids:
|
||||
ids = {
|
||||
r[0] for r in session.execute(
|
||||
select(image_tag.c.image_record_id).where(image_tag.c.tag_id == tid)
|
||||
).all()
|
||||
}
|
||||
ids.update(_rejected_ids(session, tid))
|
||||
skip[tid] = ids
|
||||
return skip
|
||||
|
||||
|
||||
def _sample_unlabeled(session: Session, exclude: set[int], limit: int) -> list[int]:
|
||||
"""Random image ids (with an embedding) NOT carrying the tag. Concepts are
|
||||
sparse, so an untagged image is almost always a true negative."""
|
||||
|
||||
@@ -91,48 +91,46 @@ def _sync_lookup(vanity: str, cookies_path: str | None) -> str | None:
|
||||
)
|
||||
|
||||
|
||||
def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
|
||||
def _campaigns_api_first(vanity: str, cookies_path: str | None) -> dict | None:
|
||||
"""The first `data` object from Patreon's campaigns API filtered by vanity
|
||||
(`?filter[vanity]=<vanity>&fields[campaign]=name`), or None on any failure
|
||||
(network / non-200 / non-JSON / empty). The single request shape shared by
|
||||
_lookup_via_api (plucks the campaign id) and resolve_display_name (plucks the
|
||||
display name)."""
|
||||
jar = _load_cookie_jar(cookies_path)
|
||||
headers = {
|
||||
"User-Agent": _USER_AGENT,
|
||||
"Accept": "application/vnd.api+json",
|
||||
}
|
||||
params = {
|
||||
"filter[vanity]": vanity,
|
||||
"fields[campaign]": "name",
|
||||
}
|
||||
try:
|
||||
resp = requests.get(
|
||||
_CAMPAIGNS_URL,
|
||||
params=params,
|
||||
headers=headers,
|
||||
params={"filter[vanity]": vanity, "fields[campaign]": "name"},
|
||||
headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"},
|
||||
cookies=jar,
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
log.warning("Patreon campaigns API request failed for vanity=%s: %s", vanity, exc)
|
||||
return None
|
||||
|
||||
if resp.status_code != 200:
|
||||
log.warning(
|
||||
"Patreon campaigns API returned HTTP %d for vanity=%s",
|
||||
resp.status_code, vanity,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError as exc:
|
||||
log.warning("Patreon campaigns API returned non-JSON for vanity=%s: %s", vanity, exc)
|
||||
return None
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
|
||||
return None
|
||||
return data[0]
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
|
||||
def _lookup_via_api(vanity: str, cookies_path: str | None) -> str | None:
|
||||
first = _campaigns_api_first(vanity, cookies_path)
|
||||
if first is None:
|
||||
return None
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, list) or not data:
|
||||
return None
|
||||
first = data[0] if isinstance(data[0], dict) else None
|
||||
campaign_id = first.get("id") if first else None
|
||||
campaign_id = first.get("id")
|
||||
if not isinstance(campaign_id, str) or not campaign_id:
|
||||
return None
|
||||
log.info("Resolved Patreon vanity=%s → campaign_id=%s", vanity, campaign_id)
|
||||
@@ -144,24 +142,10 @@ def resolve_display_name(vanity: str, cookies_path: str | None) -> str | None:
|
||||
(`fields[campaign]=name`), used to name the Artist at add-time (#130). None
|
||||
on any failure — the caller falls back to the vanity handle. Sync: call from
|
||||
an executor."""
|
||||
jar = _load_cookie_jar(cookies_path)
|
||||
try:
|
||||
resp = requests.get(
|
||||
_CAMPAIGNS_URL,
|
||||
params={"filter[vanity]": vanity, "fields[campaign]": "name"},
|
||||
headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.api+json"},
|
||||
cookies=jar,
|
||||
timeout=_TIMEOUT_SECONDS,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json().get("data")
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
log.warning("Patreon name lookup failed for vanity=%s: %s", vanity, exc)
|
||||
first = _campaigns_api_first(vanity, cookies_path)
|
||||
if first is None:
|
||||
return None
|
||||
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
|
||||
return None
|
||||
name = (data[0].get("attributes") or {}).get("name")
|
||||
name = (first.get("attributes") or {}).get("name")
|
||||
return name.strip() if isinstance(name, str) and name.strip() else None
|
||||
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ PLATFORMS below. Sidecar parsing, cookie materialization, and
|
||||
|
||||
Lifted from GallerySubscriber's
|
||||
~/Nextcloud/Projects/GallerySubscriber/backend/app/api/platforms.py
|
||||
and ~/.../extension/lib/platforms.js. Six platforms; auth_type and
|
||||
and ~/.../extension/lib/platforms.js. Five platforms; auth_type and
|
||||
URL patterns match GS exactly so the existing browser extension
|
||||
hits FC unmodified.
|
||||
hits FC unmodified. deviantart was dropped at #3069 (2026-08-27) —
|
||||
FC downloaders are art-dedicated services only.
|
||||
"""
|
||||
|
||||
from .base import (
|
||||
@@ -18,7 +19,6 @@ from .base import (
|
||||
DEFAULT_EXTERNAL_POST_ID_KEYS,
|
||||
PlatformInfo,
|
||||
)
|
||||
from .deviantart import INFO as _DEVIANTART
|
||||
from .discord import INFO as _DISCORD
|
||||
from .hentaifoundry import INFO as _HENTAIFOUNDRY
|
||||
from .patreon import INFO as _PATREON
|
||||
@@ -33,7 +33,6 @@ PLATFORMS: dict[str, PlatformInfo] = {
|
||||
_HENTAIFOUNDRY,
|
||||
_DISCORD,
|
||||
_PIXIV,
|
||||
_DEVIANTART,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class PlatformInfo:
|
||||
# Synthesize a post permalink from sidecar data. Required when
|
||||
# gallery-dl's `url` field is the file/CDN URL rather than the post
|
||||
# permalink (subscribestar/pixiv/hf/discord). None = trust the bare
|
||||
# `url` field (patreon, deviantart).
|
||||
# `url` field (patreon).
|
||||
derive_post_url: Callable[[dict], str | None] | None = None
|
||||
|
||||
# Post-process the materialized cookies.txt for gallery-dl. Used by
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"""DeviantArt — no exercised quirks yet.
|
||||
|
||||
No operator-owned DeviantArt archive existed at the 2026-05-27 sidecar
|
||||
audit, so we don't know yet whether DA's gallery-dl sidecars are
|
||||
well-behaved or have their own quirks. When DA gets exercised for the
|
||||
first time, add `derive_post_url` / `augment_cookies` here as needed.
|
||||
"""
|
||||
|
||||
from .base import GD_DEFAULTS, PlatformInfo
|
||||
|
||||
INFO = PlatformInfo(
|
||||
key="deviantart",
|
||||
name="DeviantArt",
|
||||
description="Download artwork from DeviantArt artists",
|
||||
auth_type="cookies",
|
||||
requires_auth=False,
|
||||
url_pattern=r"^https?://(www\.)?deviantart\.com/",
|
||||
url_examples=[
|
||||
"https://www.deviantart.com/example-artist",
|
||||
"https://www.deviantart.com/example-artist/gallery",
|
||||
],
|
||||
default_config={**GD_DEFAULTS, "content_types": ["gallery"]},
|
||||
)
|
||||
@@ -24,6 +24,7 @@ from ..models import (
|
||||
Post,
|
||||
PostAttachment,
|
||||
Source,
|
||||
attachment_download_url,
|
||||
)
|
||||
from ..utils.html_sanitize import (
|
||||
extract_img_srcs,
|
||||
@@ -360,7 +361,7 @@ class PostFeedService:
|
||||
"ext": att.ext,
|
||||
"mime": att.mime,
|
||||
"size_bytes": att.size_bytes,
|
||||
"download_url": f"/api/attachments/{att.id}/download",
|
||||
"download_url": attachment_download_url(att.id),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from ..models import (
|
||||
Post,
|
||||
PostAttachment,
|
||||
Source,
|
||||
attachment_download_url,
|
||||
)
|
||||
from ..utils.html_sanitize import sanitize_post_html
|
||||
|
||||
@@ -53,7 +54,7 @@ def _attachment_dict(a: PostAttachment) -> dict:
|
||||
"original_filename": a.original_filename,
|
||||
"size_bytes": a.size_bytes,
|
||||
"ext": a.ext,
|
||||
"download_url": f"/api/attachments/{a.id}/download",
|
||||
"download_url": attachment_download_url(a.id),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ family gains one member.
|
||||
import re
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.tag import WIP_SYSTEM_TAG, Tag, image_tag
|
||||
from .image_tag_apply import insert_image_tags
|
||||
|
||||
# image_tag.source stamped on title-heuristic WIP tags — distinct from the other
|
||||
# apply sources so provenance stays legible and a future undo can target only these.
|
||||
@@ -113,13 +113,9 @@ def apply_wip_image_tags(
|
||||
to_insert = [iid for iid in chunk if iid not in already]
|
||||
if not to_insert:
|
||||
continue
|
||||
session.execute(
|
||||
pg_insert(image_tag)
|
||||
.values([
|
||||
{"image_record_id": iid, "tag_id": tag_id, "source": source}
|
||||
for iid in to_insert
|
||||
])
|
||||
.on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"])
|
||||
)
|
||||
insert_image_tags(session, [
|
||||
{"image_record_id": iid, "tag_id": tag_id, "source": source}
|
||||
for iid in to_insert
|
||||
])
|
||||
inserted += len(to_insert)
|
||||
return inserted
|
||||
|
||||
@@ -409,3 +409,31 @@ def rescan_series_suggestions_task(self, after_post_id: int = 0) -> dict:
|
||||
)
|
||||
rescan_series_suggestions_task.delay(summary["resume_after_id"])
|
||||
return summary
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.admin.reclaim_orphaned_attachments_task",
|
||||
bind=True,
|
||||
autoretry_for=(OperationalError, DBAPIError),
|
||||
retry_backoff=15, retry_backoff_max=180, max_retries=1,
|
||||
# The service stops walking at its own 900s budget and reports partial, so
|
||||
# these limits are the backstop for a wedged filesystem (NFS stall), not the
|
||||
# expected exit. Comfortably above the budget so a normal run always returns
|
||||
# its summary rather than being killed mid-walk.
|
||||
soft_time_limit=1200, time_limit=1500, # 20 min / 25 min
|
||||
)
|
||||
def reclaim_orphaned_attachments_task(self, dry_run: bool = True) -> dict:
|
||||
"""Reclaim unattributed PostAttachment rows and the store blobs nothing
|
||||
references any more (#3068). dry_run (the default) returns the projection
|
||||
without touching rows or files; apply deletes the orphan rows, then unlinks
|
||||
every blob no surviving row references.
|
||||
|
||||
Defaults to the SAFE preview — unlike the other tasks here, whose apply is
|
||||
reversible-ish or scoped; this one deletes files. Operator-triggered only,
|
||||
never on a beat: an unattended sweep that unlinks blobs is not something to
|
||||
run without someone reading the projection first."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
return cleanup_service.reclaim_orphaned_attachments(
|
||||
session, images_root=IMAGES_ROOT, dry_run=dry_run,
|
||||
)
|
||||
|
||||
@@ -173,6 +173,12 @@ TASK_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
|
||||
# task-name override beats the queue threshold whatever queue the row records
|
||||
# (it recorded 'default' before the celery_signals fix → download). 65 = 60+5.
|
||||
"backend.app.tasks.external.fetch_external_link": 65,
|
||||
# Attachment reclaim walks the whole sha-addressed store; the service caps
|
||||
# itself at a 900s budget and reports partial, but the task's hard limit is
|
||||
# 25 min for a wedged filesystem (NFS stall). Same phantom-flag class as the
|
||||
# external-fetch entry above — without an override a healthy in-flight walk
|
||||
# is swept 'RecoverySweep' at the bare 5-min default. 30 = 25 + 5.
|
||||
"backend.app.tasks.admin.reclaim_orphaned_attachments_task": 30,
|
||||
}
|
||||
|
||||
|
||||
@@ -776,89 +782,62 @@ def recover_stalled_library_audit_runs() -> int:
|
||||
return recovered
|
||||
|
||||
|
||||
def _recover_stalled_runs(model, *, stall_minutes: int, keep_runs: int, label: str) -> int:
|
||||
"""Shared recovery + retention sweep for the head run-tracking tables
|
||||
(HeadTrainingRun / HeadAutoApplyRun, which share the
|
||||
status/last_progress_at/started_at/finished_at/error/id columns): flip 'running'
|
||||
rows with no progress past `stall_minutes` to 'error', then prune to the last
|
||||
`keep_runs` (rule 89). Returns the number recovered. NOTE the two other recover
|
||||
tasks are deliberately NOT folded in — library-audit has no prune tail and
|
||||
backup uses a single started_at cutoff."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(minutes=stall_minutes)
|
||||
with SessionLocal() as session:
|
||||
result = session.execute(
|
||||
update(model)
|
||||
.where(model.status == "running")
|
||||
.where(func.coalesce(model.last_progress_at, model.started_at) < cutoff)
|
||||
.values(
|
||||
status="error", finished_at=now,
|
||||
error=f"stranded by recovery sweep (no progress for {stall_minutes} min)",
|
||||
)
|
||||
)
|
||||
keep = session.execute(
|
||||
select(model.id).order_by(model.id.desc()).limit(keep_runs)
|
||||
).scalars().all()
|
||||
if keep:
|
||||
session.execute(delete(model).where(model.id.not_in(keep)))
|
||||
session.commit()
|
||||
recovered = result.rowcount or 0
|
||||
if recovered:
|
||||
log.info("%s: recovered %d rows", label, recovered)
|
||||
return recovered
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_training_runs")
|
||||
def recover_stalled_head_training_runs() -> int:
|
||||
"""Flip HeadTrainingRun rows stuck in 'running' past the stall threshold to
|
||||
'error', and prune old runs to the last HEAD_TRAINING_KEEP_RUNS (retention,
|
||||
rule 89). Runs every 5 min on the maintenance lane; no-op when idle."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(minutes=HEAD_TRAINING_STALL_THRESHOLD_MINUTES)
|
||||
with SessionLocal() as session:
|
||||
result = session.execute(
|
||||
update(HeadTrainingRun)
|
||||
.where(HeadTrainingRun.status == "running")
|
||||
.where(
|
||||
func.coalesce(
|
||||
HeadTrainingRun.last_progress_at, HeadTrainingRun.started_at
|
||||
)
|
||||
< cutoff
|
||||
)
|
||||
.values(
|
||||
status="error", finished_at=now,
|
||||
error=(
|
||||
f"stranded by recovery sweep (no progress for "
|
||||
f"{HEAD_TRAINING_STALL_THRESHOLD_MINUTES} min)"
|
||||
),
|
||||
)
|
||||
)
|
||||
keep = session.execute(
|
||||
select(HeadTrainingRun.id).order_by(HeadTrainingRun.id.desc())
|
||||
.limit(HEAD_TRAINING_KEEP_RUNS)
|
||||
).scalars().all()
|
||||
if keep:
|
||||
session.execute(
|
||||
delete(HeadTrainingRun).where(HeadTrainingRun.id.not_in(keep))
|
||||
)
|
||||
session.commit()
|
||||
recovered = result.rowcount or 0
|
||||
if recovered:
|
||||
log.info(
|
||||
"recover_stalled_head_training_runs: recovered %d rows", recovered
|
||||
)
|
||||
return recovered
|
||||
return _recover_stalled_runs(
|
||||
HeadTrainingRun,
|
||||
stall_minutes=HEAD_TRAINING_STALL_THRESHOLD_MINUTES,
|
||||
keep_runs=HEAD_TRAINING_KEEP_RUNS,
|
||||
label="recover_stalled_head_training_runs",
|
||||
)
|
||||
|
||||
|
||||
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_head_auto_apply_runs")
|
||||
def recover_stalled_head_auto_apply_runs() -> int:
|
||||
"""Flip stalled HeadAutoApplyRun 'running' rows to 'error' + prune to the
|
||||
last HEAD_AUTO_APPLY_KEEP_RUNS (retention, rule 89). 5-min maintenance lane."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
now = datetime.now(UTC)
|
||||
cutoff = now - timedelta(minutes=HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES)
|
||||
with SessionLocal() as session:
|
||||
result = session.execute(
|
||||
update(HeadAutoApplyRun)
|
||||
.where(HeadAutoApplyRun.status == "running")
|
||||
.where(
|
||||
func.coalesce(
|
||||
HeadAutoApplyRun.last_progress_at, HeadAutoApplyRun.started_at
|
||||
)
|
||||
< cutoff
|
||||
)
|
||||
.values(
|
||||
status="error", finished_at=now,
|
||||
error=(
|
||||
f"stranded by recovery sweep (no progress for "
|
||||
f"{HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES} min)"
|
||||
),
|
||||
)
|
||||
)
|
||||
keep = session.execute(
|
||||
select(HeadAutoApplyRun.id).order_by(HeadAutoApplyRun.id.desc())
|
||||
.limit(HEAD_AUTO_APPLY_KEEP_RUNS)
|
||||
).scalars().all()
|
||||
if keep:
|
||||
session.execute(
|
||||
delete(HeadAutoApplyRun).where(HeadAutoApplyRun.id.not_in(keep))
|
||||
)
|
||||
session.commit()
|
||||
recovered = result.rowcount or 0
|
||||
if recovered:
|
||||
log.info(
|
||||
"recover_stalled_head_auto_apply_runs: recovered %d rows", recovered
|
||||
)
|
||||
return recovered
|
||||
return _recover_stalled_runs(
|
||||
HeadAutoApplyRun,
|
||||
stall_minutes=HEAD_AUTO_APPLY_STALL_THRESHOLD_MINUTES,
|
||||
keep_runs=HEAD_AUTO_APPLY_KEEP_RUNS,
|
||||
label="recover_stalled_head_auto_apply_runs",
|
||||
)
|
||||
|
||||
|
||||
# Keep ~6 months of daily head-metric snapshots (enough to see tuning trends).
|
||||
|
||||
+10
-30
@@ -105,9 +105,7 @@ def embed_image(self, image_id: int) -> dict:
|
||||
record = session.get(ImageRecord, image_id)
|
||||
if record is None:
|
||||
return {"status": "missing", "image_id": image_id}
|
||||
settings = session.execute(
|
||||
select(MLSettings).where(MLSettings.id == 1)
|
||||
).scalar_one()
|
||||
settings = MLSettings.load_sync(session)
|
||||
|
||||
src = Path(record.path)
|
||||
is_vid = _is_video(src)
|
||||
@@ -488,15 +486,10 @@ def scheduled_ccip_auto_apply() -> str:
|
||||
from sqlalchemy import select as sa_select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from ..models import ImageRegion, MLSettings, Tag, TagKind, TagSuggestionRejection
|
||||
from ..models import ImageRegion, MLSettings, Tag, TagKind
|
||||
from ..models.tag import image_tag
|
||||
|
||||
fig = ("face", "figure")
|
||||
|
||||
def _l2(m):
|
||||
n = np.linalg.norm(m, axis=1, keepdims=True)
|
||||
n[n == 0] = 1.0
|
||||
return m / n
|
||||
from ..services.ml.ccip import _FIGURE_KINDS
|
||||
from ..services.ml.training_data import _applied_or_rejected, _l2norm
|
||||
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
@@ -521,7 +514,7 @@ def scheduled_ccip_auto_apply() -> str:
|
||||
)
|
||||
.join(Tag, Tag.id == image_tag.c.tag_id)
|
||||
.where(Tag.kind == TagKind.character)
|
||||
.where(ImageRegion.kind.in_(fig))
|
||||
.where(ImageRegion.kind.in_(_FIGURE_KINDS))
|
||||
.where(ImageRegion.ccip_embedding.is_not(None))
|
||||
.where(ImageRegion.image_record_id.in_(single))
|
||||
).all()
|
||||
@@ -532,29 +525,16 @@ def scheduled_ccip_auto_apply() -> str:
|
||||
for tid, vec in ref_rows:
|
||||
by_char.setdefault(tid, []).append(vec)
|
||||
ref_tags = list(by_char)
|
||||
mats = [_l2(np.asarray(by_char[t], dtype=np.float32)) for t in ref_tags]
|
||||
mats = [_l2norm(np.asarray(by_char[t], dtype=np.float32), np) for t in ref_tags]
|
||||
allref = np.vstack(mats) # (total, 768)
|
||||
seg = np.cumsum([0] + [len(m) for m in mats])[:-1] # per-char start
|
||||
|
||||
# Per character: images that already carry OR rejected the tag — skip.
|
||||
skip = {t: set() for t in ref_tags}
|
||||
for t in ref_tags:
|
||||
for (iid,) in session.execute(
|
||||
sa_select(image_tag.c.image_record_id).where(
|
||||
image_tag.c.tag_id == t
|
||||
)
|
||||
):
|
||||
skip[t].add(iid)
|
||||
for (iid,) in session.execute(
|
||||
sa_select(TagSuggestionRejection.image_record_id).where(
|
||||
TagSuggestionRejection.tag_id == t
|
||||
)
|
||||
):
|
||||
skip[t].add(iid)
|
||||
skip = _applied_or_rejected(session, ref_tags)
|
||||
|
||||
img_ids = list(session.execute(
|
||||
sa_select(ImageRegion.image_record_id)
|
||||
.where(ImageRegion.kind.in_(fig), ImageRegion.ccip_embedding.is_not(None))
|
||||
.where(ImageRegion.kind.in_(_FIGURE_KINDS), ImageRegion.ccip_embedding.is_not(None))
|
||||
.distinct()
|
||||
).scalars())
|
||||
|
||||
@@ -566,7 +546,7 @@ def scheduled_ccip_auto_apply() -> str:
|
||||
sa_select(ImageRegion.image_record_id, ImageRegion.ccip_embedding)
|
||||
.where(
|
||||
ImageRegion.image_record_id.in_(chunk),
|
||||
ImageRegion.kind.in_(fig),
|
||||
ImageRegion.kind.in_(_FIGURE_KINDS),
|
||||
ImageRegion.ccip_embedding.is_not(None),
|
||||
)
|
||||
).all()
|
||||
@@ -574,7 +554,7 @@ def scheduled_ccip_auto_apply() -> str:
|
||||
for iid, vec in rows:
|
||||
by_img.setdefault(iid, []).append(vec)
|
||||
for iid, vecs in by_img.items():
|
||||
q = _l2(np.asarray(vecs, dtype=np.float32)) # (nq, 768)
|
||||
q = _l2norm(np.asarray(vecs, dtype=np.float32), np) # (nq, 768)
|
||||
colmax = (q @ allref.T).max(axis=0) # (total,)
|
||||
charmax = np.maximum.reduceat(colmax, seg) # (n_chars,)
|
||||
for ci in np.where(charmax >= thr)[0]:
|
||||
|
||||
+202
-4
@@ -9,14 +9,31 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
## Image deps used
|
||||
|
||||
- python 3.14
|
||||
- ruff (analyzer for `backend/`, `tests/`, `alembic/`)
|
||||
- ruff (analyzer for `backend/`, `tests/`, `alembic/`, `agent/`, `scripts/`)
|
||||
- node (frontend job: `npm install` + vitest + vite build)
|
||||
- docker CLI + buildx (`.forgejo/workflows/build.yml`: build-web, build-ml — Forgejo registry push)
|
||||
- docker CLI + buildx (`.forgejo/workflows/build.yml`: build-web, build-ml, build-agent — Fabled-Git registry push, and `imagetools inspect`/`create` for the reuse path)
|
||||
|
||||
## Secondary runtime image
|
||||
|
||||
node:24-bookworm-slim — `.forgejo/workflows/extension.yml` only.
|
||||
|
||||
`.forgejo/workflows/release.yml` runs on `ci-python:3.14` like everything else
|
||||
and installs nothing: it needs git and stdlib python, and builds no image.
|
||||
|
||||
The extension lane is the one job that does NOT run on `ci-python:3.14`: it
|
||||
needs a current Node for `web-ext` and vitest and nothing Python at all. Kept
|
||||
on the upstream slim image rather than adding a Node toolchain to `ci-python`,
|
||||
per `docs/process.md`'s "add deps to the image when used by >1 project".
|
||||
|
||||
## Per-job tool installs
|
||||
|
||||
- `pip install -r requirements.txt pytest pytest-asyncio` — in `backend-lint-and-test` and `integration` jobs
|
||||
- `npm install --no-audit --no-fund` — in `frontend-build` job
|
||||
- `npm install --no-audit --no-fund` — in `extension.yml`'s `lint` job (web-ext + vitest)
|
||||
- `unzip` — in `extension.yml`'s "Verify XPI contents" step, installed via apt
|
||||
only when absent (`node:24-bookworm-slim` may or may not carry it). Debian
|
||||
package, ~2s. Not worth baking into a shared image for a single consumer, per
|
||||
`docs/process.md`'s ">1 project" rule.
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -26,12 +43,193 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
"add deps to image when used by >1 project" rule: FC alone is one Python
|
||||
project, so the deps live in `requirements.txt` and install per-job.
|
||||
Reconsider when a second Fabled-family Python backend lands.
|
||||
- Integration uses Forgejo Actions `services:` + socket-discovered bridge IPs
|
||||
- Integration uses Fabled-Git Actions `services:` + socket-discovered bridge IPs
|
||||
because `act_runner` (swarm-runner v0.6+) puts services on the default
|
||||
bridge with no embedded DNS. The pattern is documented in the rulebook's
|
||||
`forgejo.md` "CI philosophy" section and FC's `ci.yml` is the canonical
|
||||
`fabled-git.md` "CI philosophy" section and FC's `ci.yml` is the canonical
|
||||
example.
|
||||
- No `package-lock.json` is tracked yet (FC's `feedback_no_local_runs`
|
||||
memory bans `npm install` locally). Using `npm install` rather than
|
||||
`npm ci` until a lockfile lands.
|
||||
- No `imagemagick` / `pandoc` per-job installs needed.
|
||||
- `extension/`'s vitest specs load `lib/*.js` by evaluating the real file as a
|
||||
classic script (`test/helpers/loadLib.js`) rather than adding `module.exports`
|
||||
shims to production code — the libs ship as `background.scripts`, not ES
|
||||
modules, so the specs exercise exactly the bytes packaged into the XPI.
|
||||
- **`extension/scripts/packaging.sh` is the single definition of what ships
|
||||
inside the XPI.** Three consumers read from it rather than keeping their own
|
||||
copy: web-ext's `--ignore-files` (`extension/package.json`), the `git log`
|
||||
pathspec inside the script's own version derivation, and `scripts/artifacts.sh`,
|
||||
which appends the extension's set to web's because the web image bundles the
|
||||
signed XPI. Hand-kept copies of that one fact is what allowed issue #2397, so
|
||||
`extension/test/version.spec.js` asserts no workflow has reintroduced a
|
||||
literal `:(exclude)extension/…`.
|
||||
- **Packaged and version-relevant are two different sets** (#3156). `scripts/`
|
||||
is excluded from the XPI and is NOT excluded from the version derivation,
|
||||
because `packaging.sh` decides the version string stamped into the packaged
|
||||
`manifest.json`. The membership test is *"can changing this file change the
|
||||
published bytes?"*, not *"is this file copied in?"* — which is why the script
|
||||
keeps two lists rather than one.
|
||||
- **The shipped extension version is derived, not committed.** It is the commit
|
||||
TIME of the newest packaged-extension change, rendered `YYYY.M.D.HHMM` UTC
|
||||
(family rules 148/149 — never a commit count, which orders by branch rather
|
||||
than by recency). `build.yml`'s `sign-extension` computes it and stamps it
|
||||
into `extension/manifest.json` + `package.json` in the working tree before
|
||||
signing; the stamp is never committed. The version in the repo is **wholly
|
||||
inert** — since milestone 318 step 8 there is no hand-set MAJOR.MINOR either.
|
||||
- **The extension is the one artifact that does not zero-pad, and that is not a
|
||||
drift** (#3138). Mozilla's grammar for AMO is
|
||||
`^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$` — a segment is the single
|
||||
digit `0` or starts 1-9, and there are at most four. `2026.08.29.0201` is
|
||||
rejected; `2026.8.29.201` is the same value one character narrower per
|
||||
segment, and rule 148 defines comparison as numeric per segment, so nothing is
|
||||
reordered. `ci.yml`'s `extension-version` lane asserts the derived string
|
||||
against that exact regex, plus a `YYYY.M.D.HHMM` shape check that would catch
|
||||
a regression to the pre-318 `1.0.<minutes>` — which AMO would accept and which
|
||||
orders below everything already signed. Checking here is the whole point: AMO
|
||||
409s on re-signing, so a version it rejects is burned and cannot be reused.
|
||||
`scripts/artifacts.sh version extension` **delegates** to `packaging.sh` so
|
||||
the two cannot answer differently.
|
||||
- Every job that derives anything checks out with `fetch-depth: 0` — all four
|
||||
`build.yml` jobs, `ci.yml`'s `extension-version` and `backend-lint-and-test`
|
||||
(for `tests/test_artifact_paths.py` and `test_artifact_identity.py`), and
|
||||
`release.yml`, which additionally walks the tag graph. A depth-1 clone sees
|
||||
one commit and derives a wrong, too-low value **rather than failing**, so the
|
||||
full-history checkout is load-bearing rather than incidental.
|
||||
- **`scripts/artifacts.sh` is the same shape one level up: one definition per
|
||||
artifact of what it is built from, and the two values derived from it.**
|
||||
`revision` (12 hex of the newest commit touching that set) and `version`
|
||||
(`YYYY.MM.DD.HHMM` UTC, rule 148). Four artifacts, four independent answers,
|
||||
so a push touching only `agent/` leaves web and ml alone.
|
||||
`tests/test_artifact_paths.py` reads each Dockerfile and asserts every COPY
|
||||
source is covered, so adding a COPY without updating the script fails CI.
|
||||
- **A file that DECIDES an artifact's identity belongs in its set even though it
|
||||
is copied into nothing** — `packaging.sh` for the extension and web (#3156),
|
||||
and `artifacts.sh` itself for web (#3202), which decides the `FC_VERSION`
|
||||
baked into that image. Only web needs the second entry: every artifact stamps
|
||||
a revision, but a revision has a backstop (a changed derivation stops matching
|
||||
the published label and forces a rebuild) and a version has none, because
|
||||
nothing compares it to anything. `tests/test_artifact_paths.py`'s `DERIVERS`
|
||||
table is the guard.
|
||||
- **Builds are skipped when the content is already published.** Each image
|
||||
carries its revision as an `fc.revision` LABEL, and `build.yml` reads that
|
||||
label back off the moving channel tag (`imagetools inspect --format`). Equal
|
||||
to the derived revision means the bytes are already published, so the job
|
||||
repoints the remaining tags at the existing manifest instead of rebuilding.
|
||||
Two things this depends on: an inspect that errors for ANY reason reads as a
|
||||
MISS so no needed build is ever skipped, and the repoint must EXCLUDE the
|
||||
source tag — `imagetools create` wraps its source in a manifest index, and
|
||||
config labels do not resolve through an index, so writing the channel tag
|
||||
from itself destroys the label the next run reads (#3183).
|
||||
- **The build pushes exactly ONE tag — the channel's — and every other tag is
|
||||
written registry-side afterwards** (#3190). buildx on this runner pushes the
|
||||
first tag to the registry and then re-pushes the rest through the docker
|
||||
driver, out of a local image store that a registry-direct build never fills;
|
||||
it fails intermittently with `tag does not exist`. On `dev` that only reddens
|
||||
a job, but on `main` it silently skips `:c-<sha>` while `:latest` publishes
|
||||
fine — a missing rollback tag has no consumer that fails, so nothing but the
|
||||
red job would notice until somebody needs to roll back. `imagetools create`
|
||||
has no local store to be absent from, and it is the code the reuse path
|
||||
already ran, so both paths now share one proven route. The cost: `:c-<sha>`
|
||||
is an index rather than a plain image, so `fc.revision` does not resolve
|
||||
through it — nothing reads it there, and the index names the same manifest.
|
||||
- **The image builds run on a `docker-container` buildx builder, and
|
||||
`provenance`/`sbom` are explicitly OFF** (milestone 326 step 1). The builder
|
||||
is what makes a registry layer cache possible at all — the default `docker`
|
||||
driver cannot export one (#3114) — and it is #3190's leading suspect, since
|
||||
it is the driver that resolves image metadata against a local store a
|
||||
registry-direct push never fills. **The attestation flags are load-bearing,
|
||||
not tidiness:** on the container driver `build-push-action@v5` defaults
|
||||
`provenance` to true when pushing, an attestation manifest makes the pushed
|
||||
tag a manifest INDEX, and config labels do not resolve through an index — so
|
||||
leaving them on would make every push read `fc.revision=<none>`, miss, and
|
||||
rebuild forever with every lane green. Same failure as #3183, different door.
|
||||
These jobs run inside a container against a mounted docker socket, so the
|
||||
buildkit container is a sibling rather than a child.
|
||||
- **All three images import and export a registry layer cache**
|
||||
(`<image>:buildcache`, `mode=max`). This is not an optimisation bolted onto
|
||||
the driver change — it is the other half of it. The `docker-container`
|
||||
driver gets a fresh buildkit instance per job and therefore has **no local
|
||||
layer store at all**, where the old `docker` driver at least reused whatever
|
||||
the runner's dockerd happened to hold. Measured on run 4896, the first builds
|
||||
after the driver moved: web 3m44s (was 2m23s), ml 3m49s (was 3m20s), agent
|
||||
11m12s (was 9m26s) — every one slower. A `:buildcache` tag is read by every
|
||||
build that runs, is one moving ref per image, holds cache blobs rather than a
|
||||
shippable artifact, and is overwritten in place, so it is not a return of the
|
||||
per-version tags milestone 318 withdrew (#3114).
|
||||
- **`build.yml` accepts a `workflow_dispatch` with `force_build`**, which
|
||||
bypasses the reuse check for all three images. It exists because
|
||||
skip-if-exists makes its own build path untestable: `agent/` has not changed
|
||||
since 2026-07-17, so the agent build has not run in six weeks and cannot be
|
||||
exercised on demand — and #3190 lives on exactly that path. Editing
|
||||
`build.yml` does not force a build either, deliberately: the workflow is not
|
||||
shipped bytes and is in no artifact's path set. The flag is read through
|
||||
`github.event.inputs` into an env var rather than interpolated into a run
|
||||
block, and it is checked inside the reuse step so that one decision drives
|
||||
both the build and the repoint.
|
||||
- **A weekly `schedule` rebuilds all three images against fresh base layers**
|
||||
(Sunday 06:00 UTC, milestone 326 step 4, #3154). Skip-if-exists is keyed on
|
||||
OUR source, so an artifact whose source stops moving stops picking up base
|
||||
updates — `agent/` has not changed since 2026-07-17 and would otherwise serve
|
||||
that day's `nvidia/cuda` layers forever. Four things make it work:
|
||||
- It **builds `main`, not the branch that triggered it.** Forgejo registers a
|
||||
cron from the DEFAULT branch (`dev` here), so a scheduled run arrives with
|
||||
`github.ref` on dev. The ref is decided once in a top-level `env:
|
||||
BUILD_REF` that every checkout in the file takes, rather than per job —
|
||||
otherwise `sign-extension` would derive dev's extension version while
|
||||
`build-web` bundled main's, and the release download would 404 on a version
|
||||
that exists perfectly well. Every job then ASSERTS its checkout is `main`
|
||||
before doing anything, because `env` inside `with:` is not a context this
|
||||
runner is known to evaluate — if it silently resolved to empty, checkout
|
||||
would fall back to the triggering ref and the refresh would publish dev's
|
||||
source to `:latest` with every lane green.
|
||||
- It **publishes only `:latest`.** `:c-<sha>` for main's HEAD already names
|
||||
the bytes that commit built; re-pushing it over refreshed layers would
|
||||
break the one tag rule 145 makes immutable, and it is the rollback unit.
|
||||
The repoint step needs no schedule case for this — the tag list is the
|
||||
channel tag alone, so SOURCE is the only entry, it is excluded as always,
|
||||
and the step correctly does nothing.
|
||||
- **`:latest` and `:c-<sha>` therefore diverge between a refresh and the next
|
||||
`main` push, by design.** They re-converge on that push: it hits reuse (a
|
||||
refresh does not move `fc.revision`, because it does not touch the source),
|
||||
and the repoint writes the NEW `:c-<sha>` from the refreshed `:latest`. The
|
||||
push path needed no change for this, because the repoint already excluded
|
||||
the source tag — the same rule that keeps the label readable also keeps a
|
||||
refresh from being undone.
|
||||
- **`pull: true` on the scheduled path only** is the mechanism: a moved base
|
||||
tag changes the `FROM` layer's cache key and everything above it rebuilds.
|
||||
**It does not currently make the unmoved case free.** Measured on the first
|
||||
real fire (run 4934, 2026-08-30): every content step reported `CACHED` and
|
||||
the bases resolved to unchanged digests, yet all three `:latest` tags got a
|
||||
NEW manifest digest, because buildkit mints a fresh image config per run and
|
||||
republishes identical layers under it. So `:latest` is rewritten weekly
|
||||
whether or not anything changed, and `:c-<sha>` is handed a new manifest to
|
||||
diverge from on the same cadence — a digest change stops meaning anything.
|
||||
Tracked as #3265; the likely fix is a deterministic `SOURCE_DATE_EPOCH`.
|
||||
Separately not caught: a Debian package update inside the `apt-get install`
|
||||
layer while the base tag stands still — a lag rather than a hole, since the
|
||||
official python/cuda images rebuild with those updates baked in.
|
||||
- **`FC_CHANNEL` and `FC_VERSION` are build args, not runtime settings.**
|
||||
`build.yml` passes them to the web image only — the ml and agent images have
|
||||
nothing to report them to. `/api/health` returns both, the foot of Settings
|
||||
renders them, and `/api/extension/manifest` reports the channel beside the
|
||||
extension version so an install can be traced to a channel. With no version image tags, that
|
||||
self-report is the ONLY answer to "which build is this?" — which is why a
|
||||
missing version renders `unknown` rather than a blank: an empty footer reads
|
||||
as "no version", a different and false claim.
|
||||
Both are declared LAST in the Dockerfile on purpose: an ARG invalidates every
|
||||
layer below it, and these are the values that differ between the dev and main
|
||||
builds of identical source, so placing them earlier would stop the two
|
||||
channels ever sharing a cached `pip install`. Empty by default — a local build
|
||||
then reports nothing rather than claiming a channel it is not on.
|
||||
- **The channel is never folded into the version.** A `-dev` suffix makes the
|
||||
extension's per-segment `parseInt` comparator read that segment as 0, so every
|
||||
dev build compares equal to every other — issue #2993 exactly (rule 149).
|
||||
`frontend/test/systemBuild.spec.js` pins the rendered version to the bare
|
||||
number.
|
||||
- Callers MUST `set -f` before substituting the script's output. Without it the
|
||||
shell expands `test/**` against the working tree and silently narrows the
|
||||
pattern to whatever files exist at that moment — a failure that looks like
|
||||
nothing until dev files start appearing in the XPI. `test/version.spec.js`
|
||||
asserts every `--ignore-files` consumer sets it, and that no consumer has
|
||||
quietly reinstated a hardcoded list.
|
||||
|
||||
+23
-5
@@ -74,7 +74,25 @@ services:
|
||||
retries: 5
|
||||
|
||||
web:
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
||||
# :latest, NOT :dev — this file IS the install path.
|
||||
#
|
||||
# `docker compose up -d` merges docker-compose.override.yml, which sets
|
||||
# build: for all five app services, and a build: wins over image:. So a
|
||||
# contributor never pulls this tag and is unaffected by what it says.
|
||||
#
|
||||
# The tag is consulted only on `docker compose -f docker-compose.yml up -d`
|
||||
# — the documented production path, which skips the override. That is a
|
||||
# stranger installing the product, and they must land on the stable channel.
|
||||
#
|
||||
# :latest is main, which IS production (rule 147). :dev is the rolling
|
||||
# bleeding-edge channel we work out of, republished several times a day with
|
||||
# no stability promise. This file pinned :dev on all five services until
|
||||
# 2026-08-31 (#3270), so the documented install shipped development builds.
|
||||
# It went unnoticed because nobody who works on the project takes this path:
|
||||
# the operator deploys from a swarm stack file, contributors get the
|
||||
# override. Do not "fix" this back to :dev while debugging — use the
|
||||
# override, or -f with an explicit tag on the command line.
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
|
||||
command: ["web"]
|
||||
# Graceful shutdown: give the container time to drain in-flight work on a
|
||||
# deploy (docker SIGTERMs, then SIGKILLs after this window — default is only
|
||||
@@ -122,7 +140,7 @@ services:
|
||||
redis: { condition: service_healthy }
|
||||
|
||||
worker:
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
|
||||
command: ["worker"]
|
||||
# Drain in-flight import/thumbnail/download tasks before SIGKILL on deploy.
|
||||
stop_grace_period: 90s
|
||||
@@ -142,7 +160,7 @@ services:
|
||||
redis: { condition: service_healthy }
|
||||
|
||||
scheduler:
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
|
||||
command: ["scheduler"]
|
||||
# Quick maintenance/scan lane + beat — short tasks, modest drain window.
|
||||
stop_grace_period: 60s
|
||||
@@ -163,7 +181,7 @@ services:
|
||||
# 30-min backup or a multi-chunk audit can never starve the 5-min recovery
|
||||
# sweeps / vacuum (operator-flagged 2026-06-07). One slot — these are heavy.
|
||||
maintenance-long:
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
|
||||
command: ["worker"]
|
||||
# Longest lane (DB backups, library audits, translation backfill) — give it
|
||||
# the most room to finish a chunk gracefully. Chunked + idempotent, so a job
|
||||
@@ -184,7 +202,7 @@ services:
|
||||
redis: { condition: service_healthy }
|
||||
|
||||
ml-worker:
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator-ml:dev
|
||||
image: git.fabledsword.com/bvandeusen/fabledcurator-ml:latest
|
||||
command: ["ml-worker"]
|
||||
# A single GPU inference pass can run tens of seconds — let it finish.
|
||||
stop_grace_period: 120s
|
||||
|
||||
+77
-9
@@ -1,13 +1,14 @@
|
||||
# FabledCurator Firefox Extension
|
||||
|
||||
Self-hosted Firefox extension that pushes session cookies from supported
|
||||
platforms (Patreon, SubscribeStar, Hentai-Foundry, Discord, Pixiv,
|
||||
DeviantArt) into FabledCurator, and lets you add a creator as a Source
|
||||
from their page in one click.
|
||||
platforms (Patreon, SubscribeStar, Hentai-Foundry, Discord, Pixiv)
|
||||
into FabledCurator, and lets you add a creator as a Source from their
|
||||
page in one click.
|
||||
|
||||
## Install (operator)
|
||||
|
||||
The signed XPI is bundled into the FC Docker image. Open FC →
|
||||
The signed XPI is bundled into the FC Docker image — `:dev` and
|
||||
`:latest` each carry their own channel's build. Open FC →
|
||||
Settings → Maintenance → Browser extension → click "Install Firefox
|
||||
extension". Firefox shows its native install prompt. After installing,
|
||||
open the extension's options page (about:addons → FabledCurator →
|
||||
@@ -20,6 +21,7 @@ same card.
|
||||
cd extension/
|
||||
npm install --no-save # web-ext only
|
||||
npm run lint # web-ext lint
|
||||
npm run test:unit # vitest — lib/ logic + packaging/version checks
|
||||
npm run start # launches Firefox with extension loaded
|
||||
npm run build # unsigned XPI in web-ext-artifacts/
|
||||
```
|
||||
@@ -36,10 +38,76 @@ npm run build # unsigned XPI in web-ext-artifacts/
|
||||
- [ ] Subscriptions list: popup → "Sources" tab → list renders
|
||||
- [ ] Check now: click play icon on source row → no error toast
|
||||
|
||||
## Versioning — the committed number decides nothing
|
||||
|
||||
The shipped version is **derived**, not committed. `scripts/packaging.sh
|
||||
version` returns `YYYY.M.D.HHMM` in UTC: the commit *time* of the newest change
|
||||
to a packaged extension file. `build.yml` computes it and stamps it into both
|
||||
`manifest.json` and `package.json` at build time. The stamp is never
|
||||
committed — the commit carrying it would itself be a change to the extension,
|
||||
which would move the version again.
|
||||
|
||||
So:
|
||||
|
||||
- **Editing the version does nothing.** All of it is overwritten before web-ext
|
||||
ever reads it. There is no bump to make, and none to forget. There is no
|
||||
hand-set part left either: MAJOR.MINOR went away with milestone 318 step 8.
|
||||
- `npm run build` locally produces an XPI labelled with the *committed*
|
||||
version, since nothing stamped it. Fine for loading into a test profile; not
|
||||
what ships.
|
||||
|
||||
**Why the extension is the one artifact that does not zero-pad.** Every other
|
||||
FC artifact emits rule 148's `YYYY.MM.DD.HHMM`. AMO will not take it: Mozilla's
|
||||
grammar for addons.mozilla.org is
|
||||
|
||||
```
|
||||
^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
|
||||
```
|
||||
|
||||
— each segment is the single digit `0` or starts 1-9, so `08` and `0201` are
|
||||
rejected, and at most four segments are allowed. The extension therefore emits
|
||||
**the same numbers unpadded**: `2026.8.29.201` where the rest of the family
|
||||
says `2026.08.29.0201`. Rule 148 already defines comparison as numeric per
|
||||
segment, under which the two are equal, so nothing is reordered by the choice
|
||||
and left-padding each segment recovers the family string exactly. `ci.yml`'s
|
||||
`extension-version` lane checks the derived string against that regex on every
|
||||
push — the cheap place to find out, because AMO 409s on re-signing and a
|
||||
rejected version is burned for good.
|
||||
|
||||
Why commit time and not a commit count: a count is per-branch, so `dev` and
|
||||
`main` count different histories of the same code and their versions end up
|
||||
ordered by which branch accumulated more commits rather than by which is newer.
|
||||
Commit time gives both branches the same number for the same source — which is
|
||||
exactly what lets one AMO signature serve both channels (family rule 149, FC
|
||||
issue #3092).
|
||||
|
||||
## Channels
|
||||
|
||||
`dev` and `main` each build and sign their own extension, and an install is
|
||||
tied to whichever FC instance it points at — Firefox's static `update_url`
|
||||
cannot apply here, since every FC install is a different host, so the extension
|
||||
asks its configured backend. **The channel therefore IS the instance.**
|
||||
Switching channel means repointing the FC URL in options and reinstalling from
|
||||
that host; there is no separate channel setting, and adding one would
|
||||
contradict each server build shipping its own extension.
|
||||
|
||||
The channel is reported *beside* the version, never inside it:
|
||||
`/api/extension/manifest` answers `{"version": "...", "channel": "dev"}`. It is
|
||||
optional — an instance that declares none simply omits the key, and the popup,
|
||||
the toolbar tooltip and the Settings card all read exactly as they did before
|
||||
the field existed. Do not be tempted to make it a `-dev` version suffix: the
|
||||
comparator parses each dotted segment with `parseInt`, so a suffixed segment
|
||||
reads as 0 and every dev build compares equal to every other, collapsing "no
|
||||
update available" and "I cannot read this version" into one answer.
|
||||
|
||||
## Release
|
||||
|
||||
Bump `manifest.json` + `package.json` SemVer (both files) and commit
|
||||
under `extension/**`. The `.forgejo/workflows/extension.yml` workflow
|
||||
runs `web-ext sign` on main, commits the signed XPI to
|
||||
`frontend/public/extension/`, and the next FC server build bundles it
|
||||
into the Docker image.
|
||||
Nothing to do by hand. Push to `dev`: `build.yml` signs the extension if this
|
||||
change moved the version, caches the signed XPI as a Forgejo `ext-<version>`
|
||||
release, and bundles it into `fabledcurator:dev`. Merging to `main` derives the
|
||||
same version, hits that cache, and bundles the byte-identical XPI into
|
||||
`:latest` with no second AMO call.
|
||||
|
||||
AMO refuses to re-sign a version it has already issued, so signing is one-shot
|
||||
per version — which is why the cache exists and why the version must never move
|
||||
backwards.
|
||||
|
||||
@@ -37,7 +37,16 @@ ensureInitialized().catch(e => console.error('init failed:', e));
|
||||
// configured backend for the latest published version and nudge the operator to
|
||||
// reinstall the freshly-signed XPI — surfaced as a popup banner (on demand) and
|
||||
// a toolbar badge (daily). /api/extension/manifest is public and returns
|
||||
// {version, latest_url, sha256}; the XPI is served from the web root (not /api).
|
||||
// {version, latest_url, sha256} plus an OPTIONAL {channel} naming which channel
|
||||
// that instance serves ("dev"/"main", #3113); the XPI is served from the web
|
||||
// root (not /api).
|
||||
//
|
||||
// The channel IS the instance: Firefox's static update_url cannot apply here
|
||||
// because every FC install is a different host, so the extension asks its
|
||||
// configured backend — which means switching channel is repointing apiUrl in
|
||||
// options and reinstalling from that host. There is no separate channel
|
||||
// setting to build, and building one would contradict each server build
|
||||
// shipping its own extension.
|
||||
|
||||
function versionIsNewer(candidate, current) {
|
||||
// Dotted numeric compare so 1.0.10 > 1.0.9 (a plain string compare wouldn't).
|
||||
@@ -60,13 +69,22 @@ async function checkForUpdateInfo() {
|
||||
}
|
||||
const currentVersion = browser.runtime.getManifest().version;
|
||||
const latestVersion = info && info.version ? info.version : null;
|
||||
// latest_url is served from the web root; strip the /api suffix off baseUrl
|
||||
// (same transform as OPEN_ARTIST_PAGE).
|
||||
const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
|
||||
// Which channel the configured instance serves — reported ALONGSIDE the
|
||||
// version, never folded into it. A `-dev` suffix would have to survive
|
||||
// versionIsNewer's parseInt above, and it wouldn't: the segment would read
|
||||
// as 0 and every dev build would compare equal to every other.
|
||||
//
|
||||
// null is a normal answer, not a failure — an instance built before the
|
||||
// field existed, or one built locally with no channel declared. Nothing
|
||||
// below branches on it except the label.
|
||||
const channel = info && info.channel ? info.channel : null;
|
||||
// latest_url is served from the web root, not the JSON API.
|
||||
const base = api.webRoot();
|
||||
return {
|
||||
updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion),
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
channel,
|
||||
xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
|
||||
};
|
||||
}
|
||||
@@ -78,7 +96,11 @@ async function refreshUpdateBadge() {
|
||||
await browser.action.setBadgeText({ text: r.updateAvailable ? '↑' : '' });
|
||||
if (r.updateAvailable) {
|
||||
await browser.action.setBadgeBackgroundColor({ color: '#F4BA7A' });
|
||||
await browser.action.setTitle({ title: `FabledCurator — update available (v${r.latestVersion})` });
|
||||
// Channel first, version second, and the channel dropped entirely when
|
||||
// the instance doesn't report one — so the tooltip reads exactly as it
|
||||
// did before the field existed rather than saying "(unknown ...)".
|
||||
const label = r.channel ? `${r.channel} v${r.latestVersion}` : `v${r.latestVersion}`;
|
||||
await browser.action.setTitle({ title: `FabledCurator — update available (${label})` });
|
||||
} else {
|
||||
await browser.action.setTitle({ title: 'FabledCurator' });
|
||||
}
|
||||
@@ -211,6 +233,21 @@ browser.webRequest.onBeforeRedirect.addListener(
|
||||
{ urls: ['https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback*'] },
|
||||
);
|
||||
|
||||
// Extract → verify → upload one cookie-auth platform. Returns a structured
|
||||
// outcome so the two callers (EXPORT_COOKIES single, EXPORT_ALL_COOKIES) shape
|
||||
// their own response + skip semantics. Verifies the captured cookies are
|
||||
// actually live BEFORE uploading, so a confirmed-stale session doesn't overwrite
|
||||
// good FC-side credentials; platforms with no verify config (v.ok === null) fall
|
||||
// through to upload.
|
||||
async function exportPlatformCookies(key) {
|
||||
const cookies = await extractCookiesForPlatform(key);
|
||||
if (cookies.length === 0) return { status: 'empty' };
|
||||
const v = await verifyCookiesForPlatform(key);
|
||||
if (v.ok === false) return { status: 'stale', reason: v.reason, cookieCount: cookies.length };
|
||||
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
|
||||
return { status: 'ok', cookieCount: cookies.length, verified: v.ok === true };
|
||||
}
|
||||
|
||||
// ---- Message router ----
|
||||
|
||||
browser.runtime.onMessage.addListener(async (msg) => {
|
||||
@@ -255,22 +292,14 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
||||
if (!platform) return { error: `Unknown platform: ${key}` };
|
||||
try {
|
||||
if (platform.authType === 'cookies') {
|
||||
const cookies = await extractCookiesForPlatform(key);
|
||||
if (cookies.length === 0) return { error: 'No cookies found — log in first.' };
|
||||
// Verify the captured cookies are actually live BEFORE
|
||||
// uploading. Skips upload on confirmed-stale sessions so we
|
||||
// don't overwrite FC-side credentials with garbage. Platforms
|
||||
// without a verify config (verify.ok === null) fall through
|
||||
// to upload as before.
|
||||
const v = await verifyCookiesForPlatform(key);
|
||||
if (v.ok === false) {
|
||||
const r = await exportPlatformCookies(key);
|
||||
if (r.status === 'empty') return { error: 'No cookies found — log in first.' };
|
||||
if (r.status === 'stale') {
|
||||
return {
|
||||
error: `Captured ${cookies.length} ${platform.name} cookies but they don't appear authenticated (${v.reason}). Log in again in this browser, then retry.`,
|
||||
error: `Captured ${r.cookieCount} ${platform.name} cookies but they don't appear authenticated (${r.reason}). Log in again in this browser, then retry.`,
|
||||
};
|
||||
}
|
||||
const data = toNetscapeFormat(cookies);
|
||||
await api.uploadCredentials(key, 'cookies', data);
|
||||
return { success: true, cookieCount: cookies.length, verified: v.ok === true };
|
||||
return { success: true, cookieCount: r.cookieCount, verified: r.verified };
|
||||
}
|
||||
if (key === 'discord') {
|
||||
if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
|
||||
@@ -298,18 +327,10 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const cookies = await extractCookiesForPlatform(key);
|
||||
if (cookies.length === 0) {
|
||||
results[key] = { skipped: true, reason: 'no cookies' };
|
||||
continue;
|
||||
}
|
||||
const v = await verifyCookiesForPlatform(key);
|
||||
if (v.ok === false) {
|
||||
results[key] = { error: `verify failed: ${v.reason}` };
|
||||
continue;
|
||||
}
|
||||
await api.uploadCredentials(key, 'cookies', toNetscapeFormat(cookies));
|
||||
results[key] = { success: true, cookieCount: cookies.length, verified: v.ok === true };
|
||||
const r = await exportPlatformCookies(key);
|
||||
if (r.status === 'empty') results[key] = { skipped: true, reason: 'no cookies' };
|
||||
else if (r.status === 'stale') results[key] = { error: `verify failed: ${r.reason}` };
|
||||
else results[key] = { success: true, cookieCount: r.cookieCount, verified: r.verified };
|
||||
} catch (e) {
|
||||
results[key] = { error: e.message };
|
||||
}
|
||||
@@ -346,11 +367,9 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
||||
}
|
||||
|
||||
case 'OPEN_ARTIST_PAGE': {
|
||||
// apiUrl is configured with the /api suffix (see
|
||||
// options/options.html placeholder); the SPA artist route is
|
||||
// /artist/:slug, served from the same origin. Strip /api so the
|
||||
// browser-level URL hits the Vue router, not the JSON API.
|
||||
const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
|
||||
// The SPA artist route (/artist/:slug) is served from the web root, not
|
||||
// the JSON API — see api.webRoot().
|
||||
const base = api.webRoot();
|
||||
const slug = encodeURIComponent(msg.slug || '');
|
||||
if (!base || !slug) return { error: 'apiUrl or slug missing' };
|
||||
try {
|
||||
|
||||
+17
-1
@@ -11,7 +11,10 @@ class FabledCuratorAPI {
|
||||
|
||||
async init() {
|
||||
const cfg = await browser.storage.local.get(['apiUrl', 'apiKey']);
|
||||
this.baseUrl = cfg.apiUrl || null;
|
||||
// Normalize on READ, not just on save: configs stored before the options
|
||||
// page started normalizing are missing the `/api` suffix, and this heals
|
||||
// them without the operator having to reopen Settings.
|
||||
this.baseUrl = normalizeApiUrl(cfg.apiUrl) || null;
|
||||
this.apiKey = cfg.apiKey || null;
|
||||
return this.isConfigured();
|
||||
}
|
||||
@@ -50,6 +53,13 @@ class FabledCuratorAPI {
|
||||
} catch {
|
||||
message = `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
// 404/405 from FC almost always means the request never reached the JSON
|
||||
// API — it fell through to the SPA catch-all, which serves HTML on GET
|
||||
// and rejects everything else. Say so, rather than making the operator
|
||||
// decode "Method Not Allowed" on an endpoint that plainly allows POST.
|
||||
if (response.status === 404 || response.status === 405) {
|
||||
message += ` — ${url} isn't the FC API. Check the FC URL in settings.`;
|
||||
}
|
||||
const err = new Error(message);
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
@@ -96,6 +106,12 @@ class FabledCuratorAPI {
|
||||
return this.request('GET', '/extension/manifest');
|
||||
}
|
||||
|
||||
// The web/SPA root: where the Vue router (artist pages) and the served XPI
|
||||
// live, NOT the JSON API. Used by OPEN_ARTIST_PAGE + the self-update check.
|
||||
webRoot() {
|
||||
return webRootFromApiUrl(this.baseUrl);
|
||||
}
|
||||
|
||||
// Connection test = the cheapest read with auth.
|
||||
testConnection() {
|
||||
return this.request('GET', '/credentials');
|
||||
|
||||
@@ -68,16 +68,6 @@ const PLATFORMS = {
|
||||
urlPattern: /^https?:\/\/(www\.)?pixiv\.net/,
|
||||
note: 'Click to authenticate via OAuth',
|
||||
},
|
||||
deviantart: {
|
||||
name: 'DeviantArt',
|
||||
domains: ['.deviantart.com', 'www.deviantart.com', 'deviantart.com'],
|
||||
authType: 'cookies',
|
||||
color: '#05CC47',
|
||||
urlPattern: /^https?:\/\/(www\.)?deviantart\.com/,
|
||||
// DA's logged-in-only endpoints sit behind their internal _napi
|
||||
// namespace which shifts; skipping verify until a stable check
|
||||
// surfaces. Same posture as SubscribeStar.
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -98,7 +88,6 @@ const PLATFORM_ARTIST_PATTERNS = {
|
||||
patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i,
|
||||
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
|
||||
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i,
|
||||
deviantart: /^https?:\/\/(www\.)?deviantart\.com\/(?!home$|watch\b|tag\b|browse\b)[^/?#]+\/?$/i,
|
||||
pixiv: /^https?:\/\/(www\.)?pixiv\.net\/(en\/)?users\/\d+/i,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Canonical FC endpoint derivation, shared by the background client and the
|
||||
* options page so a URL entered either way behaves identically.
|
||||
*
|
||||
* FC serves two things on one origin: the JSON API under `/api`, and the Vue
|
||||
* SPA from the root. `api.js` builds requests as `${baseUrl}/credentials`, so
|
||||
* the stored base URL has to carry the `/api` suffix.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Accept what an operator would naturally type — the instance root
|
||||
* (`http://curator.example.com`) or the API root (`.../api`) — and return the
|
||||
* API root either way.
|
||||
*
|
||||
* Worth normalizing rather than validating: a root-form URL doesn't fail
|
||||
* loudly, it lands on the SPA catch-all, which answers `GET /credentials` with
|
||||
* 200 HTML and rejects `POST /credentials` with 405. The operator sees a
|
||||
* working Test Connection and a broken export.
|
||||
*/
|
||||
function normalizeApiUrl(raw) {
|
||||
const trimmed = (raw || '').trim().replace(/\/+$/, '');
|
||||
if (!trimmed) return '';
|
||||
return /\/api$/i.test(trimmed) ? trimmed : `${trimmed}/api`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SPA root — where the Vue router (artist pages) and the served XPI live,
|
||||
* NOT the JSON API. Accepts either input form, same as normalizeApiUrl.
|
||||
*/
|
||||
function webRootFromApiUrl(raw) {
|
||||
return normalizeApiUrl(raw).replace(/\/api$/i, '');
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FabledCurator",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.11",
|
||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||
|
||||
"browser_specific_settings": {
|
||||
@@ -33,7 +33,6 @@
|
||||
"*://*.hentai-foundry.com/*",
|
||||
"*://*.discord.com/*",
|
||||
"*://*.pixiv.net/*",
|
||||
"*://*.deviantart.com/*",
|
||||
"*://app-api.pixiv.net/*",
|
||||
"*://oauth.secure.pixiv.net/*",
|
||||
"*://*/*"
|
||||
@@ -46,7 +45,7 @@
|
||||
},
|
||||
|
||||
"background": {
|
||||
"scripts": ["lib/platforms.js", "lib/cookies.js", "lib/api.js", "background/background.js"]
|
||||
"scripts": ["lib/platforms.js", "lib/cookies.js", "lib/url.js", "lib/api.js", "background/background.js"]
|
||||
},
|
||||
|
||||
"options_ui": {
|
||||
@@ -61,7 +60,6 @@
|
||||
"*://*.subscribestar.com/*",
|
||||
"*://*.subscribestar.adult/*",
|
||||
"*://*.hentai-foundry.com/*",
|
||||
"*://*.deviantart.com/*",
|
||||
"*://*.pixiv.net/*"
|
||||
],
|
||||
"js": ["lib/platforms.js", "content/content-script.js"],
|
||||
|
||||
@@ -21,9 +21,12 @@
|
||||
<body>
|
||||
<h1>FabledCurator extension</h1>
|
||||
|
||||
<label for="api-url">FC base URL</label>
|
||||
<input id="api-url" type="url" placeholder="http://curator.example.com/api" />
|
||||
<div class="hint">Find this on FC → Settings → Maintenance → Browser extension.</div>
|
||||
<label for="api-url">FC instance URL</label>
|
||||
<input id="api-url" type="url" placeholder="http://curator.example.com" />
|
||||
<div class="hint">
|
||||
Your FabledCurator address — with or without the trailing <code>/api</code>; both work.
|
||||
Find it on FC → Settings → Maintenance → Browser extension.
|
||||
</div>
|
||||
|
||||
<label for="api-key">Extension API key</label>
|
||||
<input id="api-key" type="password" placeholder="paste from FC Settings card" />
|
||||
@@ -36,6 +39,7 @@
|
||||
|
||||
<div id="status" class="status" style="display:none;"></div>
|
||||
|
||||
<script src="../lib/url.js"></script>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
|
||||
async function save() {
|
||||
const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, '');
|
||||
const apiUrl = normalizeApiUrl(document.getElementById('api-url').value);
|
||||
const apiKey = document.getElementById('api-key').value.trim();
|
||||
if (!apiUrl || !apiKey) {
|
||||
showStatus('Both fields are required.', 'err');
|
||||
@@ -16,11 +16,14 @@ async function save() {
|
||||
}
|
||||
await browser.storage.local.set({ apiUrl, apiKey });
|
||||
await browser.storage.local.remove(['lastConnectionTest', 'lastConnectionStatus']);
|
||||
showStatus('Saved.', 'ok');
|
||||
// Show what was actually stored — the operator may have typed the instance
|
||||
// root and it was normalized to the API root.
|
||||
document.getElementById('api-url').value = apiUrl;
|
||||
showStatus(`Saved — using ${apiUrl}`, 'ok');
|
||||
}
|
||||
|
||||
async function test() {
|
||||
const apiUrl = document.getElementById('api-url').value.trim().replace(/\/+$/, '');
|
||||
const apiUrl = normalizeApiUrl(document.getElementById('api-url').value);
|
||||
const apiKey = document.getElementById('api-key').value.trim();
|
||||
if (!apiUrl || !apiKey) {
|
||||
showStatus('Fill both fields first.', 'err');
|
||||
@@ -31,8 +34,23 @@ async function test() {
|
||||
method: 'GET',
|
||||
headers: { 'X-Extension-Key': apiKey },
|
||||
});
|
||||
if (r.ok) showStatus(`Connected — HTTP ${r.status}.`, 'ok');
|
||||
else showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err');
|
||||
if (!r.ok) {
|
||||
showStatus(`HTTP ${r.status}: ${r.statusText}`, 'err');
|
||||
return;
|
||||
}
|
||||
// A 200 is NOT sufficient. If the URL resolves to the Vue SPA instead of
|
||||
// the JSON API, the catch-all route returns 200 with an HTML document —
|
||||
// which used to report "Connected" on a config that could not POST at all.
|
||||
const contentType = r.headers.get('content-type') || '';
|
||||
if (!contentType.includes('json')) {
|
||||
showStatus(
|
||||
`${apiUrl} answered with ${contentType || 'no content-type'}, not JSON `
|
||||
+ '— that looks like the FC web UI rather than its API.',
|
||||
'err',
|
||||
);
|
||||
return;
|
||||
}
|
||||
showStatus(`Connected to ${apiUrl} — HTTP ${r.status}.`, 'ok');
|
||||
} catch (e) {
|
||||
showStatus(`Cannot reach ${apiUrl}: ${e.message}`, 'err');
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
{
|
||||
"name": "fabledcurator-extension",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.11",
|
||||
"private": true,
|
||||
"description": "Firefox extension for FabledCurator",
|
||||
"comment_ignore_files": "The --ignore-files list comes from scripts/packaging.sh, the single source of truth shared with ci.yml's guard and the derived-version patch count. `set -f` is REQUIRED before the substitution: without it the shell globs `test/**` against the working tree and silently narrows the pattern to whatever files happen to exist.",
|
||||
"scripts": {
|
||||
"lint": "web-ext lint --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore",
|
||||
"start": "web-ext run --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --firefox=firefox",
|
||||
"build": "web-ext build --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --overwrite-dest",
|
||||
"sign": "web-ext sign --source-dir=. --no-config-discovery --ignore-files package.json package-lock.json web-ext-artifacts node_modules README.md .gitignore --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET"
|
||||
"lint": "set -f; web-ext lint --source-dir=. --no-config-discovery --ignore-files $(sh scripts/packaging.sh ignore)",
|
||||
"start": "set -f; web-ext run --source-dir=. --no-config-discovery --ignore-files $(sh scripts/packaging.sh ignore) --firefox=firefox",
|
||||
"build": "set -f; web-ext build --source-dir=. --no-config-discovery --ignore-files $(sh scripts/packaging.sh ignore) --overwrite-dest",
|
||||
"sign": "set -f; web-ext sign --source-dir=. --no-config-discovery --ignore-files $(sh scripts/packaging.sh ignore) --channel=unlisted --api-key=$WEB_EXT_API_KEY --api-secret=$WEB_EXT_API_SECRET",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.0",
|
||||
"web-ext": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-13
@@ -2,6 +2,15 @@ document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
const CONNECTION_TEST_INTERVAL = 2 * 60 * 1000;
|
||||
|
||||
// A centered muted note div — the loading / empty state shared by the platform
|
||||
// and sources lists.
|
||||
function mutedNote(text) {
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
||||
d.textContent = text;
|
||||
return d;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const cfg = await browser.runtime.sendMessage({ type: 'GET_CONFIG' });
|
||||
@@ -38,10 +47,7 @@ function showSetupRequired() {
|
||||
function showPlatformsLoading() {
|
||||
const c = document.getElementById('platforms-list');
|
||||
c.textContent = '';
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
||||
d.textContent = 'Loading platforms…';
|
||||
c.appendChild(d);
|
||||
c.appendChild(mutedNote('Loading platforms…'));
|
||||
}
|
||||
|
||||
async function testConnectionIfNeeded() {
|
||||
@@ -75,8 +81,12 @@ async function checkForUpdate() {
|
||||
}
|
||||
|
||||
function showUpdateBanner(r) {
|
||||
// The channel names itself beside the version, never inside it (#3113).
|
||||
// Absent when the instance doesn't report one, and the banner then reads
|
||||
// exactly as it did before the field existed.
|
||||
const channel = r.channel ? ` (${r.channel})` : '';
|
||||
document.getElementById('update-text').textContent =
|
||||
`Update available — v${r.latestVersion} (installed v${r.currentVersion})`;
|
||||
`Update available${channel} — v${r.latestVersion} (installed v${r.currentVersion})`;
|
||||
// Opening the signed XPI triggers Firefox's native install prompt.
|
||||
document.getElementById('update-btn').addEventListener('click', () => {
|
||||
browser.tabs.create({ url: r.xpiUrl });
|
||||
@@ -183,10 +193,7 @@ async function exportAllCookies() {
|
||||
async function loadSources() {
|
||||
const c = document.getElementById('sources-list');
|
||||
c.textContent = '';
|
||||
const d = document.createElement('div');
|
||||
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
||||
d.textContent = 'Loading sources…';
|
||||
c.appendChild(d);
|
||||
c.appendChild(mutedNote('Loading sources…'));
|
||||
const r = await browser.runtime.sendMessage({ type: 'LIST_SOURCES' });
|
||||
c.textContent = '';
|
||||
if (r.error) {
|
||||
@@ -197,10 +204,7 @@ async function loadSources() {
|
||||
return;
|
||||
}
|
||||
if (!r.sources || r.sources.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
||||
empty.textContent = 'No sources yet.';
|
||||
c.appendChild(empty);
|
||||
c.appendChild(mutedNote('No sources yet.'));
|
||||
return;
|
||||
}
|
||||
for (const src of r.sources) c.appendChild(createSourceRow(src));
|
||||
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/bin/sh
|
||||
# Single source of truth for "what ships inside the XPI", plus the version
|
||||
# derived from it.
|
||||
#
|
||||
# Three consumers used to hand-maintain their own copy of this list, and
|
||||
# keeping three copies of one fact in sync by hand is how issue #2397 happened:
|
||||
#
|
||||
# 1. web-ext's --ignore-files (extension/package.json's four scripts)
|
||||
# 2. the :(exclude) pathspec (what moves the version — a WIDER
|
||||
# set than the ignore list; see
|
||||
# NOT_VERSION_RELEVANT)
|
||||
# 3. the git-log pathspec (the derived version, below)
|
||||
#
|
||||
# They now all read from here. POSIX sh only — CI's run shell is busybox.
|
||||
#
|
||||
# -f (no pathname expansion) is set for the whole script and is load-bearing:
|
||||
# the lists below are iterated with deliberate word-splitting, and without -f
|
||||
# the shell would also GLOB them, expanding `test/**` into whatever files
|
||||
# happen to exist and corrupting the output. A caller's own `set -f` does not
|
||||
# help here — this runs as a separate sh process and does not inherit it.
|
||||
# Callers still need their own `set -f` for the substituted result; the two
|
||||
# guards protect different expansions.
|
||||
set -euf
|
||||
|
||||
# Paths under extension/ that are NOT packaged into the XPI.
|
||||
#
|
||||
# Split by whether git tracks them: node_modules and web-ext-artifacts are
|
||||
# build/dependency output that never appears in a commit, so they belong in
|
||||
# web-ext's ignore list but would be meaningless in a git pathspec.
|
||||
#
|
||||
# Directories need BOTH forms. `test/**` matches the files inside, but not the
|
||||
# directory entry itself — web-ext writes an entry for the directory too, so
|
||||
# with only the glob the XPI ends up carrying empty `test/` and `scripts/`
|
||||
# entries (caught by the XPI-content check on 2026-08-03). The bare name alone
|
||||
# is not enough either: minimatch's `test` does not match `test/url.spec.js`,
|
||||
# so dropping the glob would ship the contents. Keep both.
|
||||
NOT_PACKAGED_TRACKED='package.json package-lock.json README.md .gitignore vitest.config.js scripts scripts/** test test/**'
|
||||
NOT_PACKAGED_BUILD='web-ext-artifacts node_modules'
|
||||
|
||||
# Paths under extension/ that cannot change the SHIPPED BYTES, and so must not
|
||||
# move the derived version.
|
||||
#
|
||||
# Deliberately NOT the same list as NOT_PACKAGED_TRACKED, and the whole
|
||||
# difference is `scripts/`. packaging.sh is not packaged into the XPI — but it
|
||||
# DECIDES the version string, and build.yml stamps that string into the
|
||||
# manifest.json that is packaged. A change to how the version is computed is
|
||||
# therefore a change to the shipped bytes.
|
||||
#
|
||||
# Excluding it was harmless only while every push rebuilt the web image.
|
||||
# Milestone 313 step 4 made the rebuild conditional on the derived revision
|
||||
# moving, which turned it into a silent failure: a packaging.sh change gives a
|
||||
# NEW version, so sign-extension misses its ext-<version> cache and signs —
|
||||
# while build-web sees an unmoved revision, reuses the published image, and
|
||||
# ships the OLD XPI. An orphaned AMO signature, and an instance quietly serving
|
||||
# code the registry says is current.
|
||||
#
|
||||
# The two directions are not symmetric, which is why this list is the narrower
|
||||
# one. Too wide costs a re-sign and a rebuild for a change that ships nothing
|
||||
# new. Too narrow serves stale bytes and says nothing.
|
||||
NOT_VERSION_RELEVANT='package.json package-lock.json README.md .gitignore vitest.config.js test test/**'
|
||||
|
||||
usage() {
|
||||
echo "usage: packaging.sh {ignore|pathspec|version}" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# web-ext --ignore-files values, space-separated.
|
||||
#
|
||||
# Callers MUST disable pathname expansion first (`set -f`), or the shell will
|
||||
# glob `test/**` against the working tree before web-ext ever sees the pattern
|
||||
# and silently narrow it to whatever happens to exist right now.
|
||||
cmd_ignore() {
|
||||
echo "$NOT_PACKAGED_TRACKED $NOT_PACKAGED_BUILD"
|
||||
}
|
||||
|
||||
# git pathspec excluding the tracked files that cannot change the shipped
|
||||
# bytes, e.g. :(exclude)extension/package.json :(exclude)extension/test/**
|
||||
#
|
||||
# This answers "what moves the version?", NOT "what goes in the XPI?" — see
|
||||
# NOT_VERSION_RELEVANT for why those differ. cmd_ignore answers the other one.
|
||||
# Same `set -f` requirement as above.
|
||||
cmd_pathspec() {
|
||||
for entry in $NOT_VERSION_RELEVANT; do
|
||||
printf ':(exclude)extension/%s ' "$entry"
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
# Strip leading zeros from one segment, leaving at least one digit.
|
||||
#
|
||||
# This exists for AMO and nothing else. Mozilla's version grammar for
|
||||
# addons.mozilla.org is documented as
|
||||
#
|
||||
# ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
|
||||
#
|
||||
# — each segment is either the single digit `0` or starts 1-9, so `08` and
|
||||
# `0201` are rejected outright, while `0` itself is fine. MDN states it in
|
||||
# prose too: "Non-zero numbers must not include a leading zero."
|
||||
#
|
||||
# POSIX sh has no trim-loop, hence the while.
|
||||
unpad() {
|
||||
s=$1
|
||||
while [ "${#s}" -gt 1 ]; do
|
||||
case "$s" in
|
||||
0*) s=${s#0} ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
# The extension's version: `YYYY.M.D.HHMM`, UTC, derived from the commit TIME
|
||||
# of the newest change to a PACKAGED extension file.
|
||||
#
|
||||
# THE ONE DELIBERATE DEPARTURE FROM THE FAMILY SHAPE, and it is a rendering
|
||||
# difference only. Rule 148 says `YYYY.MM.DD.HHMM` zero-padded, and every other
|
||||
# FC artifact emits exactly that. AMO's grammar (see unpad) forbids the padding,
|
||||
# and AMO is not negotiable: a rejected version is burned, since AMO 409s on
|
||||
# re-signing a version it has already seen. So the extension emits THE SAME
|
||||
# NUMBERS unpadded — 2026.08.29.0201 and 2026.8.29.201 are one value in two
|
||||
# renderings, and rule 148 already specifies comparison as numeric per segment,
|
||||
# under which they are equal. Nothing published is reordered by the choice, and
|
||||
# left-padding each segment recovers the family string exactly.
|
||||
#
|
||||
# HHMM is one segment, not two, because AMO allows at most FOUR. Unpadded that
|
||||
# reads oddly (00:14 -> `14`, midnight -> `0`) but stays strictly increasing
|
||||
# within a day, which is all the ordering needs.
|
||||
#
|
||||
# Why the commit's time and not the build's:
|
||||
# * MONOTONIC — max() over a set that only ever gains members.
|
||||
# * STABLE while the extension is unchanged, so an unchanged extension keeps
|
||||
# its version, the ext-<version> signature cache still hits, and AMO is
|
||||
# called once per extension CHANGE rather than once per push. Build-time
|
||||
# minutes would re-sign on every push and never let two channels share a
|
||||
# signature.
|
||||
# * SHARED ACROSS CHANNELS — after a merge, `main` sees the same commit and
|
||||
# derives the same number, so `:latest` reuses the signature `:dev` already
|
||||
# produced for byte-identical code. Same code, same version, one signing.
|
||||
# * REPRODUCIBLE — any checkout of a commit yields that commit's version.
|
||||
#
|
||||
# Never a commit count (family rule 149): a count is per-branch, so `dev` and
|
||||
# `main` count different histories of the same code and order by which branch
|
||||
# accumulated more commits rather than by which is newer. A squash-merge makes
|
||||
# that permanent. Roundtable's 2026-08-24 incident, in a different repo.
|
||||
#
|
||||
# Requires real history: a depth-1 clone sees one commit and will derive a wrong
|
||||
# (too low) value. Every consumer must check out with fetch-depth: 0.
|
||||
#
|
||||
# Formatted through git rather than date(1): busybox date does not reliably
|
||||
# accept `-d @<epoch>`, and git's --date=format-local is available wherever git
|
||||
# is. TZ=UTC so the value does not depend on the runner's timezone.
|
||||
cmd_version() {
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
# Unquoted on purpose: the pathspec must word-split into separate args.
|
||||
# Globbing is already off script-wide (set -euf above).
|
||||
# shellcheck disable=SC2046
|
||||
sha=$(cd "$root" && git log --format='%ct %H' HEAD -- extension/ $(cmd_pathspec) \
|
||||
| sort -n | tail -1 | cut -d' ' -f2)
|
||||
if [ -z "$sha" ]; then
|
||||
echo "packaging.sh: no commit touches a packaged extension file" >&2
|
||||
exit 1
|
||||
fi
|
||||
padded=$(cd "$root" && TZ=UTC git show -s --format=%cd \
|
||||
--date='format-local:%Y.%m.%d.%H%M' "$sha")
|
||||
# Rebinding the function's own positional params, which are unused here.
|
||||
# shellcheck disable=SC2046
|
||||
set -- $(echo "$padded" | tr '.' ' ')
|
||||
echo "$(unpad "$1").$(unpad "$2").$(unpad "$3").$(unpad "$4")"
|
||||
}
|
||||
|
||||
[ $# -ge 1 ] || usage
|
||||
case "$1" in
|
||||
ignore) cmd_ignore ;;
|
||||
pathspec) cmd_pathspec ;;
|
||||
version) cmd_version ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const LIB_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'lib')
|
||||
|
||||
/**
|
||||
* Load an extension lib and hand back the globals it declares.
|
||||
*
|
||||
* The files under lib/ are CLASSIC scripts, not ES modules: manifest.json
|
||||
* lists them in `background.scripts` and options.html pulls them in with a
|
||||
* plain <script> tag, so they declare bare functions into a shared scope and
|
||||
* export nothing. Rather than bolt a `module.exports` shim onto production
|
||||
* code that would never run in the browser, evaluate the real file the same
|
||||
* way the browser does — as a script body — and pick the declarations back out.
|
||||
*
|
||||
* This means the specs exercise the exact bytes that get packaged into the
|
||||
* XPI. Only usable for libs that touch no browser APIs at load time
|
||||
* (url.js, platforms.js); cookies.js and api.js reference `browser.*` and
|
||||
* would need stubbing, which is why they aren't loaded this way.
|
||||
*
|
||||
* @param {string} filename e.g. 'url.js'
|
||||
* @param {string[]} names declarations to return, e.g. ['normalizeApiUrl']
|
||||
*/
|
||||
export function loadLib(filename, names) {
|
||||
const source = readFileSync(path.join(LIB_DIR, filename), 'utf8')
|
||||
const factory = new Function(`${source}\nreturn { ${names.join(', ')} }`)
|
||||
return factory()
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import { loadLib } from './helpers/loadLib.js'
|
||||
|
||||
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const manifest = JSON.parse(readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf8'))
|
||||
|
||||
const { getPlatformFromUrl, isArtistPage, PLATFORMS, PLATFORM_ARTIST_PATTERNS } = loadLib(
|
||||
'platforms.js',
|
||||
['getPlatformFromUrl', 'isArtistPage', 'PLATFORMS', 'PLATFORM_ARTIST_PATTERNS']
|
||||
)
|
||||
|
||||
describe('getPlatformFromUrl', () => {
|
||||
it('identifies each platform from a domain URL', () => {
|
||||
expect(getPlatformFromUrl('https://www.patreon.com/Atole')).toBe('patreon')
|
||||
expect(getPlatformFromUrl('https://subscribestar.adult/someone')).toBe('subscribestar')
|
||||
expect(getPlatformFromUrl('https://www.hentai-foundry.com/user/someone')).toBe('hentaifoundry')
|
||||
expect(getPlatformFromUrl('https://discord.com/channels/@me')).toBe('discord')
|
||||
expect(getPlatformFromUrl('https://www.pixiv.net/en/users/123')).toBe('pixiv')
|
||||
})
|
||||
|
||||
it('accepts http as well as https, with or without www', () => {
|
||||
expect(getPlatformFromUrl('http://patreon.com/Atole')).toBe('patreon')
|
||||
expect(getPlatformFromUrl('https://www.patreon.com/Atole')).toBe('patreon')
|
||||
})
|
||||
|
||||
it('returns null for unrelated hosts', () => {
|
||||
expect(getPlatformFromUrl('https://example.com/patreon.com')).toBe(null)
|
||||
expect(getPlatformFromUrl('https://not-patreon.com/Atole')).toBe(null)
|
||||
expect(getPlatformFromUrl('')).toBe(null)
|
||||
})
|
||||
|
||||
it('returns null for deviantart, retired at #3069', () => {
|
||||
// The 2026-07-05 product decision (FC downloaders = art-dedicated services
|
||||
// only) left deviantart wired for seven weeks. Asserting the negative is
|
||||
// what keeps a partial retirement from being re-completed by accident.
|
||||
expect(getPlatformFromUrl('https://www.deviantart.com/someone')).toBe(null)
|
||||
expect(PLATFORMS.deviantart).toBeUndefined()
|
||||
expect(PLATFORM_ARTIST_PATTERNS.deviantart).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isArtistPage', () => {
|
||||
// Regression cases from issue #1485: the Add-to-FC button vanished once the
|
||||
// operator SUBSCRIBED to a creator, because Patreon serves subscribed users
|
||||
// the /cw/ ("creator workspace") URL and the pattern only matched the bare
|
||||
// root. All three creator URL shapes must match, plus inner pages — the
|
||||
// button matters most exactly when you're subscribed.
|
||||
it('matches all three Patreon creator URL shapes', () => {
|
||||
expect(isArtistPage('https://www.patreon.com/Atole', 'patreon')).toBe(true)
|
||||
expect(isArtistPage('https://www.patreon.com/c/Atole', 'patreon')).toBe(true)
|
||||
expect(isArtistPage('https://www.patreon.com/cw/Atole', 'patreon')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches Patreon creator inner pages', () => {
|
||||
expect(isArtistPage('https://www.patreon.com/cw/Atole/posts', 'patreon')).toBe(true)
|
||||
expect(isArtistPage('https://www.patreon.com/Atole/membership', 'patreon')).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes Patreon navigation pages that are not creators', () => {
|
||||
for (const nav of ['home', 'search', 'messages', 'notifications', 'library', 'settings']) {
|
||||
expect(isArtistPage(`https://www.patreon.com/${nav}`, 'patreon')).toBe(false)
|
||||
expect(isArtistPage(`https://www.patreon.com/${nav}/anything`, 'patreon')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches SubscribeStar creator roots on both TLDs but not feed pages', () => {
|
||||
expect(isArtistPage('https://subscribestar.adult/someone', 'subscribestar')).toBe(true)
|
||||
expect(isArtistPage('https://subscribestar.com/someone', 'subscribestar')).toBe(true)
|
||||
expect(isArtistPage('https://subscribestar.adult/feed', 'subscribestar')).toBe(false)
|
||||
expect(isArtistPage('https://subscribestar.adult/messages', 'subscribestar')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches Hentai Foundry user pages only', () => {
|
||||
expect(isArtistPage('https://www.hentai-foundry.com/user/someone', 'hentaifoundry')).toBe(true)
|
||||
expect(isArtistPage('https://www.hentai-foundry.com/pictures/popular', 'hentaifoundry')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('matches Pixiv numeric user pages, with or without the /en/ prefix', () => {
|
||||
expect(isArtistPage('https://www.pixiv.net/users/12345', 'pixiv')).toBe(true)
|
||||
expect(isArtistPage('https://www.pixiv.net/en/users/12345', 'pixiv')).toBe(true)
|
||||
expect(isArtistPage('https://www.pixiv.net/en/artworks/999', 'pixiv')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a platform with no artist pattern (discord)', () => {
|
||||
expect(isArtistPage('https://discord.com/channels/@me', 'discord')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for an unknown platform key', () => {
|
||||
expect(isArtistPage('https://www.patreon.com/Atole', 'nope')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('platform table integrity', () => {
|
||||
it('gives every artist pattern a corresponding platform entry', () => {
|
||||
// A pattern keyed to a platform that no longer exists is dead code that
|
||||
// silently never fires; the reverse (a platform with no pattern) is the
|
||||
// legitimate discord case, so only this direction is an error.
|
||||
for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) {
|
||||
expect(Object.keys(PLATFORMS)).toContain(key)
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every platform the fields the popup renders', () => {
|
||||
for (const [key, platform] of Object.entries(PLATFORMS)) {
|
||||
expect(platform.name, `${key}.name`).toBeTruthy()
|
||||
expect(platform.color, `${key}.color`).toMatch(/^#[0-9A-Fa-f]{6}$/)
|
||||
expect(['cookies', 'token'], `${key}.authType`).toContain(platform.authType)
|
||||
expect(platform.urlPattern, `${key}.urlPattern`).toBeInstanceOf(RegExp)
|
||||
expect(Array.isArray(platform.domains), `${key}.domains`).toBe(true)
|
||||
expect(platform.domains.length, `${key}.domains`).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps every artist URL matched by its own platform pattern too', () => {
|
||||
// isArtistPage is only ever consulted after getPlatformFromUrl resolves a
|
||||
// key, so an artist pattern matching a URL its platform's urlPattern
|
||||
// rejects would be unreachable.
|
||||
const samples = {
|
||||
patreon: 'https://www.patreon.com/cw/Atole',
|
||||
subscribestar: 'https://subscribestar.adult/someone',
|
||||
hentaifoundry: 'https://www.hentai-foundry.com/user/someone',
|
||||
pixiv: 'https://www.pixiv.net/en/users/12345'
|
||||
}
|
||||
for (const [key, url] of Object.entries(samples)) {
|
||||
expect(isArtistPage(url, key), `${key} artist pattern`).toBe(true)
|
||||
expect(getPlatformFromUrl(url), `${key} urlPattern`).toBe(key)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('manifest.json agrees with the platform table', () => {
|
||||
// #3069: deviantart was dropped from the product in July but survived in
|
||||
// manifest.json until late August, because NOTHING tied the manifest's
|
||||
// domain lists back to PLATFORMS. These two specs are that tie. Both
|
||||
// directions matter: a stale match ships host access the product decided
|
||||
// not to use, and a missing one silently kills the Add-to-FC button.
|
||||
const matches = manifest.content_scripts[0].matches
|
||||
// '*://*.patreon.com/*' -> '.patreon.com', the form PLATFORMS.domains uses.
|
||||
const hostOf = (m) => m.replace(/^\*:\/\/\*/, '').replace(/\/\*$/, '')
|
||||
|
||||
it('injects the content script only on domains a platform claims', () => {
|
||||
for (const m of matches) {
|
||||
const host = hostOf(m)
|
||||
const owner = Object.entries(PLATFORMS).find(
|
||||
([, p]) => p.domains.includes(host)
|
||||
)
|
||||
expect(owner, `no platform claims content-script match "${m}"`).toBeTruthy()
|
||||
// The content script exists to draw the Add-as-source button, so a
|
||||
// platform with no artist pattern (discord) has no business here.
|
||||
expect(
|
||||
PLATFORM_ARTIST_PATTERNS[owner[0]],
|
||||
`"${m}" injects for ${owner[0]}, which has no artist pattern`
|
||||
).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('injects on every platform that has an artist pattern', () => {
|
||||
const covered = new Set(
|
||||
matches
|
||||
.map(hostOf)
|
||||
.map((h) => Object.entries(PLATFORMS).find(([, p]) => p.domains.includes(h)))
|
||||
.filter(Boolean)
|
||||
.map(([key]) => key)
|
||||
)
|
||||
for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) {
|
||||
expect(covered, `${key} has an artist pattern but no content-script match`).toContain(key)
|
||||
}
|
||||
})
|
||||
|
||||
it('requests no host permission for a domain no platform claims', () => {
|
||||
// '*://*/*' is the deliberate exception: FC is self-hosted at an arbitrary
|
||||
// operator-chosen URL, so the extension cannot enumerate its own backend.
|
||||
// Every OTHER entry is a platform domain and must still have an owner.
|
||||
for (const h of manifest.host_permissions) {
|
||||
if (h === '*://*/*') continue
|
||||
const host = hostOf(h)
|
||||
// pixiv's OAuth/API hosts are pixiv infrastructure, not creator pages,
|
||||
// so they are matched by suffix rather than by the domains list.
|
||||
const claimed = Object.values(PLATFORMS).some(
|
||||
(p) => p.domains.includes(host) || p.domains.some((d) => host.endsWith(d))
|
||||
)
|
||||
expect(claimed, `host permission "${h}" belongs to no platform`).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { loadLib } from './helpers/loadLib.js'
|
||||
|
||||
const { normalizeApiUrl, webRootFromApiUrl } = loadLib('url.js', [
|
||||
'normalizeApiUrl',
|
||||
'webRootFromApiUrl'
|
||||
])
|
||||
|
||||
describe('normalizeApiUrl', () => {
|
||||
// The bug this exists for (issue #2393): the instance root was accepted and
|
||||
// stored verbatim, so every request went to /credentials instead of
|
||||
// /api/credentials. That path is a Vue router route, so the SPA catch-all
|
||||
// answered GET with 200 HTML and rejected POST with 405 — which read as a
|
||||
// backend bug rather than a URL one.
|
||||
it('appends /api to an instance root', () => {
|
||||
expect(normalizeApiUrl('http://curator.traefik.internal')).toBe(
|
||||
'http://curator.traefik.internal/api'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves an API root alone rather than doubling the suffix', () => {
|
||||
expect(normalizeApiUrl('http://curator.traefik.internal/api')).toBe(
|
||||
'http://curator.traefik.internal/api'
|
||||
)
|
||||
})
|
||||
|
||||
it('is idempotent', () => {
|
||||
const once = normalizeApiUrl('http://curator.example.com')
|
||||
expect(normalizeApiUrl(once)).toBe(once)
|
||||
})
|
||||
|
||||
it('strips trailing slashes before deciding', () => {
|
||||
expect(normalizeApiUrl('http://curator.example.com/')).toBe('http://curator.example.com/api')
|
||||
expect(normalizeApiUrl('http://curator.example.com///')).toBe('http://curator.example.com/api')
|
||||
expect(normalizeApiUrl('http://curator.example.com/api/')).toBe('http://curator.example.com/api')
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace (paste artifacts)', () => {
|
||||
expect(normalizeApiUrl(' http://curator.example.com ')).toBe(
|
||||
'http://curator.example.com/api'
|
||||
)
|
||||
})
|
||||
|
||||
it('matches the /api suffix case-insensitively', () => {
|
||||
expect(normalizeApiUrl('http://curator.example.com/API')).toBe('http://curator.example.com/API')
|
||||
})
|
||||
|
||||
it('returns empty string for empty/nullish input, never a bare "/api"', () => {
|
||||
// isConfigured() gates on truthiness, so a bogus '/api' here would read as
|
||||
// "configured" and produce a request against the options page's own origin.
|
||||
expect(normalizeApiUrl('')).toBe('')
|
||||
expect(normalizeApiUrl(' ')).toBe('')
|
||||
expect(normalizeApiUrl(null)).toBe('')
|
||||
expect(normalizeApiUrl(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('does not treat a path merely containing "api" as the suffix', () => {
|
||||
expect(normalizeApiUrl('http://curator.example.com/apiary')).toBe(
|
||||
'http://curator.example.com/apiary/api'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a subpath deployment', () => {
|
||||
expect(normalizeApiUrl('http://host.internal/curator')).toBe('http://host.internal/curator/api')
|
||||
})
|
||||
})
|
||||
|
||||
describe('webRootFromApiUrl', () => {
|
||||
// The SPA root, where the Vue router and the served XPI live. Used by
|
||||
// OPEN_ARTIST_PAGE and the self-update check — NOT the JSON API.
|
||||
it('strips the /api suffix', () => {
|
||||
expect(webRootFromApiUrl('http://curator.example.com/api')).toBe('http://curator.example.com')
|
||||
})
|
||||
|
||||
it('accepts an instance root unchanged', () => {
|
||||
expect(webRootFromApiUrl('http://curator.example.com')).toBe('http://curator.example.com')
|
||||
})
|
||||
|
||||
it('agrees with normalizeApiUrl in both directions', () => {
|
||||
for (const input of ['http://curator.example.com', 'http://curator.example.com/api']) {
|
||||
expect(normalizeApiUrl(webRootFromApiUrl(input))).toBe(normalizeApiUrl(input))
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a subpath deployment', () => {
|
||||
expect(webRootFromApiUrl('http://host.internal/curator/api')).toBe('http://host.internal/curator')
|
||||
})
|
||||
|
||||
it('returns empty string for empty/nullish input', () => {
|
||||
expect(webRootFromApiUrl('')).toBe('')
|
||||
expect(webRootFromApiUrl(null)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const read = (name) => JSON.parse(readFileSync(path.join(EXT_DIR, name), 'utf8'))
|
||||
const readText = (...seg) => readFileSync(path.join(EXT_DIR, ...seg), 'utf8')
|
||||
|
||||
// Only the git-free subcommands are exercised here: `version` shells out to
|
||||
// git, and the extension lane runs on node:24-bookworm-slim which may not ship
|
||||
// it. That one is covered where git is guaranteed — ci.yml's extension-version
|
||||
// lane and build.yml both run on ci-python.
|
||||
const packaging = (cmd) =>
|
||||
execFileSync('sh', [path.join(EXT_DIR, 'scripts', 'packaging.sh'), cmd], {
|
||||
cwd: EXT_DIR,
|
||||
encoding: 'utf8'
|
||||
})
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
|
||||
describe('packaging.sh — the single definition of what ships', () => {
|
||||
it('emits an ignore list and a pathspec that agree on the tracked files', () => {
|
||||
const ignore = packaging('ignore')
|
||||
const pathspec = packaging('pathspec').map((e) => e.replace(':(exclude)extension/', ''))
|
||||
|
||||
// Every git-excluded path must also be hidden from web-ext. The reverse is
|
||||
// not required: node_modules and web-ext-artifacts are build output git
|
||||
// never tracks, so they appear only in the ignore list.
|
||||
for (const entry of pathspec) {
|
||||
expect(ignore, `pathspec has "${entry}" but --ignore-files does not`).toContain(entry)
|
||||
}
|
||||
expect(pathspec.length).toBeGreaterThan(0)
|
||||
expect(ignore).toContain('node_modules')
|
||||
})
|
||||
|
||||
it('emits glob patterns literally, never expanded against the working tree', () => {
|
||||
// The script iterates its lists with deliberate word-splitting, so it must
|
||||
// run with pathname expansion off. Without that, invoking it from a cwd
|
||||
// where test/ exists (exactly how ci.yml and vitest call it) expands
|
||||
// `test/**` into the individual spec files, and the pathspec silently stops
|
||||
// covering anything added later.
|
||||
const pathspec = packaging('pathspec')
|
||||
expect(pathspec).toContain(':(exclude)extension/test/**')
|
||||
expect(pathspec.some((e) => e.includes('.spec.js'))).toBe(false)
|
||||
expect(pathspec.some((e) => e.includes('helpers'))).toBe(false)
|
||||
|
||||
const ignore = packaging('ignore')
|
||||
expect(ignore).toContain('test/**')
|
||||
expect(ignore).toContain('scripts/**')
|
||||
expect(ignore.some((e) => e.includes('.spec.js'))).toBe(false)
|
||||
})
|
||||
|
||||
it('lets packaging.sh move the version, though it never ships in the XPI', () => {
|
||||
// The two lists answer different questions and this is the one place they
|
||||
// disagree. scripts/ is ignored by web-ext — it is repo tooling, not addon
|
||||
// code — but packaging.sh DECIDES the version string, and build.yml stamps
|
||||
// that string into the manifest.json that does ship. So changing how the
|
||||
// version is computed changes the shipped bytes.
|
||||
//
|
||||
// Excluding it from the pathspec was invisible while every push rebuilt the
|
||||
// web image. Milestone 313 step 4 made that rebuild conditional on the
|
||||
// derived revision moving, and the omission turned into a silent failure:
|
||||
// a new version means sign-extension misses its ext-<version> cache and
|
||||
// signs, while build-web sees an unmoved revision, reuses the published
|
||||
// image and ships the OLD XPI. An orphaned signature, and an instance
|
||||
// serving code the registry calls current.
|
||||
const pathspec = packaging('pathspec')
|
||||
expect(
|
||||
pathspec.some((e) => e.startsWith(':(exclude)extension/scripts')),
|
||||
'the pathspec excludes scripts/, so a change to how the version is '
|
||||
+ 'derived would not move the version it derives',
|
||||
).toBe(false)
|
||||
|
||||
// ...and it is still kept out of the package itself. Both must hold: the
|
||||
// tempting "fix" for either half is to make the two lists one again.
|
||||
expect(packaging('ignore')).toContain('scripts')
|
||||
})
|
||||
|
||||
it('keeps its own scripts and specs out of the XPI', () => {
|
||||
// Both are repo infrastructure. web-ext packages everything not ignored, so
|
||||
// omitting either would ship dev tooling to users -- and `test/**` in
|
||||
// particular only survives because callers `set -f` before substituting it.
|
||||
const ignore = packaging('ignore')
|
||||
expect(ignore).toContain('vitest.config.js')
|
||||
// Both forms per directory. The glob covers the contents; the bare name
|
||||
// covers the directory ENTRY, which web-ext writes separately — with only
|
||||
// the glob, the XPI carries an empty `test/` and `scripts/`.
|
||||
for (const dir of ['test', 'scripts']) {
|
||||
expect(ignore, `${dir} contents`).toContain(`${dir}/**`)
|
||||
expect(ignore, `${dir} directory entry`).toContain(dir)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('consumers delegate rather than keeping their own copy', () => {
|
||||
// These assertions are the actual anti-regression value: it is easy for a
|
||||
// future edit to "simplify" by inlining a literal list again, which silently
|
||||
// reintroduces the drift that issue #2397 was about.
|
||||
it('package.json derives --ignore-files from the script', () => {
|
||||
for (const [name, script] of Object.entries(read('package.json').scripts)) {
|
||||
if (!script.includes('--ignore-files')) continue
|
||||
expect(script, `${name} should call packaging.sh`).toContain('scripts/packaging.sh ignore')
|
||||
expect(script, `${name} must set -f before the substitution`).toMatch(/set -f;/)
|
||||
}
|
||||
})
|
||||
|
||||
// Read from disk rather than listed by hand. The point of this assertion is
|
||||
// that it survives consumers coming and going, and a hardcoded list is the
|
||||
// one part of it that cannot — release.yml (milestone 318 step 7) would have
|
||||
// joined the directory without joining the check.
|
||||
const WORKFLOWS = readdirSync(path.join(EXT_DIR, '..', '.forgejo', 'workflows')).filter((f) =>
|
||||
f.endsWith('.yml')
|
||||
)
|
||||
|
||||
it('no workflow hardcodes the packaged-file set', () => {
|
||||
// ci.yml used to substitute `packaging.sh pathspec` directly, for the
|
||||
// manual-bump guard that milestone 271 step 5 retired. Nothing inlines the
|
||||
// set today, and nothing should start to: a literal :(exclude)extension/...
|
||||
// in a workflow means someone bypassed the shared definition, which is
|
||||
// exactly the drift #2397 was about.
|
||||
expect(
|
||||
WORKFLOWS.length,
|
||||
'no workflows found — the glob is not looking where it thinks'
|
||||
).toBeGreaterThan(2)
|
||||
for (const wf of WORKFLOWS) {
|
||||
const text = readText('..', '.forgejo', 'workflows', wf)
|
||||
expect(text, `${wf} inlines an :(exclude) literal`).not.toMatch(/:\(exclude\)extension\//)
|
||||
}
|
||||
})
|
||||
|
||||
it('build.yml takes the shipped version from the script, not from the repo', () => {
|
||||
// The version is DERIVED from commit time (#3092, milestone 271 step 4).
|
||||
// Going back to reading the committed value is not a style regression, it
|
||||
// is the bug: a hand-set version makes dev and main sign the same number
|
||||
// for different code, and the ext-<version> cache then serves one channel
|
||||
// the other's XPI.
|
||||
const build = readText('..', '.forgejo', 'workflows', 'build.yml')
|
||||
expect(build).toContain('packaging.sh version')
|
||||
expect(build, 'build.yml re-reads the committed version instead of deriving it')
|
||||
.not.toMatch(/grep[^\n]*'"version"'[^\n]*package\.json/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extension version', () => {
|
||||
// Mozilla's published grammar for addons.mozilla.org, transcribed from MDN's
|
||||
// manifest.json/version page. Each segment is the single digit 0 or starts
|
||||
// 1-9 — so no leading zeros — and there are at most four of them.
|
||||
const AMO = /^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$/
|
||||
|
||||
it('keeps a committed version AMO would accept, though it ships nothing', () => {
|
||||
// The committed value is wholly inert since milestone 318 step 8: there is
|
||||
// no hand-set MAJOR.MINOR left for packaging.sh to read, and build.yml
|
||||
// stamps the derived string over both files before web-ext sees them.
|
||||
//
|
||||
// It is still asserted, for one reason: `npm run build` locally packages
|
||||
// whatever is committed, so a value AMO would reject turns a local build
|
||||
// into a confusing failure with no CI signal ahead of it. ci.yml checks
|
||||
// the same grammar against the DERIVED value, which is the one AMO sees.
|
||||
for (const file of ['manifest.json', 'package.json']) {
|
||||
expect(read(file).version, `${file} version is not AMO-shaped`).toMatch(AMO)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects the zero-padded family shape, which is why the extension unpads', () => {
|
||||
// Guards the reason for the exception, not just its result. If this ever
|
||||
// starts passing, someone has loosened the pattern and the next sign burns
|
||||
// an AMO version to find out. (#3138.)
|
||||
expect('2026.08.29.0201').not.toMatch(AMO)
|
||||
expect('2026.8.29.201').toMatch(AMO)
|
||||
// Five segments: AMO allows four.
|
||||
expect('2026.8.29.2.1').not.toMatch(AMO)
|
||||
})
|
||||
|
||||
it('declares manifest v3', () => {
|
||||
expect(read('manifest.json').manifest_version).toBe(3)
|
||||
})
|
||||
|
||||
it('lists every background script that exists, in dependency order', () => {
|
||||
// url.js must load BEFORE api.js: api.js calls normalizeApiUrl at
|
||||
// init()-time, and these are classic scripts sharing one scope, so a
|
||||
// reordering here is a runtime ReferenceError with no build-time signal.
|
||||
const scripts = read('manifest.json').background.scripts
|
||||
for (const rel of scripts) {
|
||||
expect(() => readFileSync(path.join(EXT_DIR, rel)), `missing ${rel}`).not.toThrow()
|
||||
}
|
||||
expect(scripts.indexOf('lib/url.js')).toBeLessThan(scripts.indexOf('lib/api.js'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Mirrors frontend/vitest.config.js, minus the Vue plugin — the extension has
|
||||
// no SFCs and mounts nothing. Pure-logic specs only, so `node` is enough; the
|
||||
// libs under test are deliberately the ones with no browser-API surface (see
|
||||
// test/helpers/loadLib.js).
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['test/**/*.spec.js'],
|
||||
passWithNoTests: true
|
||||
}
|
||||
})
|
||||
@@ -50,14 +50,19 @@ const projected = ref(null)
|
||||
|
||||
const projectedCounts = computed(() => projected.value?.projected || null)
|
||||
|
||||
const modalDescription = computed(
|
||||
() => projected.value
|
||||
? `Artist “${props.artistName}” — `
|
||||
+ `${projected.value.projected.images} images, `
|
||||
+ `${projected.value.projected.sources} sources, `
|
||||
+ `${Math.round(projected.value.projected.bytes_on_disk / 1_048_576)} MiB on disk`
|
||||
: '',
|
||||
)
|
||||
// `posts` is named here, not left to the counts grid below it: an artist whose
|
||||
// posts are body-only previews as `images: 0`, and a summary line that says
|
||||
// only "0 images" reads as "this artist is empty" while the apply destroys
|
||||
// every captured post body (#3067). Attachments stay in the grid — the grid
|
||||
// renders every key, so this line carries only what changes the read.
|
||||
const modalDescription = computed(() => {
|
||||
const p = projectedCounts.value
|
||||
return p
|
||||
? `Artist “${props.artistName}” — ${p.images} images, `
|
||||
+ `${p.posts} posts, ${p.sources} sources, `
|
||||
+ `${Math.round(p.bytes_on_disk / 1_048_576)} MiB on disk`
|
||||
: ''
|
||||
})
|
||||
|
||||
async function onClick() {
|
||||
loading.value = true
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<!--
|
||||
Canonical settings number field (DRY pass #161): a compact numeric v-text-field
|
||||
with a built-in clamp to [min,max] on commit. Hand-rolled identically across the
|
||||
ML settings cards (HeadsCard x6, CropProposersCard, VideoEmbeddingCard).
|
||||
|
||||
The clamp is the point: the cards previously sent Number(raw) straight to the
|
||||
API, so an out-of-range value bounced off the API's 400 validator (only
|
||||
TranslationCard clamped). This is now the single home for that clamp.
|
||||
|
||||
Binds `modelValue` (v-model) and emits `change` on blur/enter AFTER clamping, so
|
||||
the parent's save reads the already-clamped value — same as the prior
|
||||
`v-model.number` + `@change=save` pattern.
|
||||
-->
|
||||
<template>
|
||||
<v-text-field
|
||||
:model-value="modelValue"
|
||||
:label="label"
|
||||
type="number"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
:density="density" hide-details
|
||||
:style="{ maxWidth }"
|
||||
@update:model-value="v => emit('update:modelValue', v)"
|
||||
@change="onCommit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Number, String], default: null },
|
||||
label: { type: String, default: '' },
|
||||
min: { type: [Number, String], default: null },
|
||||
max: { type: [Number, String], default: null },
|
||||
step: { type: [Number, String], default: 1 },
|
||||
maxWidth: { type: String, default: '200px' },
|
||||
density: { type: String, default: 'compact' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
function onCommit() {
|
||||
// On blur/enter: coerce to a number and clamp to [min,max] so an out-of-range
|
||||
// value never reaches the API. props.modelValue reflects the latest keystroke
|
||||
// (kept in sync by the passthrough above); re-emit the clamped number, then let
|
||||
// the parent persist.
|
||||
let n = Number(props.modelValue)
|
||||
if (!Number.isNaN(n)) {
|
||||
if (props.min !== null && props.min !== '') n = Math.max(Number(props.min), n)
|
||||
if (props.max !== null && props.max !== '') n = Math.min(Number(props.max), n)
|
||||
if (n !== Number(props.modelValue)) emit('update:modelValue', n)
|
||||
}
|
||||
emit('change')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!--
|
||||
Canonical settings toggle row (DRY pass #161): an accent icon + an uppercase
|
||||
.fc-section-h label + a right-aligned switch. Hand-rolled identically in the
|
||||
ML settings cards (HeadsCard x3, CropProposersCard, MLBackfillCard).
|
||||
|
||||
Two-way binds `modelValue` (so the parent switch state stays optimistic) AND
|
||||
emits `change` with the new boolean, so the parent can persist + revert on
|
||||
failure — matching the prior `v-model` + `@update:model-value=handler` pattern.
|
||||
-->
|
||||
<template>
|
||||
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
||||
<v-icon v-if="icon" size="18" :color="iconColor">{{ icon }}</v-icon>
|
||||
<span class="fc-section-h">{{ label }}</span>
|
||||
<v-switch
|
||||
:model-value="modelValue"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
hide-details density="compact" color="success" class="ml-auto"
|
||||
@update:model-value="onSwitch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
label: { type: String, default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
// Icon tint. Default accent; pass null for the theme default (e.g. when a row
|
||||
// is off). null (not undefined) so the default doesn't override it.
|
||||
iconColor: { type: String, default: 'accent' },
|
||||
loading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
function onSwitch(v) {
|
||||
const b = !!v
|
||||
emit('update:modelValue', b)
|
||||
emit('change', b)
|
||||
}
|
||||
</script>
|
||||
@@ -139,9 +139,9 @@ function onThumbError() { thumbError.value = true }
|
||||
position: absolute; top: 8px; left: 8px;
|
||||
width: 22px; height: 22px; border-radius: 4px;
|
||||
border: 2px solid rgba(232, 228, 216, 0.8);
|
||||
background: rgba(20, 23, 26, 0.45);
|
||||
background: rgba(var(--v-theme-background), 0.45);
|
||||
display: grid; place-items: center;
|
||||
color: #14171A; z-index: 11;
|
||||
color: rgb(var(--v-theme-background)); z-index: 11;
|
||||
}
|
||||
.fc-gallery-item__checkbox.on {
|
||||
background: rgb(var(--v-theme-accent));
|
||||
@@ -152,7 +152,7 @@ function onThumbError() { thumbError.value = true }
|
||||
min-width: 22px; height: 22px; padding: 0 5px;
|
||||
border-radius: 11px;
|
||||
background: rgb(var(--v-theme-accent));
|
||||
color: #14171A; font-size: 12px; font-weight: 700;
|
||||
color: rgb(var(--v-theme-background)); font-size: 12px; font-weight: 700;
|
||||
display: grid; place-items: center; z-index: 11;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -160,7 +160,8 @@ function onThumbError() { thumbError.value = true }
|
||||
position: absolute; left: 0; right: 0; bottom: 0;
|
||||
padding: 14px 8px 6px;
|
||||
background: linear-gradient(
|
||||
to top, rgba(20, 23, 26, 0.78), rgba(20, 23, 26, 0)
|
||||
to top, rgba(var(--v-theme-background), 0.78),
|
||||
rgba(var(--v-theme-background), 0)
|
||||
);
|
||||
font-size: 12px; line-height: 1.2;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<!-- #3068: attachment reclamation. PostAttachment's FKs are both SET NULL, so
|
||||
a deleted post or artist leaves the row behind; and the store is
|
||||
sha-addressed, so one blob backs many rows and deleting a row never freed
|
||||
its file. Nothing swept either. Preview first, then apply (destructive:
|
||||
unlinks files). -->
|
||||
<MaintenanceTile
|
||||
icon="mdi-paperclip-off"
|
||||
title="Reclaim orphaned attachments"
|
||||
blurb="Remove attachment records belonging to nothing, and the files nothing references."
|
||||
destructive
|
||||
:open="applying || previewing"
|
||||
>
|
||||
<p class="text-body-2 mb-3">
|
||||
Attachment records survive the post and artist they belonged to, and the
|
||||
files behind them are shared between records — so a deleted record never
|
||||
freed its file on its own. This finds records attributed to
|
||||
<strong>neither</strong> a post nor an artist, and files in the attachment
|
||||
store that <strong>no remaining record</strong> points at.
|
||||
<strong>Preview</strong> first; <strong>Apply</strong> deletes those
|
||||
records and unlinks those files. Files written in the last few hours are
|
||||
always left alone, so an in-progress download is never caught mid-write.
|
||||
</p>
|
||||
|
||||
<div class="d-flex align-center flex-wrap" style="gap: 12px;">
|
||||
<v-btn
|
||||
color="primary" variant="tonal" rounded="pill"
|
||||
:loading="previewing" :disabled="applying" @click="preview"
|
||||
>
|
||||
<v-icon start>mdi-magnify</v-icon> Preview
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error" rounded="pill"
|
||||
:loading="applying"
|
||||
:disabled="previewing || !canApply"
|
||||
@click="confirmOpen = true"
|
||||
>
|
||||
<v-icon start>mdi-paperclip-off</v-icon> Apply
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="summary" :type="summaryType" variant="tonal" class="mt-4"
|
||||
density="comfortable"
|
||||
>
|
||||
<span v-if="applied">
|
||||
Deleted {{ summary.rows }} orphaned record(s) and unlinked
|
||||
{{ summary.files }} file(s), reclaiming {{ humanBytes(summary.bytes) }}.
|
||||
</span>
|
||||
<span v-else-if="hasWork">
|
||||
{{ summary.rows }} orphaned record(s) and {{ summary.files }}
|
||||
unreferenced file(s) — {{ humanBytes(summary.bytes) }} reclaimable.
|
||||
Click <strong>Apply</strong> to remove them.
|
||||
</span>
|
||||
<span v-else>Nothing to reclaim — every attachment is accounted for.</span>
|
||||
|
||||
<!-- Both of these change what the numbers MEAN, so they are stated
|
||||
whenever they are non-zero rather than hidden in a tooltip. -->
|
||||
<div v-if="summary.files_failed" class="mt-1 text-caption">
|
||||
{{ summary.files_failed }} file(s) could not be read or removed — see
|
||||
the worker log.
|
||||
</div>
|
||||
<div v-if="summary.partial" class="mt-1 text-caption">
|
||||
Stopped early at the time limit; some of the store was not examined.
|
||||
Run it again to continue.
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<QueueStatusBar queue="maintenance_long" queue-label="Maintenance" />
|
||||
|
||||
<v-dialog v-model="confirmOpen" max-width="440">
|
||||
<v-card>
|
||||
<v-card-title>Reclaim orphaned attachments?</v-card-title>
|
||||
<v-card-text class="text-body-2">
|
||||
This permanently deletes
|
||||
<strong>{{ summary?.rows ?? 0 }}</strong> attachment record(s) and
|
||||
unlinks <strong>{{ summary?.files ?? 0 }}</strong> file(s)
|
||||
({{ humanBytes(summary?.bytes) }}). Only files that no remaining
|
||||
record points at are removed, so nothing still attached to a post
|
||||
is affected.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="confirmOpen = false">Cancel</v-btn>
|
||||
<v-btn color="error" @click="apply">Reclaim</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useMaintenanceTask } from '../../composables/useMaintenanceTask.js'
|
||||
import { humanBytes } from '../../utils/bytes.js'
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import QueueStatusBar from './QueueStatusBar.vue'
|
||||
|
||||
const confirmOpen = ref(false)
|
||||
|
||||
// Walks the whole attachment store, so it can run for minutes on a large
|
||||
// library — the service caps itself at 900s and reports `partial`. 150 polls
|
||||
// × 2s ≈ 5m of foreground waiting; past that the composable hands off to the
|
||||
// task dashboard rather than spinning forever.
|
||||
const { previewing, applying, summary, applied, preview, apply: applyTask } = useMaintenanceTask({
|
||||
endpoint: '/api/admin/maintenance/reclaim-attachments',
|
||||
storageKey: 'fc.maint.reclaimAttachments',
|
||||
appliedToast: 'Orphaned attachments reclaimed',
|
||||
maxPolls: 150,
|
||||
})
|
||||
|
||||
const hasWork = computed(
|
||||
() => !!summary.value && (summary.value.rows > 0 || summary.value.files > 0),
|
||||
)
|
||||
const canApply = computed(() => hasWork.value && !applied.value)
|
||||
const summaryType = computed(() => {
|
||||
if (applied.value) return 'success'
|
||||
return hasWork.value ? 'info' : 'success'
|
||||
})
|
||||
|
||||
// The confirm dialog gates the destructive apply; close it, then run.
|
||||
function apply () {
|
||||
confirmOpen.value = false
|
||||
applyTask()
|
||||
}
|
||||
</script>
|
||||
@@ -4,12 +4,22 @@
|
||||
<span v-if="manifest?.installed" class="text-caption fc-muted">
|
||||
· Firefox · v{{ manifest.version }}
|
||||
</span>
|
||||
<!-- Which channel this instance serves, so it is visible without
|
||||
installing anything. Rendered only when the image declares one: a
|
||||
locally-built image, or one predating the field, says nothing rather
|
||||
than guessing. Never merged into the version string beside it — see
|
||||
the endpoint's note on why a `-dev` suffix breaks the comparator. -->
|
||||
<v-chip
|
||||
v-if="manifest?.channel"
|
||||
size="x-small" variant="tonal" class="ml-2"
|
||||
:color="manifest.channel === 'dev' ? 'warning' : 'info'"
|
||||
>{{ manifest.channel }}</v-chip>
|
||||
</CardHeading>
|
||||
|
||||
<v-card-text>
|
||||
<p class="fc-muted text-body-2">
|
||||
Pushes session cookies from supported platforms
|
||||
(patreon, subscribestar, hentaifoundry, discord, pixiv, deviantart)
|
||||
(patreon, subscribestar, hentaifoundry, discord, pixiv)
|
||||
into FabledCurator, and lets you add a creator as a source from
|
||||
their page in one click.
|
||||
</p>
|
||||
|
||||
@@ -15,28 +15,23 @@
|
||||
</p>
|
||||
|
||||
<div v-for="p in proposers" :key="p.key" class="fc-proposer">
|
||||
<div class="d-flex align-center mb-1" style="gap: 10px;">
|
||||
<v-icon size="18" :color="p.on ? 'accent' : undefined">{{ p.icon }}</v-icon>
|
||||
<span class="fc-section-h">{{ p.label }}</span>
|
||||
<v-switch
|
||||
v-model="p.on" :loading="busy" hide-details density="compact"
|
||||
color="success" class="ml-auto"
|
||||
@update:model-value="v => saveToggle(p, v)"
|
||||
/>
|
||||
</div>
|
||||
<SettingToggleRow
|
||||
v-model="p.on" :loading="busy" :icon="p.icon"
|
||||
:icon-color="p.on ? 'accent' : null" :label="p.label"
|
||||
@change="v => saveToggle(p, v)"
|
||||
/>
|
||||
<p class="fc-muted text-body-2 mb-2">{{ p.help }}</p>
|
||||
<div class="d-flex flex-wrap mb-4" style="gap: 12px;">
|
||||
<v-text-field
|
||||
v-model="p.weights" label="Weights" density="compact" hide-details
|
||||
style="min-width: 300px; flex: 1;" :disabled="busy || !p.on"
|
||||
placeholder="name | URL | hf_repo::file"
|
||||
@change="save({ [`detector_${p.key}_weights`]: p.weights })"
|
||||
@change="saveField({ [`detector_${p.key}_weights`]: p.weights })"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="p.conf" label="Confidence" type="number"
|
||||
min="0" max="1" step="0.05" density="compact" hide-details
|
||||
style="max-width: 140px;" :disabled="busy || !p.on"
|
||||
@change="save({ [`detector_${p.key}_conf`]: Number(p.conf) })"
|
||||
<SettingNumberField
|
||||
v-model="p.conf" label="Confidence" :min="0" :max="1" :step="0.05"
|
||||
max-width="140px" :disabled="busy || !p.on"
|
||||
@change="saveField({ [`detector_${p.key}_conf`]: Number(p.conf) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,12 +43,12 @@
|
||||
storage. Dedupe IoU drops near-duplicate crops before embedding.
|
||||
</p>
|
||||
<div class="d-flex flex-wrap" style="gap: 12px;">
|
||||
<v-text-field
|
||||
<SettingNumberField
|
||||
v-for="c in caps" :key="c.key"
|
||||
v-model.number="c.val" :label="c.label" type="number"
|
||||
:min="c.min" :max="c.max" :step="c.step || 1" density="compact"
|
||||
hide-details style="max-width: 165px;" :disabled="busy"
|
||||
@change="save({ [c.key]: Number(c.val) })"
|
||||
v-model="c.val" :label="c.label"
|
||||
:min="c.min" :max="c.max" :step="c.step || 1"
|
||||
max-width="165px" :disabled="busy"
|
||||
@change="saveField({ [c.key]: Number(c.val) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,14 +56,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import SettingNumberField from '../common/SettingNumberField.vue'
|
||||
import SettingToggleRow from '../common/SettingToggleRow.vue'
|
||||
import { useSettingSave } from '../../composables/useSettingSave.js'
|
||||
import { useMLStore } from '../../stores/ml.js'
|
||||
|
||||
const mlSettings = useMLStore()
|
||||
const busy = ref(false)
|
||||
const { busy, save } = useSettingSave(mlSettings.patchSettings)
|
||||
const proposers = ref([])
|
||||
const caps = ref([])
|
||||
|
||||
@@ -111,31 +108,20 @@ onMounted(async () => {
|
||||
caps.value = CAP_DEFS.map(c => ({ ...c, val: s[c.key] ?? 0 }))
|
||||
})
|
||||
|
||||
async function save(patch, revert) {
|
||||
busy.value = true
|
||||
try {
|
||||
await mlSettings.patchSettings(patch)
|
||||
toast({ text: 'Saved', type: 'success' })
|
||||
} catch (e) {
|
||||
if (revert) revert()
|
||||
toast({ text: `Could not save: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
// Field @change → persist with a "Saved" confirmation. SettingNumberField has
|
||||
// already clamped numeric values to their [min,max] before this fires.
|
||||
function saveField(patch) {
|
||||
save(patch, { successMessage: 'Saved' })
|
||||
}
|
||||
|
||||
function saveToggle (p, v) {
|
||||
async function saveToggle(p, v) {
|
||||
// Revert the switch on failure so it never lies about the persisted state.
|
||||
save({ [`detector_${p.key}_enabled`]: !!v }, () => { p.on = !v })
|
||||
const ok = await save({ [`detector_${p.key}_enabled`]: !!v }, { successMessage: 'Saved' })
|
||||
if (!ok) p.on = !v
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-section-h {
|
||||
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
|
||||
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-proposer {
|
||||
border-top: 1px solid rgb(var(--v-theme-surface-light)); padding-top: 14px;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,6 @@ async function onCommit() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-code {
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
border-radius: 4px; padding: 2px 8px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user