Compare commits

...
29 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 6e5db94b80 style: sort two import blocks the download cleanup left out of order
CI and images / lint (push) Successful in 1s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 59s
CI and images / integration (push) Successful in 2m41s
CI and images / build-agent (push) Successful in 4s
CI and images / sign-extension (push) Successful in 4m2s
CI and images / build-web (push) Successful in 2m19s
CI and images / smoke-web (push) Successful in 48s
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:09:39 -05:00
bvandeusenandClaude Opus 5.5 da98195ada refactor: the native downloaders share one download loop, file record and seen-ledger key; a clean walk marks its credential verified
CI and images / lint (push) Failing after 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 23s
CI and images / frontend-build (push) Successful in 26s
CI and images / backend-lint-and-test (push) Successful in 53s
CI and images / integration (push) Successful in 2m50s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
Patreon, SubscribeStar and Discord each carried their own copy of the same
per-post download loop, file record and seen-ledger key, and every copy was
another place a fix could be missed.

- BaseNativeDownloader runs one post's files: the seen and on-disk skips,
  the mid-post time-box, pacing, validation and the per-file sidecar. Each
  platform overrides only where its layout differs: Discord's channel folder,
  file names and message-id sidecar, and Patreon's Mux video through yt-dlp.
- One MediaItem and one ledger_key in native_ingest_common replace the three
  per-client copies. Keys are unchanged, except that a SubscribeStar file with
  no gallery id is now keyed by its filename instead of an empty id.
- DownloadResult, ErrorType and the run-stats and tier-gated helpers move
  from gallery_dl.py, which now serves only HentaiFoundry, to
  download_result.py. The unused delegator methods on GalleryDLService go.
- The download lock's platform list is the native-ingester list, not a copy.
- A walk that used a credential and finished with no error type stamps that
  credential's last-verified time, so the credentials page shows when the
  account last worked. A tier-limited walk does not count: an expired session
  hides paid posts the same way.
- The extension no longer clears a retired pixiv token on startup.
- Two comments that still described every download as gallery-dl now name
  both backends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:03:32 -05:00
bvandeusenandClaude Opus 5.5 4cdc851cb5 fix: a game build is recognised before the archive integrity test, so re-extract no longer stalls on it
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 2m1s
CI and images / smoke-web (push) Successful in 49s
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / sign-extension (push) Successful in 3s
CI and images / frontend-build (push) Successful in 24s
CI and images / extension-test (push) Successful in 18s
CI and images / backend-lint-and-test (push) Successful in 47s
CI and images / integration (push) Successful in 2m44s
The archive probe read and checksummed every member before the importer
looked at what the archive held. For a multi-gigabyte game build that is
minutes of work on a file that is kept whole and never extracted, and it
held a re-extract chunk far past its time budget.

- The importer checks the archive's directory for a software build first,
  for the outer archive and for one nested inside an art pack, and only
  probes archives it may extract.
- The probe rejects an archive over the size cap from its directory totals,
  before the integrity test decompresses anything.
- Re-extract logs each archive as it starts and finishes, with its size and
  how long it took, so an archive that stalls a chunk is named in the log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:53:05 -05:00
bvandeusenandClaude Opus 5.5 af8386509a fix: a downloaded file with broken pixel data is skipped instead of ending the download run
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / promote (push) Successful in 1s
CI and images / frontend-build (push) Successful in 24s
CI and images / unattended-failure (push) Skipped
CI and images / extension-test (push) Successful in 19s
CI and images / backend-lint-and-test (push) Successful in 46s
CI and images / integration (push) Successful in 2m47s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 4s
CI and images / build-web (push) Successful in 1m59s
CI and images / smoke-web (push) Successful in 49s
CI and images / refresh-dev (push) Skipped
Importer._import_media and Importer.attach_in_place each carried their own
copy of the still-image filters (verify, minimum size, transparency, single
colour) and of the phash step, and the copies had drifted: attach_in_place's
transparency check had no guard for an OSError from decoding truncated pixel
data, so that error escaped into DownloadService's per-file loop and ended the
run with the remaining files unattached; its phash step swallowed every error
and imported the broken file without a hash.

Both paths now call _screen_still and _phash_of, which turn a decode error
into an invalid_image skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:38:31 -05:00
bvandeusenandClaude Opus 5.5 c2af70e6af feat: archive images reach the library: game builds stay whole, and re-extract covers every archive
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 21s
CI and images / frontend-build (push) Successful in 21s
CI and images / backend-lint-and-test (push) Successful in 45s
CI and images / integration (push) Successful in 2m31s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 4s
CI and images / build-web (push) Successful in 2m1s
CI and images / smoke-web (push) Successful in 49s
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
Most archive attachments never had their images imported. The re-extract
action skipped archives without a post, and nothing kept game builds out
of an extraction.

- An archive holding executables, libraries, or game-engine data
  (Ren'Py, Unity, Godot, RPG Maker, Python builds) is kept as an
  attachment and not extracted. The same holds for one nested inside an
  art pack.
- Re-extract now reaches archives imported from a folder without a post.
  Their images go to the artist, in a folder named after the archive.
- Re-extract passes over archives whose members are already recorded.
- Re-extract stages each archive by symlink instead of copying it.
- The card says what the action does now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:27:36 -05:00
bvandeusenandClaude Opus 5.5 f22d284b74 fix: an artifact's revision breaks a same-second tie toward the newest commit
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 22s
CI and images / lint (push) Successful in 2s
CI and images / frontend-build (push) Successful in 22s
CI and images / integration (push) Successful in 2m38s
CI and images / sign-extension (push) Successful in 3s
CI and images / backend-lint-and-test (push) Successful in 49s
CI and images / build-web (push) Successful in 2m22s
CI and images / smoke-web (push) Successful in 49s
CI and images / build-agent (push) Successful in 6m30s
CI and images / promote (push) Successful in 2s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
Two commits pushed together can share a committer second. artifacts.sh sorted
the whole "<time> <sha>" line, so a tie went to the larger hash, here the
parent. Its revision then named a commit the image's content had moved past,
and test_artifact_identity (which takes git's own order among equals) caught
it. A stable sort on the timestamp alone keeps git log's order, so the
descendant wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:24:38 -05:00
bvandeusenandClaude Opus 5.5 2e76ad89fd fix: a blurred Patreon preview embedded in a viewable post is not downloaded
CI and images / extension-version (push) Successful in 2s
CI and images / lint (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 22s
CI and images / backend-lint-and-test (push) Failing after 51s
CI and images / integration (push) Successful in 2m34s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
A post the account can view can show media from a post it can't, such as a
public teaser carrying a paid post's image. Patreon serves that media blurred,
and the post-level gate (current_user_can_view) lets it through. The CDN URL
says so in its render options ({"b": 70, ...}), so extract_media now drops any
item whose URL asks for a blur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:18:12 -05:00
bvandeusenandClaude Opus 5.5 f2e4f94a96 fix: the image viewer pans without unzooming, scrolls with the wheel, and opens a long strip at full width
- The click that ends a drag no longer resets the zoom.
- The wheel scrolls a zoomed image (shift+wheel goes sideways). Ctrl+wheel or
  a trackpad pinch zooms at the pointer.
- Clicking a long strip or panorama zooms it to fill the canvas, so a comic
  strip opens at reading width. Anything else zooms 2x. The clicked point
  stays under the cursor.
- The zoom limit follows the image's own resolution instead of a flat 4x.
- Panning stops at the image's edges.
- A zoom bar (−, slider, +, size against full resolution, fit) fades in at
  the bottom of the canvas, and the shortcut sheet lists the gestures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:18:12 -05:00
bvandeusenandClaude Opus 5.5 14936b62dc refactor: retire the images tarball backup
CI and images / build-web (push) Successful in 4m10s
CI and images / smoke-web (push) Successful in 1m3s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / promote (push) Successful in 1s
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 50s
CI and images / integration (push) Successful in 2m38s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 8s
Images and attachments are backed up off-site, one object per file, so the
tar+zstd of /images goes: its service and restore, both tasks, the trigger and
restore routes, the Keep last setting, the screen's Images section, and the
tests. Migration 0122 drops backup_run.tar_path and
import_settings.backup_images_keep_last_n and deletes any images runs.

The local database dump stays for an install with no off-site destination,
and the Backups card now says plainly that images and attachments are backed
up only off-site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:07:44 -05:00
bvandeusenandClaude Opus 5.5 794b04a64e fix: a test restore has its own stall threshold, and the dumps list test knows its new fields
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 22s
CI and images / extension-test (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 47s
CI and images / integration (push) Successful in 2m38s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 2m4s
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / smoke-web (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:31:19 -05:00
bvandeusenandClaude Opus 5.5 75008ad048 feat: test a database backup without touching the install
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 22s
CI and images / extension-test (push) Successful in 24s
CI and images / backend-lint-and-test (push) Failing after 47s
CI and images / integration (push) Failing after 2m42s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / build-agent (push) Skipped
"Test a backup" on the Backups card loads a dump into a separate, temporary
database on the same Postgres server, the same way a restore does, checks every
table against the row counts recorded with the dump, brings the copy up to this
version, and drops it. The live database is only read.

Every dump now records its row counts, read in the snapshot pg_dump dumps from
(pg_export_snapshot + --snapshot), so the comparison is exact. Off-site the
counts sit beside the dump as <name>.counts.json; locally they go in the
manifest file. A dump taken before this is compared with the live database
instead.

A test cut off by a restart is ended, and its database dropped, when the long
maintenance lane starts. A test doesn't block backups; a restore and a second
test wait for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:21:25 -05:00
bvandeusenandClaude Opus 5.5 66045794f0 feat: the Backups screen says whether backups are keeping up, and the System view warns when they aren't
CI and images / extension-version (push) Successful in 2s
CI and images / lint (push) Successful in 2s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 45s
CI and images / integration (push) Successful in 2m31s
CI and images / extension-test (push) Successful in 18s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m54s
CI and images / promote (push) Successful in 1s
CI and images / smoke-web (push) Successful in 48s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
- A Backups row on Settings → System and the nav dot turn amber when the
  newest database dump is more than a day old or failed, or the weekly
  bucket check found files missing that no media run has sent since.
  No row when backups aren't set up.
- The weekly bucket check is kept as a run (kind "reconcile") with its
  counts, trimmed to the last 12, and listed on the Backups card.
- The Backups card leads with the same verdict and the newest dump's size,
  shows bytes beside the running media job's file count, and lets each run
  open its list of failed files with why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:51:39 -05:00
bvandeusenandClaude Opus 5.5 9715143492 fix: a restore with pg_restore 17 into Postgres 16 isn't stopped by a setting the server lacks
CI and images / lint (push) Successful in 1s
CI and images / extension-version (push) Successful in 2s
CI and images / backend-lint-and-test (push) Successful in 48s
CI and images / frontend-build (push) Successful in 24s
CI and images / extension-test (push) Successful in 20s
CI and images / integration (push) Successful in 2m51s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 4s
CI and images / build-web (push) Successful in 1m52s
CI and images / smoke-web (push) Successful in 55s
CI and images / promote (push) Successful in 2s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
pg_restore 17 opens with `SET transaction_timeout = 0`, which Postgres 16 refuses.
Under --exit-on-error that one harmless statement ended every restore: the
image ships pg_restore 17.11 and the bundled server is pg16, so a restore
from the bucket (and the local restore) could never have loaded a dump. The
backup round trip, now that CI has pg_dump, found it.

pg_restore now runs to the end and its errors are judged afterwards: the
known harmless one is ignored, and any other fails the restore with that
error's words rather than the "Command was:" line that followed it.

Also sorts an import the lint lane flagged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 15:00:00 -05:00
bvandeusenandClaude Opus 5.5 1e63f70bf7 fix: a media backup or restore cut off by a restart carries on where it stopped
CI and images / lint (push) Failing after 1s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 19s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 46s
CI and images / integration (push) Failing after 3m8s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
A media backup and a restore's file phase run as chains of 45-minute chunks,
each queuing the next when it ends. A restart killed the chunk in flight, so
nothing queued the next one; the run's row stayed "running", "Back up now"
skipped it as already running, and only the 7-hour stall sweep ended it.

- When the long-maintenance lane starts, it queues the next chunk of every
  media run still running and every restore fetching its files.
- Each chunk writes its token to the run and hands it to the chunk it queues.
  A chunk queued by any other chunk is dropped (a killed chunk's message
  redelivered by acks_late, say), so a resume never doubles the chain; a chunk
  taken over stops at its next file.
- The integration job installs postgresql-client when the CI image lacks it:
  the backup round trip in 375e7e2 runs the real pg_dump and pg_restore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:53:57 -05:00
bvandeusenandClaude Opus 5.5 375e7e2eda feat: restore a new install from the off-site bucket, database first, then every file
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 1s
CI and images / extension-test (push) Successful in 17s
CI and images / integration (push) Failing after 2m26s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / frontend-build (push) Successful in 19s
CI and images / backend-lint-and-test (push) Successful in 45s
CI and images / unattended-failure (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / build-agent (push) Skipped
Settings → Maintenance → Backups gains "Restore from the bucket": pick one of
the bucket's dated dumps, type its confirmation, and FabledCurator saves the
current database to the bucket, replaces it with the dump, migrates it to this
version, then fetches every image and attachment back to the path its row
records. Each file is checked against its sha256 on arrival and moved into
place only then; a file already on disk is left alone, so "Fetch missing
files" repairs a library that lost files without touching the database.

- services/backup_restore.py: list/find/fetch dumps, migrate, keep this
  install's destination (the dump's sealed secret can't be opened here),
  library_files work list, restore_file (one fetch per sha, copies for
  duplicate paths, refuses paths outside /images).
- restore_offsite_task: the database phase, then 45-minute file chunks with a
  heartbeat; a failed load leaves an empty migrated database and says why.
  The dump's own run row is marked ok, other runs then going "Interrupted".
- backup_s3.get_file: sha/size check on arrival, ObjectMissing for a 404,
  download pacing.
- backup_service: every dump leaves the credential table's rows out;
  restore_db reports pg_restore's last line, stops at the first error, ends
  other connections and waits at most 60 s for locks.
- Backups and orphan repair wait while a restore runs; the stall sweep judges
  restores by heartbeat; restore runs are pruned to 20.
- CLI: python -m backend.app.scripts.restore list|database|files.
- README: backup and restore section; logins are in no backup.
- Tests: a round trip through an in-process S3 bucket (tests/fake_s3.py) with
  real pg_dump/pg_restore, plus file, run, API and get_file cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:34:47 -05:00
bvandeusenandClaude Opus 5.5 d465198c4a fix: the media backup task has its own stall threshold, and its test seeds a post for a shared archive
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 3s
CI and images / frontend-build (push) Successful in 27s
CI and images / extension-test (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 47s
CI and images / integration (push) Successful in 2m25s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 2m12s
CI and images / smoke-web (push) Successful in 50s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / promote (push) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:15:26 -05:00
bvandeusenandClaude Opus 5.5 0cf8edaa10 feat: images and attachments go to the off-site bucket once each, by fingerprint
CI and images / lint (push) Successful in 2s
CI and images / frontend-build (push) Successful in 25s
CI and images / extension-test (push) Successful in 18s
CI and images / backend-lint-and-test (push) Failing after 44s
CI and images / extension-version (push) Successful in 2s
CI and images / integration (push) Failing after 2m28s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
With an off-site destination set up, the library's files are sent to the
bucket's media/ folder as media/ab/cd/<sha256><ext>, beside the database
dumps under database/. Each file goes once: a new backup_blob table records
what each destination holds (migration 0121), and a run's work list is every
image and attachment sha with no row yet, so runs are incremental and a new
bucket starts its copy from the beginning.

- The bucket checks each file against the sha recorded at import; a file
  over 1 GiB goes in parts and is checked before the upload is completed.
  A file missing or changed on disk is reported, not stored.
- A run is chunked: each 45-minute chunk commits after every file and queues
  the next, so other long-maintenance jobs get turns. One run at a time.
  The stall sweep judges a media run by its progress heartbeat
  (backup_run.last_progress_at), not its start.
- A file that can't be sent is listed on the run (status ok, with the error
  line) and tried again by the next run. A refused put for an object
  already in an append-only bucket counts as sent.
- Nightly, with a destination set, runs the dump and then the media.
- Weekly reconcile lists media/ and re-sends what the bucket lost, and
  adopts what it already holds (a restored database sends nothing twice).
- Uploads use the bandwidth limit; read transactions end before transfers.
- Backups card: with a destination, an "Images and attachments" section
  shows coverage (files and bytes in the bucket), "Send new files now", and
  the media runs. Without one, the local tarball section is unchanged.
- Media run history keeps the latest 60 rows.

Task #6879, milestone 586.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:07:15 -05:00
bvandeusenandClaude Opus 5.5 772482d794 feat: with an off-site destination set up, database backups stream to it
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 1s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 41s
CI and images / integration (push) Successful in 2m25s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m50s
CI and images / smoke-web (push) Successful in 49s
CI and images / promote (push) Successful in 2s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
The nightly database backup (and "Run DB backup now") now sends pg_dump's
output straight to the off-site bucket as a multipart upload, with no copy
staged on this server. The object is named database/fabledcurator-<UTC
time>.dump and appears only once pg_dump has exited cleanly and every part
has landed; a failed dump aborts the upload and records pg_dump's last line.
Without a destination, dumps are written to /images/_backups as before.

- backup_s3: Upload (start / add part / complete / abort), with a busy or
  silent bucket rested and asked again (15 s, 60 s, Retry-After obeyed), and
  a Pacer that holds uploads to the new bandwidth limit.
- Settings: "Upload limit (Mbit/s)" on the destination card, 200 by default,
  0 for none (migration 0120).
- pg_dump runs under nice (and ionice where installed), locally too.
- Backups card says where dumps go; the runs table gains "Saved to" and
  shows a failed run's error. Off-site rows have no restore/tag/delete here:
  restoring from the bucket is a later step (#6880).

Task #6878, milestone 586.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:11:12 -05:00
bvandeusenandClaude Opus 5.5 bb10d30b2d fix: two copies of one picture imported at once are filed as duplicates, and an archive's row always ends
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 38s
CI and images / integration (push) Successful in 2m31s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 4s
CI and images / build-web (push) Successful in 1m56s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / smoke-web (push) Successful in 50s
CI and images / promote (push) Successful in 1s
The sha256 lookup before inserting an image row can lose a race to another
worker importing a second copy of the same picture. The unique index then
refused the insert. A media import retried behind the whole import queue, and
the recovery sweep ran it a second time. An archive swallowed the error, failed
on the dead transaction, and its row stayed 'processing' because the failure
mark could not commit either.

- Importer._store_new_record inserts under a savepoint; on a conflict the file
  is filed as a duplicate of the row that won (_file_as_duplicate, now shared
  by every duplicate branch). _import_media removes its stray library copy.
- _import_archive commits its post before extracting; the member loop rolls
  back a failed member and lets SoftTimeLimitExceeded through.
- _mark_failed rolls back before writing the failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:35:51 -05:00
bvandeusenandClaude Opus 5.5 32cac46b8b fix: the import recovery sweep no longer fails queued files that are waiting their turn
CI and images / smoke-web (push) Successful in 50s
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 22s
CI and images / frontend-build (push) Successful in 24s
CI and images / backend-lint-and-test (push) Successful in 41s
CI and images / integration (push) Successful in 2m40s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 2m1s
The orphan sweep in recover_interrupted_tasks failed every 'queued' row
older than 30 minutes. A queued row has its broker message, so in a batch
bigger than half an hour of work the sweep failed the whole tail: a
library import of 8,135 files lost 3,114 that way. Never-enqueued
'pending' rows are still swept after 30 minutes; a 'queued' row is swept
only when the broker's import queue is empty, and is kept when the queue
can't be read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:28:04 -05:00
bvandeusenandClaude Opus 5.5 5e3ea352e7 fix: the backup destination migration is 0119, after the existing 0118
CI and images / lint (push) Successful in 1s
CI and images / frontend-build (push) Successful in 21s
CI and images / extension-test (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 44s
CI and images / extension-version (push) Successful in 2s
CI and images / integration (push) Successful in 2m21s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 4s
CI and images / build-web (push) Successful in 2m8s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / smoke-web (push) Successful in 48s
CI and images / promote (push) Successful in 2s
Two migrations both claimed revision 0118 on top of 0117, so alembic saw two
heads and the upgrade refused to run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:15:09 -05:00
bvandeusenandClaude Opus 5.5 4477d7d14f feat: Settings can point backups at an off-site S3 bucket, with a Test destination check
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 27s
CI and images / backend-lint-and-test (push) Successful in 40s
CI and images / integration (push) Failing after 19s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
Settings → Maintenance → Storage gains "Off-site backup destination": the
address (with its port), bucket, region, access key and secret key of an S3
bucket such as Fabled Archives. The secret key is sealed with the credential
key, never returned by the API, and kept when the field is left blank. Test
destination lists the bucket with the fields as typed and writes nothing, and
names the cause when it fails ("nothing is listening there (check the port)",
"the secret key is wrong", "answered, but not as S3").

services/backup_s3 is a standard-library SigV4 client (put, get, paged list)
ported from Grimoire's; it is held to AWS's published signing examples and
exercised against a local HTTP server. Migration 0118 adds the columns; no
destination is set up until someone saves one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:04:57 -05:00
bvandeusenandClaude Opus 5.5 315399c4e5 fix: an unfinished download is never stored as an attachment, and the temp sweep keeps any file a library row points at
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 24s
CI and images / frontend-build (push) Successful in 26s
CI and images / backend-lint-and-test (push) Successful in 35s
CI and images / integration (push) Successful in 2m23s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m59s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
The importer refuses a staging file (.part, .partial, .megatmp.*) at both
entry points, the same way it refuses a sidecar .json, and
discard_after_attach never deletes one: the age-gated temp sweep owns them.
The sweep checks its stale candidates against image and attachment rows and
keeps any a row points at.

A Google Drive fetch's half-written file had been stored as <sha>.part; the
sweep then deleted it by name and left the row with no file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:48:14 -05:00
bvandeusenandClaude Opus 5.5 2fc71cfe4e feat: Cleanup can import files that are on disk but not in the library
CI and images / lint (push) Successful in 2s
CI and images / extension-test (push) Successful in 19s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 34s
CI and images / integration (push) Successful in 2m18s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m50s
CI and images / smoke-web (push) Successful in 49s
CI and images / promote (push) Successful in 2s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
A download writes its files into the artist's folder and imports them in a
later step. A run that stopped in between left complete files no record
points at, and a walk that had already marked them seen never returned for
them; on the live instance that is about 95 GB of art.

"Import files the library is missing" (Cleanup, Duplicates & leftovers)
walks every folder named for an artist, matched by slug so a differently
cased folder still matches, and lists the images, videos and archives no
image, attachment or queued import points at. Stores, sidecars, staging
files and files changed in the last day are left alone. Preview shows the
counts per artist; Import queues the same list as a library import batch,
whose tasks file the folder's post record, attach each file where it lies
with its folder's artist and source, and remove the ones the library
already holds or the import filters reject. A file whose import crashed is
kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:31:37 -05:00
bvandeusenandClaude Opus 5.5 1b25f598c5 fix: the external-link worker reads the staging-file check from utils, not tasks.maintenance
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / backend-lint-and-test (push) Successful in 36s
CI and images / frontend-build (push) Successful in 26s
CI and images / extension-test (push) Successful in 24s
CI and images / integration (push) Successful in 2m34s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m47s
CI and images / smoke-web (push) Successful in 48s
CI and images / promote (push) Successful in 2s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
Importing tasks.maintenance made every fetch path reach the membership
roster through its sync_memberships task, which test_gated_reason
forbids. is_temp_staging_file and its constants now live in
utils.staging, imported by both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:31:04 -05:00
bvandeusenandClaude Opus 5.5 2bea4b0d31 fix: a Mega folder link's files in subfolders are imported, and a retry no longer deletes complete files
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 20s
CI and images / extension-test (push) Successful in 18s
CI and images / backend-lint-and-test (push) Failing after 33s
CI and images / integration (push) Successful in 2m30s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
_fetch_mega listed only the top level of the link dir, so a folder link's
files in subfolders never reached the importer: the link either failed with
"wrote no new file" or read downloaded with its subfolders left on disk.
It now walks the tree.

The worker cleared every unrecorded file in a link dir before each fetch
and after a failed one. Combined with the daily recovery of dead links,
that deletes complete downloads whose upstream is gone, which may be the
only copy. Every fetcher stages to .part or .megatmp.* and renames on
completion, so the clear now removes staging files only, and a successful
fetch imports every complete unrecorded file in the link dir, including
ones an earlier attempt finished.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:24:14 -05:00
bvandeusenandClaude Opus 5.5 5259caa846 fix: dead external links are retried sparingly, not three times a day
CI and images / extension-test (push) Successful in 23s
CI and images / lint (push) Successful in 1s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 20s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m20s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-web (push) Successful in 2m1s
CI and images / smoke-web (push) Successful in 49s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / promote (push) Successful in 1s
The daily recovery reset every dead link to a fresh three-attempt budget,
so a link that can never succeed (an archive too big to import inside the
fetch's hard limit, a file made private) was downloaded up to three times a
day, every day.

Recovery now gives a dead link one more attempt once it has been dead for
2**recoveries days (1, 2, 4, 8, 16), and stops after five. A recovered link
that fails again goes straight back to dead. Retention prunes only links
recovery has given up on, 30 days after their last attempt. A manual refetch
of a post restores the full budgets.

Migration 0118 adds external_link.recoveries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:13:46 -05:00
bvandeusenandClaude Opus 5.5 157f2803c0 fix: downloads stop leaving files on disk that the library doesn't use
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 19s
CI and images / frontend-build (push) Successful in 28s
CI and images / integration (push) Successful in 2m25s
CI and images / lint (push) Successful in 1s
CI and images / backend-lint-and-test (push) Successful in 35s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 2m3s
CI and images / smoke-web (push) Successful in 55s
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
- An archive whose images were extracted was kept at its download path
  as well as in the attachments store. Both download paths now remove
  the downloaded copy.
- A downloaded file the import filters reject (too small, transparent,
  single colour, unreadable) was left on disk with no record. A later
  walk found it, imported it again and rejected it again, every time.
  Rejected files are now removed; the seen ledger keeps them from being
  downloaded again.
- Both paths share one policy, Importer.discard_after_attach, which
  never removes a file an ImageRecord points at.
- An external-link fetch that fails, or whose worker was killed, left
  partial files (megatools' .megatmp.*) in the link's dir, and a killed
  fetch left its link 'downloading' for good. A fetch now clears
  unrecorded leftovers before starting and after failing. The sweep
  returns a link to retry once it has been 'downloading' past the
  task's hard limit, using a new external_link.started_at column
  (migration 0117).
- The orphaned temp-file sweep now recognises .megatmp.* as well as
  .part and .partial.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:00:31 -05:00
bvandeusenandClaude Opus 5.5 47fb3f2193 ci: the weekly refresh also rebuilds :dev, and a failed unattended run opens an issue
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 24s
CI and images / backend-lint-and-test (push) Successful in 35s
CI and images / build-agent (push) Successful in 6s
CI and images / extension-test (push) Successful in 20s
CI and images / integration (push) Successful in 2m19s
CI and images / sign-extension (push) Successful in 8s
CI and images / build-web (push) Successful in 5s
CI and images / smoke-web (push) Successful in 35s
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
The Sunday refresh failed five weeks running (2026-10-04 to 11-01) and nothing
said so; it was found by reading the run list (#6864). It also only ever
refreshed :latest: an artifact whose dev source is quiet, the agent most weeks,
kept its last build's base layers on :dev, because reuse skips it on every push.

- refresh-dev: after promote, a refresh dispatches a forced dev run. That run is
  an ordinary dev build (lanes, smoke, promote), so :dev gets dev's own source
  with FC_CHANNEL=dev rather than main's bytes relabelled.
- A forced build now pulls fresh bases, as the refresh does; otherwise forcing
  rebuilds the same image.
- unattended-failure: any failed run that is not a push or PR opens an issue
  (or comments on the open one) naming the run and the failed jobs.
- RELEASE_TOKEN's scope list names write:repository, which the dispatch needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:26:50 -05:00
107 changed files with 9642 additions and 1586 deletions
+2 -3
View File
@@ -56,9 +56,8 @@ SECRET_KEY=
# LINE. Leaving it set disarms the protection permanently, on an instance that
# by then has credentials worth protecting.
#
# BACK UP ./images/secrets/ ALONGSIDE YOUR DATABASE. The key is the only thing
# that can read your stored credentials; a database restored without it needs
# every credential re-entered by hand.
# Backups leave the key and the stored logins out on purpose: after restoring
# onto a new install, sign in to each account again.
CURATOR_BOOTSTRAP_NEW_KEY=1
+130 -5
View File
@@ -202,7 +202,8 @@ env:
# Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes:
# - write:package, read:package (for docker push to git.fabledsword.com)
# - write:release (for ext-<version> release asset cache)
# - write:issue (for future issue-management automation)
# - write:issue (`unattended-failure` opens an issue)
# - write:repository (`refresh-dev` dispatches this workflow)
# The injected GITHUB_TOKEN cannot be used — it lacks write:package.
jobs:
@@ -578,6 +579,9 @@ jobs:
else
pip install -r requirements.txt pytest pytest-asyncio
fi
# The backup round trip runs the real pg_dump and pg_restore, from the
# same Debian client package the app image installs.
command -v pg_dump >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq postgresql-client; }
# Relax durability on the throwaway CI Postgres so the per-test
# TRUNCATE's commit-fsync — the integration teardown's dominant cost
# (~1.5-2s/test, which collapsed the suite from ~13min to ~45s) — is
@@ -1446,8 +1450,11 @@ jobs:
# `no-cache: true`, which is a much larger version of the same
# churn #3265 is about.
#
# Only on the schedule. An ordinary push wants the cached base.
pull: ${{ env.IS_REFRESH == 'true' }}
# Only on the refresh and on a forced build. An ordinary push wants
# the cached base. A forced build pulls because forcing is how the
# refresh rebuilds `:dev` (see `refresh-dev`), and a rebuild that
# keeps the old base layers would be the same image again.
pull: ${{ env.IS_REFRESH == 'true' || format('{0}', github.event.inputs.force_build) == 'true' }}
# ONE tag, the channel's. Every other tag is written by the step
# below, registry-side. buildx here pushes the first tag to the
# registry and then re-pushes the rest through the DOCKER driver,
@@ -2202,6 +2209,121 @@ jobs:
exit 1
fi
echo "promote: $MOVED tag(s) written"
# The refresh rebuilds `:dev` as well as `:latest`.
#
# The refresh run itself builds main and publishes `:latest`. `:dev` needs a
# build of dev's own source with FC_CHANNEL=dev baked in, so copying main's
# refreshed bytes onto `:dev` is not the same thing: the image would report
# itself as main, and the reuse check would then keep it that way. Instead
# this job dispatches an ordinary dev run with `force_build`. That run goes
# through the lanes, the smoke and `promote` like any push, and a forced
# build pulls fresh bases (see `pull:` in both build jobs).
#
# Without this, an artifact whose source is quiet on dev (the agent, most
# weeks) keeps the base layers of its last build indefinitely, because
# reuse skips its build on every push.
#
# The dispatched run shares this run's concurrency group (both are on the
# dev ref), so it queues until this run has finished. It runs whatever
# `promote` did: a failed `:latest` refresh is no reason to leave `:dev`
# stale, and `unattended-failure` reports the failure either way.
refresh-dev:
needs: [promote]
if: >-
!cancelled() && (github.event_name == 'schedule'
|| format('{0}', github.event.inputs.refresh) == 'true')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- name: Dispatch a forced dev build
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -eu
CODE=$(curl -sS --max-time 30 -o response.txt -w '%{http_code}' -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d '{"ref":"dev","inputs":{"force_build":"true"}}' \
"$API/actions/workflows/build.yml/dispatches")
echo "refresh-dev: dispatch answered HTTP $CODE"
case "$CODE" in
2??) echo "refresh-dev: a forced dev run is queued behind this one" ;;
*)
cat response.txt >&2
echo "refresh-dev: the dispatch was refused. A 403 means RELEASE_TOKEN" >&2
echo "refresh-dev: lacks write:repository (see the scope list at the top)." >&2
exit 1 ;;
esac
# A run nobody is watching reports its own failure.
#
# A push run is watched by whoever pushed it. The weekly refresh and the dev
# run it dispatches are not, and a red run on the Actions page alone goes
# unseen. So any failed run that is not a push or a pull request opens an
# issue, or comments on the one already open, naming the run and the jobs
# that failed. A manual dispatch that fails reports here too; closing the
# issue once it is dealt with is what keeps the next failure visible.
#
# The issue is written with RELEASE_TOKEN, so it is authored by the token's
# owner, and the forge does not notify people of their own actions. It
# shows in the repository's open issues rather than as an email.
unattended-failure:
needs: [lint, extension-version, backend-lint-and-test, frontend-build,
extension-test, integration, sign-extension, build-web, smoke-web,
build-agent, promote, refresh-dev]
if: >-
failure() && github.event_name != 'push'
&& github.event_name != 'pull_request'
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- name: Open or update the failure issue
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EVENT: ${{ github.event_name }}
REF: ${{ github.ref_name }}
NEEDS: ${{ toJSON(needs) }}
run: |
python3 - <<'EOF'
import json, os, urllib.parse, urllib.request
TITLE = "CI: an unattended build.yml run failed"
api, token = os.environ["API"], os.environ["TOKEN"]
def call(method, path, body=None):
req = urllib.request.Request(
api + path, method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": f"token {token}",
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)
needs = json.loads(os.environ["NEEDS"])
failed = sorted(j for j, v in needs.items() if v.get("result") == "failure")
text = (f"Run: {os.environ['RUN_URL']}\n"
f"Trigger: `{os.environ['EVENT']}` on `{os.environ['REF']}`\n"
f"Failed jobs: {', '.join(f'`{j}`' for j in failed) or '(none reported)'}\n")
q = urllib.parse.urlencode({"state": "open", "type": "issues", "q": TITLE})
existing = [i for i in call("GET", f"/issues?{q}") if i["title"] == TITLE]
if existing:
n = existing[0]["number"]
call("POST", f"/issues/{n}/comments", {"body": text})
print(f"unattended-failure: commented on open issue #{n}")
else:
body = ("A build.yml run that nobody watches failed. Read the run's "
"failing job logs; close this issue once it is fixed, so the "
"next failure opens a new one.\n\n" + text)
n = call("POST", "/issues", {"title": TITLE, "body": body})["number"]
print(f"unattended-failure: opened issue #{n}")
EOF
build-agent:
# THE GATE (2026-09-23). Every lane above must have PASSED before this job
# exists at all — so a red suite does not produce an image, let alone push
@@ -2540,8 +2662,11 @@ jobs:
# `no-cache: true`, which is a much larger version of the same
# churn #3265 is about.
#
# Only on the schedule. An ordinary push wants the cached base.
pull: ${{ env.IS_REFRESH == 'true' }}
# Only on the refresh and on a forced build. An ordinary push wants
# the cached base. A forced build pulls because forcing is how the
# refresh rebuilds `:dev` (see `refresh-dev`), and a rebuild that
# keeps the old base layers would be the same image again.
pull: ${{ env.IS_REFRESH == 'true' || format('{0}', github.event.inputs.force_build) == 'true' }}
# ONE tag, the channel's. Every other tag is written by the step
# below, registry-side. buildx here pushes the first tag to the
# registry and then re-pushes the rest through the DOCKER driver,
+50 -3
View File
@@ -114,9 +114,10 @@ that cannot decrypt anything, leaving an instance that looks healthy while every
paywalled download fails. Making you say so once, on an empty install, is the
price of that not happening silently later.
**Which means: back up `./images/secrets/` alongside your database.** It is the
only thing that can read your stored credentials. A database restored without it
needs every credential entered again by hand.
**Your account logins are sealed with that key, and no backup carries either.**
FabledCurator's backups (below) leave the key and the logins out on purpose, so
a stolen backup holds no way into your accounts. After a restore, sign in to
each account again through the browser extension.
A few other things are worth knowing about the first few minutes:
@@ -177,6 +178,52 @@ Run it for a burst of tagging, stop it to get your card back. It deploys from
`agent/docker-compose.yml`, not the main stack — see
[agent/README.md](agent/README.md).
## Backing up and restoring
Backups live under **Settings → Maintenance → Backups**. With nothing else set
up, the database is dumped nightly into `./images/_backups`, which is on the
same disk as what it protects. Images and attachments are backed up only to an
off-site destination.
To keep a copy off the machine, set up an off-site destination on the same
screen: any S3 bucket (Fabled Archives, MinIO, AWS) and a key that may add and
read objects. Each night FabledCurator then sends the database as a dated dump
under `database/`, and every image and attachment once, named by its
fingerprint, under `media/`. It never deletes from the bucket; the bucket's own
retention thins old dumps. The first copy of a large library takes hours; a
restart along the way carries on where it stopped rather than starting over.
Once a week the bucket is checked against what was sent, and anything missing
goes again in the next run. When the newest database backup is more than a day
old or failed, or the check found files missing, the **Backups** row on
**Settings → System** and the dot beside the brand turn amber and say why.
**Test a backup** proves a dump restores without touching your install: the dump
is loaded into a separate, temporary database on the same Postgres server,
every table is checked against the row counts recorded as the dump was taken,
the copy is brought up to this version, and then it is deleted. It needs about
as much free space on the database's disk as the database itself, while it
runs. A dump taken before counts were recorded is shown beside the live
database instead.
To rebuild after losing the server, start a fresh install (first boot as
above), enter the same destination, and under **Restore from the bucket** choose
a dump. The database is replaced, then every file is fetched back to its place
and checked against its fingerprint as it arrives; progress shows on the same
screen. **Fetch missing files** does only the second half, for a library that
lost files but kept its database.
If the web page isn't reachable, the same restore runs from a shell:
```bash
docker compose exec web python -m backend.app.scripts.restore list
docker compose exec web python -m backend.app.scripts.restore database --dump latest --yes
```
On an install with no destination saved yet, add `--endpoint`, `--bucket` and
`--access-key`, and put the secret key in `FC_BACKUP_SECRET_KEY` (or type it at
the prompt).
## Upgrading
```bash
@@ -0,0 +1,33 @@
"""external_link.started_at: when a fetch claimed the link.
A worker killed mid-fetch (the Celery hard limit, an OOM, a restart) never
reaches the code that moves its link out of 'downloading'. The external-link
sweep returns such a link to the retry path once it has been 'downloading'
longer than the task's hard limit, which needs the claim time.
A row in 'downloading' with no claim time is treated as stale: no worker
survives the deploy that adds this column.
Revision ID: 0117
Revises: 0116
Create Date: 2026-11-05
"""
import sqlalchemy as sa
from alembic import op
revision = "0117"
down_revision = "0116"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"external_link",
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("external_link", "started_at")
@@ -0,0 +1,32 @@
"""external_link.recoveries: how many times recovery has retried a dead link.
The daily recovery gives a dead link one more attempt, spaced further apart
each time, and leaves it dead once the count reaches its limit. A link that
can never succeed is then downloaded a handful of times in total, not every
day.
Revision ID: 0118
Revises: 0117
Create Date: 2026-11-05
"""
import sqlalchemy as sa
from alembic import op
revision = "0118"
down_revision = "0117"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"external_link",
sa.Column(
"recoveries", sa.Integer(), nullable=False, server_default=sa.text("0")
),
)
def downgrade() -> None:
op.drop_column("external_link", "recoveries")
@@ -0,0 +1,43 @@
"""import_settings: the off-site backup destination (an S3 bucket).
Address, bucket, access key and region are plain settings; the secret key is
stored sealed with the credential key. All empty means no destination is set
up, which is every existing install's state.
Revision ID: 0119
Revises: 0118
Create Date: 2026-11-06
"""
import sqlalchemy as sa
from alembic import op
revision = "0119"
down_revision = "0118"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("import_settings", sa.Column("backup_s3_endpoint", sa.Text(), nullable=True))
op.add_column(
"import_settings", sa.Column("backup_s3_bucket", sa.String(255), nullable=True),
)
op.add_column("import_settings", sa.Column("backup_s3_access_key", sa.Text(), nullable=True))
op.add_column(
"import_settings", sa.Column("backup_s3_secret_sealed", sa.Text(), nullable=True),
)
op.add_column(
"import_settings",
sa.Column(
"backup_s3_region", sa.String(64), nullable=False, server_default="us-east-1",
),
)
def downgrade() -> None:
for column in (
"backup_s3_region", "backup_s3_secret_sealed", "backup_s3_access_key",
"backup_s3_bucket", "backup_s3_endpoint",
):
op.drop_column("import_settings", column)
@@ -0,0 +1,30 @@
"""import_settings: a bandwidth limit for uploads to the off-site backup bucket.
In megabits per second; 0 means no limit. Every install starts at 200 Mbit/s,
which leaves room on a gigabit link for everything else the server does.
Revision ID: 0120
Revises: 0119
Create Date: 2026-11-07
"""
import sqlalchemy as sa
from alembic import op
revision = "0120"
down_revision = "0119"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"import_settings",
sa.Column(
"backup_upload_limit_mbps", sa.Integer(), nullable=False, server_default="200",
),
)
def downgrade() -> None:
op.drop_column("import_settings", "backup_upload_limit_mbps")
+46
View File
@@ -0,0 +1,46 @@
"""backup_blob: which library files are already in the off-site bucket.
One row per (destination, sha256) sent. The media backup's work list is every
image and attachment sha with no row for the current destination, so a run is
incremental and resumable by construction, and pointing backups at a new
bucket starts that bucket's copy from the beginning.
backup_run.last_progress_at is the heartbeat of a run that spans hours; the
stall sweep measures a media run's silence from it, not from its start.
Revision ID: 0121
Revises: 0120
Create Date: 2026-11-07
"""
import sqlalchemy as sa
from alembic import op
revision = "0121"
down_revision = "0120"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"backup_blob",
sa.Column("target", sa.Text(), nullable=False),
sa.Column("sha256", sa.String(64), nullable=False),
sa.Column("key", sa.Text(), nullable=False),
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
sa.Column(
"uploaded_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("target", "sha256", name=op.f("pk_backup_blob")),
)
op.add_column(
"backup_run",
sa.Column("last_progress_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("backup_run", "last_progress_at")
op.drop_table("backup_blob")
@@ -0,0 +1,30 @@
"""Retire the images tarball backup.
Images and attachments are backed up off-site, one object per file
(backup_blob). The tarball of /images that this replaces is gone, with its
retention setting, its path column, and any runs it left.
Revision ID: 0122
Revises: 0121
Create Date: 2026-11-08
"""
import sqlalchemy as sa
from alembic import op
revision = "0122"
down_revision = "0121"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("DELETE FROM backup_run WHERE kind = 'images'")
op.drop_column("backup_run", "tar_path")
op.drop_column("import_settings", "backup_images_keep_last_n")
def downgrade() -> None:
op.add_column("import_settings", sa.Column(
"backup_images_keep_last_n", sa.Integer(), nullable=False, server_default="3"))
op.add_column("backup_run", sa.Column("tar_path", sa.Text(), nullable=True))
+15
View File
@@ -475,6 +475,21 @@ async def trigger_reclaim_attachments():
return _queued(async_result)
@admin_bp.route("/maintenance/import-unimported", methods=["POST"])
async def trigger_import_unimported():
"""Import files in the artists' folders that the library has no record of.
Body {"dry_run": bool}; dry_run (the DEFAULT) lists what was found, per
artist, and changes nothing. dry_run=false queues them as an import batch
that attaches each file where it lies. Returns the Celery task id — poll
/maintenance/task-result/<id> for the summary."""
from ..tasks.admin import import_unimported_files_task
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", True))
async_result = import_unimported_files_task.delay(dry_run=dry_run)
return _queued(async_result)
@admin_bp.route("/maintenance/repair-discord-downloads", methods=["POST"])
async def trigger_repair_discord_downloads():
"""Clean re-download of the Discord files broken by the `None` naming
+315 -28
View File
@@ -1,5 +1,5 @@
"""FC-3h: /api/system/backup — create/list/restore/delete/tag for
DB + image backups.
"""/api/system/backup: database dumps, the off-site destination and its media
backup, restores, test restores, and the run history.
Read endpoints are public on FC (operator-facing internal API; same
posture as /api/system/activity). Write endpoints take a typed
@@ -20,13 +20,12 @@ system_backup_bp = Blueprint(
"system_backup", __name__, url_prefix="/api/system/backup",
)
_KINDS = frozenset({"db", "images"})
_KINDS = frozenset({"db", "media", "restore", "reconcile", "test"})
_TAG_MAX_LEN = 64
_BACKUP_SETTINGS_FIELDS = (
"backup_db_nightly_enabled",
"backup_db_nightly_hour_utc",
"backup_db_keep_last_n",
"backup_images_keep_last_n",
)
@@ -44,11 +43,13 @@ def _row_to_dict(r: BackupRun) -> dict:
if r.finished_at and r.started_at else None
),
"sql_path": r.sql_path,
"tar_path": r.tar_path,
"size_bytes": r.size_bytes,
"error": r.error,
"restored_from_id": r.restored_from_id,
"manifest": r.manifest or {},
# Where an off-site dump went ({endpoint, bucket, key}); None for a
# backup kept on this server.
"offsite": (r.manifest or {}).get("offsite"),
}
@@ -78,10 +79,6 @@ def _validate_backup_settings_patch(body: dict):
v = body["backup_db_keep_last_n"]
if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 365):
return _bad("invalid_value", detail="backup_db_keep_last_n must be 1..365")
if "backup_images_keep_last_n" in body:
v = body["backup_images_keep_last_n"]
if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 100):
return _bad("invalid_value", detail="backup_images_keep_last_n must be 1..100")
return None
@@ -96,14 +93,173 @@ async def trigger_db_backup():
return jsonify({"status": "dispatched"}), 202
@system_backup_bp.route("/images", methods=["POST"])
async def trigger_images_backup():
@system_backup_bp.route("/media", methods=["POST"])
async def trigger_media_backup():
"""Send the images and attachments not yet in the off-site bucket. Refused
when no destination is set up; a run already going is left to finish."""
from ..services import backup_destination as dest
async with get_session() as session:
row = await ImportSettings.load(session)
if not dest.describe(row)["configured"]:
return _bad("no_destination",
detail="set up an off-site destination before sending files")
from ..tasks.backup import backup_media_task
backup_media_task.delay(triggered_by="manual")
return jsonify({"status": "dispatched"}), 202
@system_backup_bp.route("/media", methods=["GET"])
async def media_coverage():
"""How much of the library is in the off-site bucket: files and bytes,
sent and total, each file counted once however many posts hold it."""
from ..services import backup_destination as dest
from ..services import backup_media
async with get_session() as session:
row = await ImportSettings.load(session)
f = dest.fields_of(row)
configured = dest.describe(row)["configured"]
tid = backup_media.destination_id(f.endpoint, f.bucket) if configured else None
counts = await session.run_sync(lambda s: backup_media.coverage(s, tid))
return jsonify({"configured": configured, **counts})
# ── Restoring from the off-site bucket ──────────────────────────────────────
async def _saved_target():
"""(target, None) for the saved destination, or (None, error response)."""
from ..services import backup_destination as dest
from ..services.backup_s3 import S3Error
async with get_session() as session:
row = await ImportSettings.load(session)
try:
target = dest.saved_target(row)
except S3Error as exc:
return None, _bad("destination_unreadable", detail=str(exc))
if target is None:
return None, _bad("no_destination",
detail="set up an off-site destination before restoring from it")
return target, None
async def _busy_reason() -> str | None:
from ..tasks.backup import backup_busy
async with get_session() as session:
return await session.run_sync(backup_busy)
def restore_token(ts: str) -> str:
"""What the person types to confirm replacing the database with the dump
taken at `ts`."""
return f"restore-offsite-{ts}"
@system_backup_bp.route("/offsite/dumps", methods=["GET"])
async def list_offsite_dumps():
"""The database dumps in the off-site bucket, newest first, each with
the confirmation a restore of it asks for."""
import asyncio
from ..services import backup_restore
from ..services.backup_s3 import S3Error
target, err = await _saved_target()
if err:
return err
try:
dumps = await asyncio.to_thread(backup_restore.list_dumps, target)
except S3Error as exc:
return _bad("destination_unreachable", detail=str(exc), status=502)
return jsonify({
"dumps": [
{"key": d.key, "ts": d.ts, "size_bytes": d.size_bytes, "counted": d.counted,
"confirm": restore_token(d.ts)}
for d in dumps
],
# A test restore needs about this much free space on the database's
# disk while it runs.
"database_bytes": await _database_bytes(),
})
async def _database_bytes() -> int:
from sqlalchemy import text
async with get_session() as session:
return (await session.execute(
text("SELECT pg_database_size(current_database())"))).scalar_one()
async def _restore_test_blocked() -> str | None:
from ..tasks.backup import restore_test_blocked
async with get_session() as session:
return await session.run_sync(restore_test_blocked)
@system_backup_bp.route("/offsite/test", methods=["POST"])
async def restore_test_offsite():
"""Test a dump from the bucket without touching this install: it is
loaded into a temporary database, checked table by table, and dropped.
Body: {key}, a dump's key from /offsite/dumps, or {} for the newest.
Needs no confirmation; refused while a restore or another test is going."""
body = await request.get_json(silent=True) or {}
tag = _validate_tag(body.get("tag"))
if isinstance(tag, tuple):
return tag
from ..tasks.backup import backup_images_task
backup_images_task.delay(tag=tag, triggered_by="manual")
key = body.get("key")
if key is not None and (not isinstance(key, str) or not key.startswith("database/")):
return _bad("invalid_key", detail="key must be a dump's key from /offsite/dumps")
_, err = await _saved_target()
if err:
return err
busy = await _restore_test_blocked()
if busy:
return _bad("busy", detail=f"{busy}; try again when it has finished", status=409)
from ..tasks.backup import restore_test_task
restore_test_task.delay(dump_key=key)
return jsonify({"status": "dispatched"}), 202
@system_backup_bp.route("/offsite/restore", methods=["POST"])
async def restore_from_offsite():
"""Replace this install's database with a dump from the bucket, then
fetch the library's files back. Body: {key, confirm}, where confirm is
the dump's token from /offsite/dumps. Refused while a backup or another
restore is going."""
body = await request.get_json(silent=True) or {}
key = body.get("key")
if not isinstance(key, str) or not key.startswith("database/"):
return _bad("invalid_key", detail="key must be a dump's key from /offsite/dumps")
ts = key.removeprefix("database/fabledcurator-").removesuffix(".dump")
expected = restore_token(ts)
if body.get("confirm") != expected:
return _bad("confirm_mismatch", detail=f"confirm must equal {expected!r}",
expected=expected)
_, err = await _saved_target()
if err:
return err
busy = await _busy_reason()
if busy:
return _bad("busy", detail=f"{busy}; try again when it has finished", status=409)
from ..tasks.backup import restore_offsite_task
restore_offsite_task.delay(dump_key=key)
return jsonify({"status": "dispatched"}), 202
@system_backup_bp.route("/offsite/fetch-files", methods=["POST"])
async def fetch_missing_files():
"""Fetch every library file that is missing from disk back from the
bucket, leaving the database as it is."""
_, err = await _saved_target()
if err:
return err
busy = await _busy_reason()
if busy:
return _bad("busy", detail=f"{busy}; try again when it has finished", status=409)
from ..tasks.backup import restore_offsite_task
restore_offsite_task.delay(files_only=True)
return jsonify({"status": "dispatched"}), 202
@@ -175,10 +331,16 @@ async def trigger_restore(run_id: int):
row = await session.get(BackupRun, run_id)
if row is None:
return _bad("not_found", status=404)
if row.status != "ok":
if (row.manifest or {}).get("offsite"):
return _bad(
"not_restorable",
detail=f"source backup status={row.status!r}; only 'ok' rows are restorable",
detail=("this dump is kept in the off-site bucket, not on this server, "
"so it can't be restored from here"),
)
if row.kind != "db" or row.status != "ok" or not row.sql_path:
return _bad(
"not_restorable",
detail="only a finished database dump kept on this server can be restored here",
)
expected = f"restore-{row.kind}-{row.id}"
if supplied != expected:
@@ -187,15 +349,29 @@ async def trigger_restore(run_id: int):
detail=f"confirm must equal {expected!r}",
expected=expected,
)
kind = row.kind
if kind == "db":
from ..tasks.backup import restore_db_task
restore_db_task.delay(source_backup_run_id=run_id)
else: # 'images' (the only other value _KINDS allows via the trigger path)
from ..tasks.backup import restore_images_task
restore_images_task.delay(source_backup_run_id=run_id)
return jsonify({"status": "dispatched", "kind": kind}), 202
from ..tasks.backup import restore_db_task
restore_db_task.delay(source_backup_run_id=run_id)
return jsonify({"status": "dispatched", "kind": "db"}), 202
@system_backup_bp.route("/runs/<int:run_id>/test", methods=["POST"])
async def restore_test_local(run_id: int):
"""Test a database dump kept on this server, the same way as one in the
bucket. Needs no confirmation."""
async with get_session() as session:
row = await session.get(BackupRun, run_id)
if row is None:
return _bad("not_found", status=404)
if row.kind != "db" or row.status != "ok" or not row.sql_path:
return _bad("not_testable",
detail="only a finished database dump on this server can be tested here")
busy = await _restore_test_blocked()
if busy:
return _bad("busy", detail=f"{busy}; try again when it has finished", status=409)
from ..tasks.backup import restore_test_task
restore_test_task.delay(source_backup_run_id=run_id)
return jsonify({"status": "dispatched"}), 202
@system_backup_bp.route("/runs/<int:run_id>", methods=["DELETE"])
@@ -216,7 +392,7 @@ async def delete_run(run_id: int):
)
from ..services import backup_service
backup_service.unlink_artifact_files(
sql_path=row.sql_path, tar_path=row.tar_path,
sql_path=row.sql_path,
manifest_path=(row.manifest or {}).get("manifest_path"),
)
await session.delete(row)
@@ -232,10 +408,121 @@ async def get_settings():
"backup_db_nightly_enabled": row.backup_db_nightly_enabled,
"backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc,
"backup_db_keep_last_n": row.backup_db_keep_last_n,
"backup_images_keep_last_n": row.backup_images_keep_last_n,
})
@system_backup_bp.route("/destination", methods=["GET"])
async def get_destination():
"""The off-site destination as the settings screen shows it. The secret
key is never returned, only whether one is saved."""
from ..services import backup_destination as dest
async with get_session() as session:
row = await ImportSettings.load(session)
return jsonify(dest.describe(row))
@system_backup_bp.route("/destination", methods=["PATCH"])
async def patch_destination():
"""Save the destination. Body {endpoint, bucket, access_key, region,
secret_key, upload_limit_mbps}; an empty field clears it, and a missing
upload_limit_mbps keeps the saved limit. A missing or empty secret_key
keeps the saved one, so the screen can save the other fields without the
secret being typed again."""
from ..services import backup_destination as dest
body = await request.get_json(silent=True)
if not isinstance(body, dict):
return _bad("invalid_body", detail="body must be a JSON object")
fields, why = dest.clean_fields(body)
if fields is None:
return _bad("invalid_destination", detail=why)
secret = body.get("secret_key")
if secret is not None and not isinstance(secret, str):
return _bad("invalid_destination", detail="secret_key must be text")
limit = body.get("upload_limit_mbps")
if limit is not None and (
not isinstance(limit, int) or isinstance(limit, bool) or not 0 <= limit <= 100_000
):
return _bad("invalid_destination",
detail="upload_limit_mbps must be a whole number from 0 (no limit) up")
async with get_session() as session:
row = await ImportSettings.load(session)
if limit is not None:
row.backup_upload_limit_mbps = limit
row.backup_s3_endpoint = fields.endpoint
row.backup_s3_bucket = fields.bucket
row.backup_s3_access_key = fields.access_key
row.backup_s3_region = fields.region
if secret and secret.strip():
row.backup_s3_secret_sealed = dest.seal(secret.strip())
await session.commit()
return jsonify(dest.describe(row))
@system_backup_bp.route("/destination", methods=["DELETE"])
async def delete_destination():
"""Forget the destination, secret included. What was already sent stays in
the bucket: FabledCurator never deletes there."""
from ..services import backup_destination as dest
async with get_session() as session:
row = await ImportSettings.load(session)
row.backup_s3_endpoint = None
row.backup_s3_bucket = None
row.backup_s3_access_key = None
row.backup_s3_secret_sealed = None
row.backup_s3_region = dest.DEFAULT_REGION
await session.commit()
return jsonify(dest.describe(row))
@system_backup_bp.route("/destination/test", methods=["POST"])
async def try_destination():
"""Reach the bucket with the fields in the body (the form as typed, saved
or not); a missing secret_key uses the saved one. Lists the bucket and
writes nothing. Answers {ok, message} either way: a destination that
can't be reached is a result to show, not an API error."""
import asyncio
from ..services import backup_destination as dest
from ..services.backup_s3 import S3Error
from ..services.credential_crypto import InvalidCredentialBlob
body = await request.get_json(silent=True) or {}
if not isinstance(body, dict):
return _bad("invalid_body", detail="body must be a JSON object")
fields, why = dest.clean_fields(body)
if fields is None:
return jsonify({"ok": False, "message": f"Not tested: {why}."})
secret = body.get("secret_key")
secret = secret.strip() if isinstance(secret, str) else ""
if not secret:
async with get_session() as session:
row = await ImportSettings.load(session)
sealed = row.backup_s3_secret_sealed
if sealed:
try:
secret = dest.unseal(sealed)
except InvalidCredentialBlob:
return jsonify({"ok": False, "message": (
"Not tested: the saved secret key can't be opened on this server. "
"Enter the secret key again."
)})
target = dest.target_for(fields, secret or None)
if target is None:
missing = [name for name, v in (
("address", fields.endpoint), ("bucket", fields.bucket),
("access key", fields.access_key), ("secret key", secret),
) if not v]
return jsonify({"ok": False, "message": f"Not tested: fill in the {', '.join(missing)}."})
try:
return jsonify(await asyncio.to_thread(dest.check_target, target))
except S3Error as exc:
return jsonify({"ok": False, "message": str(exc)})
@system_backup_bp.route("/settings", methods=["PATCH"])
async def patch_settings():
body = await request.get_json(silent=True)
+12
View File
@@ -35,6 +35,7 @@ from sqlalchemy import select, text
from ..config import get_config
from ..extensions import get_session
from ..models import ServiceSeen
from ..services import backup_health
from ..services.worker_lanes import SWEEP_PERIOD_SECONDS
system_health_bp = Blueprint("system_health", __name__, url_prefix="/api/system")
@@ -227,6 +228,17 @@ async def system_health():
**{k: v for k, v in (row.details or {}).items() if k != "agent_id"},
})
# Whether the backups are keeping up. Read like the rest, and a
# failure to read it is reported as unknown rather than raised.
try:
backup = await session.run_sync(lambda s: backup_health.backup_part(s, now))
except Exception as exc: # noqa: BLE001 — a probe reports, it never raises
backup = {"key": "backup", "kind": "backup", "name": "Backups",
"state": _UNKNOWN,
"detail": f"could not be checked: {type(exc).__name__}"}
if backup is not None:
parts.append(backup)
parts.append(await _probe_redis())
overall = max((p["state"] for p in parts), key=lambda s: _SEVERITY[s], default=_UNKNOWN)
+5 -1
View File
@@ -4,7 +4,7 @@ Queues:
import — filesystem import path (FC-2)
ml — WD14 + SigLIP inference (FC-2; runs in the ml-worker image)
thumbnail — image and video thumbnail generation (FC-2)
download — gallery-dl tasks (FC-3)
download — source walks: the native ingesters and gallery-dl (FC-3)
scan — periodic source checks (FC-3) — kept separate so long imports
don't starve the scheduler
maintenance — recovery sweeps, pHash backfill, GPU-queue coordination, etc.
@@ -302,6 +302,10 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.backup.prune_backups",
"schedule": 86400.0, # daily
},
"reconcile-media-backup": {
"task": "backend.app.tasks.backup.reconcile_media_backup",
"schedule": 604800.0, # weekly: list the bucket, re-send what's missing
},
# Audit 2026-06-02 — three new per-entity recovery sweeps.
# Each runs every 5 min like the other recover_stalled_*
# sweeps; each is a no-op when nothing is stuck.
+27 -6
View File
@@ -232,17 +232,38 @@ def _consumed_queues(consumer) -> set[str]:
@worker_ready.connect
def _on_worker_ready(sender=None, **_):
"""The download lane clears what its previous process left behind (#4433).
"""A lane picks up what its previous process left behind. Only the lane
that runs the work does it; the other lanes booting beside it must not,
or a lane restarting on its own would disturb work still running.
Best-effort: a failure is logged and the worker still starts."""
queues = _consumed_queues(sender)
if "download" in queues:
_clear_download_leftovers()
if "maintenance_long" in queues:
_resume_offsite_runs()
def _resume_offsite_runs() -> None:
"""The long-maintenance lane carries on an off-site backup or restore that
a restart cut off mid-chunk. With nothing queuing its next chunk, the run
would read "running" for hours, and block every new run, until the stall
sweep ended it."""
try:
from .tasks.backup import resume_offsite_runs
resume_offsite_runs()
except Exception: # noqa: BLE001 — never block the worker from starting
log.exception("long-maintenance lane boot: resuming off-site runs failed")
def _clear_download_leftovers() -> None:
"""The download lane clears what its previous process left behind.
A restart SIGKILLs any walk that outlives the stop grace, so its event is
never finalized and its platform lock is never released. Both used to wait
out timers — a 30-min stall sweep that then blamed the source, and a 27-min
lock TTL that stalled every other source on the platform. Only the process
consuming `download` does this; the other lanes booting beside it must not.
Best-effort: a failure here is logged and the worker still starts.
lock TTL that stalled every other source on the platform.
"""
if "download" not in _consumed_queues(sender):
return
booted_at = datetime.now(UTC)
try:
from .services.download_recovery import interrupt_orphaned_download_events
+2
View File
@@ -4,6 +4,7 @@ from .app_setting import AppSetting
from .artist import Artist
from .artist_membership_suggestion import ArtistMembershipSuggestion
from .artist_visit import ArtistVisit
from .backup_blob import BackupBlob
from .backup_run import BackupRun
from .base import Base
from .character_prototype import CcipPrototypeState, CharacterPrototype
@@ -55,6 +56,7 @@ __all__ = [
"Artist",
"ArtistMembershipSuggestion",
"ArtistVisit",
"BackupBlob",
"BackupRun",
"Source",
"Credential",
+26
View File
@@ -0,0 +1,26 @@
"""backup_blob: a library file already sent to the off-site bucket.
One row per (destination, sha256). `target` names the destination as
"<endpoint>/<bucket>", so pointing backups at another bucket leaves these rows
inert and that bucket's copy starts from the beginning. `key` is where the
file sits in the bucket (`media/ab/cd/<sha256><ext>`).
"""
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class BackupBlob(Base):
__tablename__ = "backup_blob"
target: Mapped[str] = mapped_column(Text, primary_key=True)
sha256: Mapped[str] = mapped_column(String(64), primary_key=True)
key: Mapped[str] = mapped_column(Text, nullable=False)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
uploaded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
+8 -2
View File
@@ -1,6 +1,8 @@
"""FC-3h: backup_run — operator-facing artifact record for a backup run.
One row per backup attempt (kind='db' or 'images'). Lifecycle
One row per run: kind 'db' (a database dump), 'media' (the off-site upload of
images and attachments, which spans many task chunks), 'restore', 'reconcile'
(the weekly bucket check) or 'test' (a test restore). Lifecycle
tracking (started_at/finished_at/duration_ms/exception text) lives
in task_run from FC-3i — this row records artifact metadata: file
paths, sizes, tag (retention protection), and restore lineage via
@@ -54,7 +56,6 @@ class BackupRun(Base):
DateTime(timezone=True), nullable=True, index=True,
)
sql_path: Mapped[str | None] = mapped_column(Text, nullable=True)
tar_path: Mapped[str | None] = mapped_column(Text, nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
manifest: Mapped[dict] = mapped_column(
@@ -62,6 +63,11 @@ class BackupRun(Base):
)
# Self-referential FK, unindexed until 0089 (#3300): SET NULL has to find
# the rows pointing at a deleted run before it can null them.
# Heartbeat of a run that spans hours (the media backup): set as it makes
# progress, and what the stall sweep measures its silence from.
last_progress_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
restored_from_id: Mapped[int | None] = mapped_column(
ForeignKey("backup_run.id", ondelete="SET NULL"),
nullable=True, index=True,
+11
View File
@@ -80,12 +80,23 @@ class ExternalLink(Base):
Integer, nullable=False, server_default=text("0")
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
# Times the daily recovery has given this link another attempt after it
# went dead. Recovery stops at a limit, so a link that can never succeed
# isn't downloaded forever.
recoveries: Mapped[int] = mapped_column(
Integer, nullable=False, server_default=text("0")
)
attachment_id: Mapped[int | None] = mapped_column(
ForeignKey("post_attachment.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# When the current fetch claimed the link ('downloading'). The sweep uses it
# to recover a link whose worker died mid-fetch.
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
+14 -3
View File
@@ -9,6 +9,7 @@ from sqlalchemy import (
CheckConstraint,
Float,
Integer,
String,
Text,
select,
text,
@@ -96,9 +97,19 @@ class ImportSettings(Base):
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",
# Off-site backup destination: an S3 bucket (services/backup_destination).
# The secret key is stored sealed with the credential key, never in clear.
backup_s3_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
backup_s3_bucket: Mapped[str | None] = mapped_column(String(255), nullable=True)
backup_s3_access_key: Mapped[str | None] = mapped_column(Text, nullable=True)
backup_s3_secret_sealed: Mapped[str | None] = mapped_column(Text, nullable=True)
backup_s3_region: Mapped[str] = mapped_column(
String(64), nullable=False, default="us-east-1",
server_default="us-east-1",
)
# Upload bandwidth to that bucket, in megabits per second; 0 = no limit.
backup_upload_limit_mbps: Mapped[int] = mapped_column(
Integer, nullable=False, default=200, server_default="200",
)
# FC-6.3 series continuation matcher. enabled gates the rescan; threshold is
+119
View File
@@ -0,0 +1,119 @@
"""Restore FabledCurator from its off-site bucket, from a shell in the
container. The Backup screen does the same; this is for an install whose page
you can't reach yet, or for scripting a rebuild.
python -m backend.app.scripts.restore list
python -m backend.app.scripts.restore database --dump latest --yes
python -m backend.app.scripts.restore files
On a brand-new install with no destination saved, name it on the command
line: --endpoint, --bucket and --access-key, with the secret key in the
FC_BACKUP_SECRET_KEY environment variable (or typed at the prompt). It is
checked by listing the bucket, then saved as this install's destination,
sealed, exactly as the settings page saves it.
`database` replaces the database with the chosen dump here, then hands the
files to the worker; their progress shows on the Backup screen. `files`
fetches only what is missing from disk and leaves the database alone.
"""
from __future__ import annotations
import argparse
import getpass
import os
import sys
from ..models import ImportSettings
from ..services import backup_destination, backup_restore
from ..services.backup_s3 import S3Error
from ..tasks._sync_engine import sync_session_factory
def _human(n: int) -> str:
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1000 or unit == "TB":
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
n /= 1000
return str(n)
def _destination(args) -> object:
"""The target to restore from: the one named on the command line, saved
first, or the one already saved."""
session_factory = sync_session_factory()
if args.endpoint or args.bucket or args.access_key:
fields, why = backup_destination.clean_fields({
"endpoint": args.endpoint, "bucket": args.bucket,
"access_key": args.access_key, "region": args.region,
})
if fields is None:
raise SystemExit(f"Destination not usable: {why}.")
secret = os.environ.get("FC_BACKUP_SECRET_KEY") or getpass.getpass("Secret key: ")
target = backup_destination.target_for(fields, secret.strip() or None)
if target is None:
raise SystemExit("Name the address, bucket, access key and secret key.")
check = backup_destination.check_target(target)
print(check["message"])
if not check["ok"]:
raise SystemExit(1)
with session_factory() as session:
settings = ImportSettings.load_sync(session)
limit = settings.backup_upload_limit_mbps
backup_restore.keep_destination(session, target, limit)
print("Saved as this install's off-site destination.")
return target
with session_factory() as session:
target = backup_destination.saved_target(ImportSettings.load_sync(session))
if target is None:
raise SystemExit("No off-site destination is saved; name one with "
"--endpoint, --bucket and --access-key.")
return target
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="python -m backend.app.scripts.restore",
description=__doc__.split("\n\n")[0])
parser.add_argument("action", choices=("list", "database", "files"))
parser.add_argument("--dump", default="latest",
help="the dump's key from `list`, or latest (the default)")
parser.add_argument("--yes", action="store_true",
help="confirm replacing this install's database")
parser.add_argument("--endpoint")
parser.add_argument("--bucket")
parser.add_argument("--access-key")
parser.add_argument("--region", default=None)
args = parser.parse_args(argv)
try:
target = _destination(args)
if args.action == "list":
for d in backup_restore.list_dumps(target):
print(f"{d.key} {_human(d.size_bytes)}")
return 0
from ..tasks.backup import restore_offsite_task
if args.action == "files":
restore_offsite_task.delay(files_only=True)
print("Queued: the worker fetches every missing file; watch the Backup screen.")
return 0
dump = backup_restore.find_dump(target, args.dump)
if not args.yes:
print(f"This replaces this install's database with {dump.key} "
f"({_human(dump.size_bytes)}). Run again with --yes to go ahead.")
return 2
print(f"Restoring {dump.key}; this takes a while for a large library…")
result = restore_offsite_task(dump_key=dump.key)
if result.get("skipped"):
print(f"Not started: {result['skipped']}.")
return 1
print("The database is restored. The worker is fetching the library's files; "
"watch the Backup screen.")
return 0
except S3Error as exc:
print(f"Couldn't restore: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+44
View File
@@ -57,6 +57,50 @@ def is_archive(path: Path) -> bool:
return detect_archive_format(path) is not None
# Files that only a program ships: executables and libraries, and the packed
# data and compiled scripts of the common game engines (Ren'Py, Unity, Godot,
# RPG Maker, Python builds). An archive holding any of them is a game or app
# build. Its images are sprites, UI and CGs bundled for the program, not art
# for the library, so the archive is kept whole as an attachment.
SOFTWARE_EXTS = frozenset({
".exe", ".msi", ".dll", ".so", ".dylib", ".apk",
".rpa", ".rpyc", ".rpy", ".pyc", ".pyo",
".pak", ".pck", ".unity3d", ".assets", ".ress",
".rgssad", ".rgss2a", ".rgss3a", ".rxdata", ".rvdata", ".rvdata2",
})
def member_names(path: Path) -> list[str]:
"""Every member name in the archive, read from its directory without
decompressing anything. Empty when the archive can't be listed."""
try:
fmt = detect_archive_format(path)
if fmt == "zip":
with zipfile.ZipFile(path) as zf:
return zf.namelist()
if fmt == "rar":
import rarfile
with rarfile.RarFile(path) as rf:
return rf.namelist()
if fmt == "7z":
import py7zr
with py7zr.SevenZipFile(path, "r") as zf:
return zf.getnames()
except Exception as exc:
log.warning("archive listing failed for %s: %s", path, exc)
return []
def software_marker(path: Path) -> str | None:
"""The first member that marks the archive as a software build, or None."""
for name in member_names(path):
if Path(name).suffix.lower() in SOFTWARE_EXTS:
return name
return None
@contextmanager
def extract_archive(path: Path):
"""Context manager yielding a list of (member_name, extracted_path)
+138
View File
@@ -0,0 +1,138 @@
"""The off-site backup destination: an S3 bucket (Fabled Archives, or any S3
service) that the database dumps and the media blobs are sent to.
The address, bucket, access key and region are plain settings. The secret key
is sealed at rest with the credential key (`/images/secrets`), the same key
that seals account logins, and no API ever returns it: the settings answer
only whether one is set. Because the credential key is kept out of every
backup, a database dump carries the secret only as ciphertext nobody can open
without the original server.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlsplit
from .backup_s3 import S3Error, S3Target, list_objects
from .credential_crypto import CredentialCrypto, InvalidCredentialBlob
KEY_PATH = Path("/images/secrets/credential_key.b64")
DEFAULT_REGION = "us-east-1"
_TEST_LISTING = 5
def _crypto() -> CredentialCrypto:
return CredentialCrypto(KEY_PATH)
def seal(secret: str) -> str:
return _crypto().encrypt(secret).decode("ascii")
def unseal(sealed: str) -> str:
return _crypto().decrypt(sealed.encode("ascii"))
@dataclass(frozen=True)
class DestinationFields:
endpoint: str | None
bucket: str | None
access_key: str | None
region: str
def clean_fields(body: dict) -> tuple[DestinationFields | None, str | None]:
"""The destination fields from a request body, trimmed, with empty ones as
None; or (None, why) when the address can't be an S3 endpoint."""
def text(name: str) -> str | None:
v = body.get(name)
if v is None:
return None
if not isinstance(v, str):
raise TypeError(name)
return v.strip() or None
try:
endpoint = text("endpoint")
bucket = text("bucket")
access_key = text("access_key")
region = text("region") or DEFAULT_REGION
except TypeError as exc:
return None, f"{exc.args[0]} must be text"
if endpoint is not None:
u = urlsplit(endpoint)
if u.scheme not in ("http", "https") or not u.hostname:
return None, ("the address must start with http:// or https:// and name "
"a host, e.g. http://archives.example:8081")
if u.query or u.fragment:
return None, "the address can't carry a query or a fragment"
endpoint = endpoint.rstrip("/")
if bucket is not None and "/" in bucket:
return None, "the bucket is a name, not a path: put any path in the address"
return DestinationFields(endpoint, bucket, access_key, region), None
def target_for(fields: DestinationFields, secret: str | None) -> S3Target | None:
"""The S3 target, or None while any part of it is missing."""
if not (fields.endpoint and fields.bucket and fields.access_key and secret):
return None
return S3Target(fields.endpoint, fields.bucket, fields.access_key, secret,
region=fields.region)
def saved_target(settings) -> S3Target | None:
"""The destination saved in ImportSettings, or None when none is set up.
Raises S3Error when the secret can't be opened (the credential key
changed since it was saved)."""
sealed = settings.backup_s3_secret_sealed
if not sealed:
return None
try:
secret = unseal(sealed)
except InvalidCredentialBlob:
raise S3Error(
"The saved secret key can't be opened on this server (the credential "
"key changed since it was saved). Enter the secret key again."
) from None
return target_for(fields_of(settings), secret)
def fields_of(settings) -> DestinationFields:
return DestinationFields(
settings.backup_s3_endpoint, settings.backup_s3_bucket,
settings.backup_s3_access_key, settings.backup_s3_region or DEFAULT_REGION,
)
def describe(settings) -> dict:
"""The destination as the settings screen shows it: never the secret."""
f = fields_of(settings)
return {
"endpoint": f.endpoint or "",
"bucket": f.bucket or "",
"access_key": f.access_key or "",
"region": f.region,
"secret_set": bool(settings.backup_s3_secret_sealed),
"upload_limit_mbps": settings.backup_upload_limit_mbps,
"configured": bool(
f.endpoint and f.bucket and f.access_key and settings.backup_s3_secret_sealed
),
}
def check_target(target: S3Target) -> dict:
"""Reach the bucket by listing it. A listing proves the address, the
bucket and both keys at once, and writes nothing, because an Archives key
is allowed to add objects but a test object could never be removed."""
try:
page = list_objects(target, limit=_TEST_LISTING)
except S3Error as exc:
return {"ok": False, "message": str(exc)}
if not page.objects:
said = f"Reached {target.bucket} at {target.endpoint}. The bucket is empty."
else:
more = "+" if page.next_token else ""
said = (f"Reached {target.bucket} at {target.endpoint}. It holds "
f"{len(page.objects)}{more} object(s), e.g. {page.objects[0].key}.")
return {"ok": True, "message": said}
+120
View File
@@ -0,0 +1,120 @@
"""Are the backups keeping up? One row on the System view, and the nav dot.
The backups run on their own at night, so the only way to notice one has
stopped is to be told. The System view's health endpoint asks `backup_part`,
which answers with a part like any other: `ok`, or `stale` with the sentence
that says what is wrong. It warns when
- the newest database backup is more than a day old, or there is none;
- the newest database backup failed;
- the weekly check of the off-site bucket found files missing from it, and no
media run has finished since to send them again.
No part at all when backups aren't set up (no off-site destination and the
nightly backup off): someone who chose not to back up is not told so every
fifteen seconds.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import select
from ..models import BackupRun, ImportSettings
from . import backup_destination
# A nightly backup is due every 24 hours. The hour of slack covers the dump
# itself and a beat tick that lands a little late.
DUMP_DUE_SECONDS = 25 * 3600
@dataclass(frozen=True)
class BackupFacts:
expected: bool
offsite: bool
last_dump_at: datetime | None = None
last_dump_error: str | None = None
missing: int = 0
def _age(seconds: float) -> str:
hours = int(seconds // 3600)
if hours >= 48:
return f"{hours // 24} days"
if hours >= 1:
return f"{hours} h"
return f"{max(1, int(seconds // 60))} min"
def verdict(facts: BackupFacts, now: datetime) -> dict | None:
"""The part for these facts, or None when backups aren't set up."""
if not facts.expected:
return None
where = "to the off-site bucket" if facts.offsite else "on this server"
problems = []
if facts.last_dump_error:
problems.append(f"The last database backup failed: {facts.last_dump_error}")
if facts.last_dump_at is None:
if not facts.last_dump_error:
problems.append(f"No database backup has been taken {where} yet")
else:
age = (now - facts.last_dump_at).total_seconds()
if age >= DUMP_DUE_SECONDS:
problems.append(f"The newest database backup {where} is {_age(age)} old")
if facts.missing:
problems.append(
f"The bucket check found {facts.missing:,} file(s) missing from the bucket; "
"the next media run sends them again"
)
part = {"key": "backup", "kind": "backup", "name": "Backups"}
if problems:
return {**part, "state": "stale", "detail": ". ".join(problems)}
age = (now - facts.last_dump_at).total_seconds()
return {**part, "state": "ok",
"detail": f"The newest database backup {where} is {_age(age)} old"}
def gather(session) -> BackupFacts:
"""Read the facts `verdict` judges, from a sync session."""
settings = ImportSettings.load_sync(session)
offsite = backup_destination.describe(settings)["configured"]
expected = offsite or bool(settings.backup_db_nightly_enabled)
if not expected:
return BackupFacts(expected=False, offsite=False)
def newest(kind: str, statuses: tuple[str, ...]):
return session.execute(
select(BackupRun).where(BackupRun.kind == kind)
.where(BackupRun.status.in_(statuses))
.order_by(BackupRun.started_at.desc()).limit(1)
).scalar_one_or_none()
last_ok = newest("db", ("ok",))
last_done = newest("db", ("ok", "error"))
failed = None
if last_done is not None and last_done.status == "error":
failed = last_done.error or "no reason recorded"
missing = 0
if offsite:
check = newest("reconcile", ("ok",))
dropped = int((check.manifest or {}).get("dropped") or 0) if check else 0
if dropped:
sent_since = session.execute(
select(BackupRun.id).where(BackupRun.kind == "media")
.where(BackupRun.status == "ok")
.where(BackupRun.finished_at > check.finished_at).limit(1)
).scalar_one_or_none()
missing = 0 if sent_since is not None else dropped
return BackupFacts(
expected=True, offsite=offsite,
last_dump_at=(last_ok.finished_at or last_ok.started_at) if last_ok else None,
last_dump_error=failed, missing=missing,
)
def backup_part(session, now: datetime) -> dict | None:
return verdict(gather(session), now)
+202
View File
@@ -0,0 +1,202 @@
"""The off-site copy of the library's files: every image and attachment, sent
once each to the backup bucket as a content-addressed object.
Layout in the bucket, beside the database dumps under `database/`:
media/ab/cd/<sha256><ext>
`ab` and `cd` are the first four hex digits of the file's sha256, which keeps
any one prefix small, and the extension keeps the files readable in the bucket
without FabledCurator. The sha256 is the one recorded at import, so a file
uploads exactly once however many rows or posts point at it, and a run is
incremental: its work list is every sha with no `backup_blob` row for the
current destination. A file removed from the library stays in the bucket until
the bucket's own retention removes it; FabledCurator never deletes there.
The bucket checks every object against that recorded sha256, so a file whose
bytes changed on disk since import is refused and reported, never stored under
the wrong name.
"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass
from pathlib import Path
from sqlalchemy import delete, select, text
from sqlalchemy.dialects.postgresql import insert
from ..models import BackupBlob
from .backup_s3 import (
Pacer,
S3Error,
S3Target,
list_objects,
object_present,
put_file,
put_file_in_parts,
)
PREFIX = "media/"
# Files above this size go in parts (S3 refuses a single PUT over 5 GiB, and a
# part that fails is resent alone instead of the whole file).
MULTIPART_OVER = 1 << 30
# The bucket is listed this many keys at a time during a reconcile, with a
# short rest between pages.
LIST_PAGE = 1000
LIST_REST_SECONDS = 0.2
_EXT = re.compile(r"^\.[a-z0-9]{1,10}$")
@dataclass(frozen=True)
class Blob:
sha256: str
path: str
size_bytes: int
mime: str | None
def destination_id(endpoint: str, bucket: str) -> str:
"""How backup_blob names a destination: one copy per endpoint and bucket."""
return f"{endpoint.rstrip('/')}/{bucket}"
def target_id(target: S3Target) -> str:
return destination_id(target.endpoint, target.bucket)
def blob_key(sha256: str, path: str) -> str:
"""The bucket key for a file: `media/ab/cd/<sha256><ext>`. An extension
that isn't short and plain is left off, so a key is always safe."""
ext = Path(path).suffix.lower()
return f"{PREFIX}{sha256[:2]}/{sha256[2:4]}/{sha256}{ext if _EXT.match(ext) else ''}"
# Every library file once per sha256: images and attachments together. An
# attachment's sha can repeat across posts, and an image extracted from an
# archive is its own file with its own sha.
_LIBRARY = """
SELECT sha256, path, size_bytes, mime FROM image_record
UNION ALL
SELECT sha256, path, size_bytes, mime FROM post_attachment
"""
def pending(session, tid: str, *, after: str = "", limit: int = 500) -> list[Blob]:
"""The next `limit` files not yet in the bucket, in sha order after
`after`, so a run walks the library once even when some files fail."""
rows = session.execute(text(f"""
SELECT DISTINCT ON (lib.sha256) lib.sha256, lib.path, lib.size_bytes, lib.mime
FROM ({_LIBRARY}) AS lib
WHERE lib.sha256 > :after
AND NOT EXISTS (
SELECT 1 FROM backup_blob b WHERE b.target = :tid AND b.sha256 = lib.sha256
)
ORDER BY lib.sha256, lib.path
LIMIT :limit
"""), {"after": after, "tid": tid, "limit": limit}).all()
return [Blob(r.sha256, r.path, r.size_bytes, r.mime) for r in rows]
def coverage(session, tid: str | None) -> dict:
"""How much of the library is in the bucket: files and bytes, sent and
total, each sha counted once."""
row = session.execute(text(f"""
WITH lib AS (
SELECT DISTINCT ON (sha256) sha256, size_bytes FROM ({_LIBRARY}) AS u
ORDER BY sha256
)
SELECT count(*) AS files_total,
coalesce(sum(lib.size_bytes), 0) AS bytes_total,
count(b.sha256) AS files_sent,
coalesce(sum(lib.size_bytes) FILTER (WHERE b.sha256 IS NOT NULL), 0)
AS bytes_sent
FROM lib
LEFT JOIN backup_blob b ON b.sha256 = lib.sha256 AND b.target = :tid
"""), {"tid": tid or ""}).one()
return {
"files_total": row.files_total, "bytes_total": int(row.bytes_total),
"files_sent": row.files_sent, "bytes_sent": int(row.bytes_sent),
}
def send(target: S3Target, blob: Blob, images_root: Path, pacer: Pacer | None) -> str:
"""Upload one file to its key and return the key. Raises S3Error (or
OSError) with the reason when it can't be sent."""
path = Path(blob.path)
if not path.is_absolute():
path = images_root / path
if not path.is_file():
raise S3Error(f"{path} is not on disk.")
size = path.stat().st_size
if size != blob.size_bytes:
raise S3Error(f"{path} is {size} bytes on disk but {blob.size_bytes} when imported.")
key = blob_key(blob.sha256, blob.path)
content_type = blob.mime or "application/octet-stream"
try:
if size > MULTIPART_OVER:
put_file_in_parts(target, key, path, sha256=blob.sha256,
content_type=content_type, pacer=pacer)
else:
put_file(target, key, path, content_type=content_type,
payload_sha256=blob.sha256, pacer=pacer)
except S3Error:
if object_present(target, key, size):
return key # already there from an earlier run
raise
return key
def record(session, tid: str, blobs: list[tuple[Blob, str]]) -> None:
"""Note files as sent: (blob, key) pairs. A row already there is kept."""
if blobs:
session.execute(insert(BackupBlob).values([
{"target": tid, "sha256": b.sha256, "key": key, "size_bytes": b.size_bytes}
for b, key in blobs
]).on_conflict_do_nothing())
def reconcile(session, target: S3Target) -> dict:
"""Check backup_blob against what the bucket actually holds under
`media/`. A row whose object is missing or the wrong size is dropped, so
the next run sends that file again; an object the bucket holds for a
library file with no row (a restored database, a lost commit) is recorded,
so it isn't sent twice. Returns the counts."""
tid = target_id(target)
held: dict[str, int] = {}
token = None
while True:
page = list_objects(target, PREFIX, LIST_PAGE, continuation=token)
held.update((o.key, o.size) for o in page.objects)
token = page.next_token
if not token:
break
time.sleep(LIST_REST_SECONDS)
rows = session.execute(
select(BackupBlob.sha256, BackupBlob.key, BackupBlob.size_bytes)
.where(BackupBlob.target == tid)
).all()
gone = [r.sha256 for r in rows if held.get(r.key) != r.size_bytes]
for at in range(0, len(gone), 1000):
session.execute(delete(BackupBlob).where(
BackupBlob.target == tid, BackupBlob.sha256.in_(gone[at:at + 1000]),
))
kept = {r.sha256 for r in rows} - set(gone)
found = []
for r in session.execute(text(f"""
SELECT DISTINCT ON (sha256) sha256, path, size_bytes, mime
FROM ({_LIBRARY}) AS lib ORDER BY sha256, path
""")).all():
if r.sha256 in kept:
continue
blob = Blob(r.sha256, r.path, r.size_bytes, r.mime)
key = blob_key(blob.sha256, blob.path)
if held.get(key) == blob.size_bytes:
found.append((blob, key))
for at in range(0, len(found), 1000):
record(session, tid, found[at:at + 1000])
session.commit()
return {"listed": len(held), "dropped": len(gone), "adopted": len(found)}
+198
View File
@@ -0,0 +1,198 @@
"""Restoring from the off-site bucket: the database from one of its dated
dumps, then every library file the restored rows name, fetched back to the
path each row records.
The database comes first because it is the map: each image and attachment row
holds its file's path and sha256, which is also the file's name in the bucket
(`media/ab/cd/<sha256><ext>`). A file is checked against that sha256 on
arrival and only then moved into place, so a restore never leaves a wrong or
half-written file in the library. A file already on disk at the right size is
left alone, which makes a restore safe to run again and lets it bring back
only what a damaged library is missing.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from sqlalchemy import select, text
from ..models import BackupBlob, ImportSettings
from . import backup_destination
from .backup_media import _LIBRARY, blob_key
from .backup_s3 import Pacer, S3Error, S3Target, get_file, list_objects
DUMP_PREFIX = "database/"
_DUMP_NAME = re.compile(r"^database/fabledcurator-(\d{8}T\d{6}Z)\.dump$")
_ALEMBIC_INI = Path(__file__).resolve().parents[3] / "alembic.ini"
_MIGRATE_TIMEOUT_S = 1800
@dataclass(frozen=True)
class Dump:
key: str
ts: str # 20261107T134258Z, UTC
size_bytes: int
counted: bool = False # its row counts sit beside it, for a test restore
def list_dumps(target: S3Target) -> list[Dump]:
"""Every database dump in the bucket, newest first."""
found, keys, token = [], set(), None
while True:
page = list_objects(target, DUMP_PREFIX, 1000, continuation=token)
for o in page.objects:
keys.add(o.key)
if m := _DUMP_NAME.match(o.key):
found.append((o.key, m.group(1), o.size))
token = page.next_token
if not token:
break
dumps = [Dump(k, ts, size, counted=k.removesuffix(".dump") + ".counts.json" in keys)
for k, ts, size in found]
return sorted(dumps, key=lambda d: d.ts, reverse=True)
def find_dump(target: S3Target, key: str | None) -> Dump:
"""The dump at `key`, or the newest when `key` is None or "latest"."""
dumps = list_dumps(target)
if not dumps:
raise S3Error(f"The {target.bucket} bucket holds no database dumps.")
if key in (None, "", "latest"):
return dumps[0]
for d in dumps:
if d.key == key:
return d
raise S3Error(f"{key} is not among the dumps in the {target.bucket} bucket.")
def fetch_dump(target: S3Target, dump: Dump, into: Path, pacer: Pacer | None) -> Path:
"""Download a dump into the folder `into`, checked against its listed
size. pg_restore reads it from there."""
into.mkdir(parents=True, exist_ok=True)
path = into / Path(dump.key).name
get_file(target, dump.key, path, size=dump.size_bytes, pacer=pacer)
return path
def migrate(db_name: str | None = None) -> None:
"""Bring a restored database up to this install's schema. A dump taken by
an older FabledCurator gains the migrations it predates; one from a newer
FabledCurator fails here, with alembic's reason, because this install
doesn't know its schema. `db_name` names another database on the same
server to bring up instead of this install's own."""
env = {**os.environ, "DB_NAME": db_name} if db_name else None
with tempfile.TemporaryFile() as err:
proc = subprocess.run(
[sys.executable, "-m", "alembic", "-c", str(_ALEMBIC_INI), "upgrade", "head"],
cwd=_ALEMBIC_INI.parent, stdout=subprocess.DEVNULL, stderr=err,
timeout=_MIGRATE_TIMEOUT_S, env=env,
)
if proc.returncode != 0:
err.seek(0)
lines = err.read().decode(errors="replace").strip().splitlines()
raise S3Error("The restored database couldn't be brought up to this "
f"version: {lines[-1] if lines else 'alembic failed'}")
def keep_destination(session, target: S3Target, limit_mbps: int | None) -> None:
"""Write this install's destination into the restored settings. The
dump's own copy of the secret is sealed with the old server's credential
key, which never leaves that server, so it can't be opened here."""
settings = ImportSettings.load_sync(session)
settings.backup_s3_endpoint = target.endpoint
settings.backup_s3_bucket = target.bucket
settings.backup_s3_access_key = target.access_key
settings.backup_s3_region = target.region
settings.backup_s3_secret_sealed = backup_destination.seal(target.secret_key)
settings.backup_upload_limit_mbps = limit_mbps
session.commit()
# ── files ───────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class LibraryFile:
sha256: str
paths: tuple[str, ...] # every row's path for this sha, sorted
size_bytes: int
def library_files(session, *, after: str = "", limit: int = 200) -> list[LibraryFile]:
"""The next `limit` distinct files the library names, in sha order after
`after`, each with every path a row gives it."""
rows = session.execute(text(f"""
SELECT lib.sha256, array_agg(DISTINCT lib.path ORDER BY lib.path) AS paths,
max(lib.size_bytes) AS size_bytes
FROM ({_LIBRARY}) AS lib
WHERE lib.sha256 > :after
GROUP BY lib.sha256
ORDER BY lib.sha256
LIMIT :limit
"""), {"after": after, "limit": limit}).all()
return [LibraryFile(r.sha256, tuple(r.paths), r.size_bytes) for r in rows]
def sent_keys(session, tid: str, shas: list[str]) -> dict[str, str]:
"""The bucket key recorded for each sha that was sent to this destination."""
if not shas:
return {}
return dict(session.execute(
select(BackupBlob.sha256, BackupBlob.key)
.where(BackupBlob.target == tid, BackupBlob.sha256.in_(shas))
).all())
def _resolve(path: str, images_root: Path) -> Path:
"""Where a row's path lands on this server. A path outside the library
folder is refused: a restore writes only inside it."""
p = Path(path)
if not p.is_absolute():
p = images_root / p
root = Path(*images_root.parts)
if ".." in p.parts or not (p == root or root in p.parents):
raise S3Error(f"{path} is outside {images_root}, so it isn't restored there.")
return p
def _present(p: Path, size: int) -> bool:
try:
return p.is_file() and p.stat().st_size == size
except OSError:
return False
def restore_file(target: S3Target, f: LibraryFile, key: str | None,
images_root: Path, pacer: Pacer | None) -> str:
"""Put one file back at every path the library gives it. Returns
"present" (nothing was missing), "copied" (a path already on disk
supplied the others) or "restored" (fetched from the bucket). Raises
ObjectMissing when the bucket doesn't hold it, S3Error or OSError
otherwise."""
places = [_resolve(p, images_root) for p in f.paths]
have = [p for p in places if _present(p, f.size_bytes)]
need = [p for p in places if p not in have]
if not need:
return "present"
outcome = "copied"
if not have:
first = need.pop(0)
first.parent.mkdir(parents=True, exist_ok=True)
get_file(target, key or blob_key(f.sha256, f.paths[0]), first,
sha256=f.sha256, size=f.size_bytes, pacer=pacer)
have.append(first)
outcome = "restored"
for p in need:
p.parent.mkdir(parents=True, exist_ok=True)
partial = p.with_name(p.name + ".partial")
shutil.copyfile(have[0], partial)
partial.replace(p)
return outcome
+533
View File
@@ -0,0 +1,533 @@
"""A small S3 client for sending backups off the box: AWS Signature Version 4,
path-style addressing (endpoint/bucket/key), and the calls a backup needs —
put an object (whole, or in parts when its size isn't known up front), read
one back, list a prefix.
FabledCurator puts, lists and reads; it never deletes. The bucket keeps the
history and thins it on its own policy, so a key that may only add and read
objects is enough, and is the safer key to hand a service: a stolen one cannot
wipe the history it writes into.
Built on the standard library's http.client rather than boto3: three calls
don't justify a large dependency, and signing is a pure function the tests
hold to AWS's own published examples. Every call is synchronous, because the
uploads run in Celery workers; the API runs the Test destination call in a
thread.
"""
from __future__ import annotations
import hashlib
import hmac
import http.client
import os
import socket
import ssl
import time
import xml.etree.ElementTree as ET
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
TIMEOUT_SECONDS = 30.0
# A single object can be a multi-gigabyte video or archive; a slow link still
# gets this long between bytes before the call gives up.
TRANSFER_TIMEOUT_SECONDS = 15 * 60
CHUNK = 1 << 20
class S3Error(Exception):
"""Why a call to the bucket failed, said to the person."""
@dataclass(frozen=True)
class S3Target:
endpoint: str # http(s)://host[:port][/prefix], no bucket
bucket: str
access_key: str
secret_key: str
region: str = "us-east-1"
verify_tls: bool = True
def url(self, key: str = "") -> str:
base = f"{self.endpoint.rstrip('/')}/{quote(self.bucket, safe='')}"
return f"{base}/{quote(key, safe='/-_.~')}" if key else f"{base}/"
@dataclass(frozen=True)
class StoredObject:
key: str
size: int
last_modified: str
@dataclass(frozen=True)
class ListPage:
objects: list[StoredObject]
next_token: str | None # pass back as `continuation` for the next page
# ── Signing ──────────────────────────────────────────────────────────────────
def _encode(text: str, keep_slash: bool = False) -> str:
return quote(text, safe="-_.~" + ("/" if keep_slash else ""))
def _hmac(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
def sign(method: str, url: str, headers: dict[str, str], payload_sha256: str,
access_key: str, secret_key: str, region: str, at: datetime,
service: str = "s3") -> dict[str, str]:
"""The request's headers with Host, x-amz-date, x-amz-content-sha256 and
Authorization added. Every header given is signed, Host always."""
u = urlsplit(url)
stamp = at.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
day = stamp[:8]
out = dict(headers)
out["Host"] = u.netloc
out["x-amz-date"] = stamp
out["x-amz-content-sha256"] = payload_sha256
signed = {k.lower(): " ".join(str(v).split()) for k, v in out.items()}
names = sorted(signed)
query = sorted(
(_encode(k), _encode(v)) for k, v in parse_qsl(u.query, keep_blank_values=True)
)
canonical = "\n".join([
method.upper(),
_encode(unquote(u.path) or "/", keep_slash=True),
"&".join(f"{k}={v}" for k, v in query),
"".join(f"{n}:{signed[n]}\n" for n in names),
";".join(names),
payload_sha256,
])
scope = f"{day}/{region}/{service}/aws4_request"
to_sign = "\n".join(["AWS4-HMAC-SHA256", stamp, scope,
hashlib.sha256(canonical.encode()).hexdigest()])
key = _hmac(_hmac(_hmac(_hmac(f"AWS4{secret_key}".encode(), day), region),
service), "aws4_request")
signature = hmac.new(key, to_sign.encode(), hashlib.sha256).hexdigest()
out["Authorization"] = (f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, "
f"SignedHeaders={';'.join(names)}, Signature={signature}")
return out
# ── Transport ────────────────────────────────────────────────────────────────
def _connect(target: S3Target, url: str, timeout: float) -> http.client.HTTPConnection:
u = urlsplit(url)
if u.scheme == "https":
ctx = ssl.create_default_context()
if not target.verify_tls:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return http.client.HTTPSConnection(
u.hostname, u.port, timeout=timeout, context=ctx, blocksize=CHUNK,
)
if u.scheme != "http":
raise S3Error(f"{target.endpoint} isn't an http:// or https:// address.")
return http.client.HTTPConnection(u.hostname, u.port, timeout=timeout, blocksize=CHUNK)
def _request_target(url: str) -> str:
u = urlsplit(url)
return (u.path or "/") + (f"?{u.query}" if u.query else "")
def _reach_error(target: S3Target, exc: Exception) -> S3Error:
if isinstance(exc, TimeoutError):
return S3Error(f"{target.endpoint} didn't answer in time.")
if isinstance(exc, socket.gaierror):
return S3Error(f"Couldn't reach {target.endpoint}: the host name doesn't resolve.")
if isinstance(exc, ConnectionRefusedError):
return S3Error(
f"Couldn't reach {target.endpoint}: nothing is listening there "
"(check the port)."
)
if isinstance(exc, ssl.SSLError):
return S3Error(f"Couldn't reach {target.endpoint}: TLS failed ({exc.reason}).")
return S3Error(f"Couldn't reach {target.endpoint}: {exc}.")
def _says(status: int, reason: str, content: bytes) -> str:
"""An S3 error response in words: its Code and Message when it has them."""
try:
root = ET.fromstring(content) if content else None
code = (root.findtext("Code") or "") if root is not None else ""
message = (root.findtext("Message") or "") if root is not None else ""
except ET.ParseError:
code, message = "", ""
hints = {
"SignatureDoesNotMatch": "the secret key is wrong",
"InvalidAccessKeyId": "the access key isn't known there",
"NoSuchBucket": "there is no such bucket",
"AccessDenied": "the key isn't allowed to do that",
"XAmzContentSHA256Mismatch": "the file's bytes don't match its recorded checksum",
}
said = hints.get(code) or message or reason
if not code and 400 <= status < 500:
# S3 refuses with an <Error><Code> body. A 4xx without one came from
# something else at that address — most often a web server on the
# default port, when the S3 service listens on a port of its own.
said = ("the address answered, but not as S3 — check the scheme and "
"the port (an S3 service usually listens on its own port)")
return f"HTTP {status}{f' {code}' if code else ''}: {said}."
# ── Calls ────────────────────────────────────────────────────────────────────
def file_sha256(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(CHUNK):
h.update(chunk)
return h.hexdigest()
def put_file(target: S3Target, key: str, path: Path, *,
content_type: str = "application/octet-stream",
payload_sha256: str | None = None,
pacer: Pacer | None = None,
now: datetime | None = None) -> None:
"""Upload one file as one object, streamed from disk. `payload_sha256` is
the file's sha256 when the caller already knows it (the library stores
every file's), which saves reading the file twice; the bucket checks the
bytes against it, so a file that changed on disk is refused rather than
stored under the wrong name."""
digest = payload_sha256 or file_sha256(path)
size = path.stat().st_size
url = target.url(key)
headers = sign("PUT", url, {"Content-Length": str(size), "Content-Type": content_type},
digest, target.access_key, target.secret_key, target.region,
now or datetime.now(UTC))
conn = _connect(target, url, TRANSFER_TIMEOUT_SECONDS)
try:
with open(path, "rb") as f:
conn.request("PUT", _request_target(url),
body=pacer.file_chunks(f) if pacer else f, headers=headers)
response = conn.getresponse()
content = response.read()
except (OSError, http.client.HTTPException) as exc:
raise _reach_error(target, exc) from None
finally:
conn.close()
if response.status // 100 != 2:
raise S3Error(f"Upload of {key} refused: "
f"{_says(response.status, response.reason, content)}")
class ObjectMissing(S3Error):
"""The bucket holds nothing at the key asked for."""
def get_file(target: S3Target, key: str, path: Path, *,
sha256: str | None = None, size: int | None = None,
pacer: Pacer | None = None,
now: datetime | None = None) -> None:
"""Download one object to `path`, streamed to disk. The bytes land under a
`.partial` name and are renamed into place when complete, so a broken
transfer never leaves a truncated file at `path`. With `sha256` (and
`size`) given, the bytes are checked on arrival and a copy that doesn't
match is thrown away and reported. Raises ObjectMissing when the bucket
has no such object."""
url = target.url(key)
headers = sign("GET", url, {}, EMPTY_SHA256, target.access_key, target.secret_key,
target.region, now or datetime.now(UTC))
partial = path.with_name(path.name + ".partial")
digest = hashlib.sha256()
got = 0
conn = _connect(target, url, TRANSFER_TIMEOUT_SECONDS)
try:
conn.request("GET", _request_target(url), headers=headers)
response = conn.getresponse()
if response.status == 404:
response.read()
raise ObjectMissing(f"{key} is not in the {target.bucket} bucket.")
if response.status != 200:
raise S3Error(f"Reading {key} refused: "
f"{_says(response.status, response.reason, response.read())}")
with open(partial, "wb") as f:
while chunk := response.read(CHUNK):
if pacer:
pacer.take(len(chunk))
digest.update(chunk)
got += len(chunk)
f.write(chunk)
if size is not None and got != size:
raise S3Error(f"{key} arrived as {got} bytes, not the {size} expected.")
if sha256 and digest.hexdigest() != sha256:
raise S3Error(f"{key} arrived with a different fingerprint "
f"({digest.hexdigest()[:12]}…), so it wasn't kept.")
os.replace(partial, path)
except (OSError, http.client.HTTPException) as exc:
partial.unlink(missing_ok=True)
raise _reach_error(target, exc) from None
except S3Error:
partial.unlink(missing_ok=True)
raise
finally:
conn.close()
def list_objects(target: S3Target, prefix: str = "", limit: int = 1000, *,
continuation: str | None = None,
now: datetime | None = None) -> ListPage:
"""One page (up to `limit`) of the objects under `prefix`, ListObjectsV2."""
params = {"list-type": "2", "max-keys": str(limit)}
if prefix:
params["prefix"] = prefix
if continuation:
params["continuation-token"] = continuation
url = f"{target.url()}?{urlencode(params, quote_via=quote)}"
headers = sign("GET", url, {}, EMPTY_SHA256, target.access_key, target.secret_key,
target.region, now or datetime.now(UTC))
conn = _connect(target, url, TIMEOUT_SECONDS)
try:
conn.request("GET", _request_target(url), headers=headers)
response = conn.getresponse()
content = response.read()
except (OSError, http.client.HTTPException) as exc:
raise _reach_error(target, exc) from None
finally:
conn.close()
if response.status != 200:
raise S3Error(f"Listing {target.bucket} refused: "
f"{_says(response.status, response.reason, content)}")
try:
root = ET.fromstring(content)
except ET.ParseError:
raise S3Error(
f"{target.endpoint} answered a listing with something that isn't S3's XML."
) from None
objects, next_token = [], None
for item in root.iter():
tag = item.tag.rsplit("}", 1)[-1]
if tag == "NextContinuationToken":
next_token = item.text or None
if tag != "Contents":
continue
fields = {child.tag.rsplit("}", 1)[-1]: (child.text or "") for child in item}
objects.append(StoredObject(fields.get("Key", ""), int(fields.get("Size") or 0),
fields.get("LastModified", "")))
return ListPage(objects, next_token)
# ── Multipart: one object sent in parts ─────────────────────────────────────
# Each part is held in memory while it is sent, so this bounds the memory a
# streamed upload uses. S3 allows 10,000 parts, which at this size is 640 GiB
# for one object, and every part but the last must be at least 5 MiB.
PART_SIZE = 64 * CHUNK
# A part the bucket couldn't take (it didn't answer, or said it was busy) is
# sent again after a rest that grows each time: 15 s, then 60 s.
PART_RETRY_WAITS = (15, 60)
_BUSY = frozenset({429, 500, 502, 503, 504})
_MAX_RETRY_AFTER = 300
class Pacer:
"""Holds the bytes a job moves to an average rate, across every request
it makes, so a backup or a restore leaves room on the link for everything
else."""
def __init__(self, mbps: int | None):
self.rate = mbps * 1_000_000 / 8 if mbps else None # bytes per second
self.sent = 0
self.started = time.monotonic()
def take(self, n: int) -> None:
"""Count `n` bytes moved, pausing while the job is ahead of its rate."""
if self.rate:
self.sent += n
ahead = self.sent / self.rate - (time.monotonic() - self.started)
if ahead > 0:
time.sleep(ahead)
def chunks(self, data: bytes) -> Iterator[memoryview]:
view = memoryview(data)
for at in range(0, len(view), CHUNK):
piece = view[at:at + CHUNK]
self.take(len(piece))
yield piece
def file_chunks(self, f) -> Iterator[bytes]:
while piece := f.read(CHUNK):
self.take(len(piece))
yield piece
def _send(target: S3Target, method: str, url: str, body: bytes = b"",
headers: dict[str, str] | None = None, *, timeout: float = TIMEOUT_SECONDS,
pacer: Pacer | None = None,
now: datetime | None = None) -> tuple[int, str, http.client.HTTPMessage, bytes]:
"""Sign and send one request with an in-memory body, paced when a pacer
is given. Returns (status, reason, headers, content)."""
digest = hashlib.sha256(body).hexdigest()
signed = sign(method, url, {"Content-Length": str(len(body)), **(headers or {})},
digest, target.access_key, target.secret_key, target.region,
now or datetime.now(UTC))
conn = _connect(target, url, timeout)
try:
conn.request(method, _request_target(url),
body=pacer.chunks(body) if pacer and body else body, headers=signed)
response = conn.getresponse()
content = response.read()
except (OSError, http.client.HTTPException) as exc:
raise _reach_error(target, exc) from None
finally:
conn.close()
return response.status, response.reason, response.headers, content
def _xml_field(content: bytes, name: str) -> str | None:
try:
root = ET.fromstring(content)
except ET.ParseError:
return None
for item in root.iter():
if item.tag.rsplit("}", 1)[-1] == name:
return item.text
return None
def _is_error_body(content: bytes) -> bool:
try:
return bool(content) and ET.fromstring(content).tag.rsplit("}", 1)[-1] == "Error"
except ET.ParseError:
return False
def _retry_after(headers: http.client.HTTPMessage | None, default: int) -> int:
try:
asked = int((headers or {}).get("Retry-After") or default)
except ValueError:
asked = default
return max(0, min(asked, _MAX_RETRY_AFTER))
class Upload:
"""One object written as a series of parts, for data whose size isn't known
when it starts, such as a database dump streamed from pg_dump.
The object appears in the bucket only when `complete()` succeeds. Until
then the parts are invisible, so a stream that fails partway leaves
nothing behind that looks like a finished backup. `abort()` asks the
bucket to drop the parts at once; if the abort is refused or never
arrives, S3 services expire an unfinished upload on their own (Fabled
Archives after a day)."""
def __init__(self, target: S3Target, key: str, *,
content_type: str = "application/octet-stream",
pacer: Pacer | None = None):
self.target = target
self.key = key
self.size = 0
self.pacer = pacer
self._etags: list[str] = []
status, reason, _, content = _send(
target, "POST", f"{target.url(key)}?uploads", headers={"Content-Type": content_type},
)
upload_id = _xml_field(content, "UploadId") if status == 200 else None
if not upload_id:
raise S3Error(f"Starting the upload of {key} refused: "
f"{_says(status, reason, content)}")
self.upload_id = upload_id
def _url(self, **params: str) -> str:
query = urlencode({**params, "uploadId": self.upload_id}, quote_via=quote)
return f"{self.target.url(self.key)}?{query}"
@property
def parts(self) -> int:
return len(self._etags)
def add(self, data: bytes) -> None:
"""Send the next part, resting and sending it again when the bucket
doesn't answer or says it is busy."""
n = len(self._etags) + 1
for wait in (*PART_RETRY_WAITS, None):
headers = None
try:
status, reason, headers, content = _send(
self.target, "PUT", self._url(partNumber=str(n)), data,
timeout=TRANSFER_TIMEOUT_SECONDS, pacer=self.pacer,
)
except S3Error as exc:
if wait is None:
raise S3Error(f"Part {n} of {self.key}: {exc}") from None
time.sleep(wait)
continue
etag = headers.get("ETag") if status == 200 else None
if etag:
self._etags.append(etag)
self.size += len(data)
return
if status not in _BUSY or wait is None:
raise S3Error(f"Part {n} of {self.key} refused: "
f"{_says(status, reason, content)}")
time.sleep(_retry_after(headers, wait))
def complete(self) -> None:
"""Join the parts into the object. S3 can refuse a completion with an
error body under a 200 status, so the body is read too."""
if not self._etags:
raise S3Error(f"Nothing was sent for {self.key}.")
body = "<CompleteMultipartUpload>" + "".join(
f"<Part><PartNumber>{n}</PartNumber><ETag>{etag}</ETag></Part>"
for n, etag in enumerate(self._etags, start=1)
) + "</CompleteMultipartUpload>"
status, reason, _, content = _send(
self.target, "POST", self._url(), body.encode(),
headers={"Content-Type": "application/xml"}, timeout=TRANSFER_TIMEOUT_SECONDS,
)
if status != 200 or _is_error_body(content):
raise S3Error(f"Finishing the upload of {self.key} refused: "
f"{_says(status, reason, content)}")
def abort(self) -> None:
"""Drop the parts sent so far. Never raises: it runs while another
failure is already being reported, and the bucket expires the parts on
its own if this doesn't reach it."""
try:
_send(self.target, "DELETE", self._url())
except Exception: # noqa: BLE001 — best effort, see above
pass
def put_file_in_parts(target: S3Target, key: str, path: Path, *, sha256: str,
content_type: str = "application/octet-stream",
pacer: Pacer | None = None) -> int:
"""Upload a large file as a multipart object, read from disk a part at a
time. The whole file's sha256 is computed on the way and must equal
`sha256` before the upload is completed; otherwise it is aborted, so a file
that changed on disk is never stored under its old name. Returns the
number of bytes sent."""
upload = Upload(target, key, content_type=content_type, pacer=pacer)
digest = hashlib.sha256()
try:
with open(path, "rb") as f:
while part := f.read(PART_SIZE):
digest.update(part)
upload.add(part)
if digest.hexdigest() != sha256:
raise S3Error(f"{path} no longer matches its recorded checksum; not stored.")
upload.complete()
except BaseException:
upload.abort()
raise
return upload.size
def object_present(target: S3Target, key: str, size: int) -> bool:
"""Whether `key` is already in the bucket at `size` bytes. Used when a put
is refused: an append-only bucket refuses to replace an object that is
already there, which for a content-addressed key means the work is done."""
try:
page = list_objects(target, prefix=key, limit=1)
except S3Error:
return False
return any(o.key == key and o.size == size for o in page.objects)
+274 -100
View File
@@ -1,12 +1,11 @@
"""FC-3h: first-class backup/restore service for FC.
Two independent backup kinds:
- 'db' — pg_dump only; fast; nightly via Beat (settings-gated)
- 'images' — tar+zstd of /images; slow; manual trigger only
Files live under <images_root>/_backups/. Each backup writes:
fc_<kind>_<ts>.{sql|tar.zst} — the artifact
fc_<kind>_<ts>.json — manifest (kind/tag/triggered_by)
The database dump (pg_dump), nightly via Beat when the setting is on. With an
off-site destination set up, the dump streams straight to that bucket
(backup_db_offsite), and images and attachments go there too (backup_media).
Without one, the dump is written under <images_root>/_backups/:
fc_db_<ts>.dump — the dump (custom format)
fc_db_<ts>.json — manifest (tag, trigger, row counts)
Service functions are sync (subprocess-bound). Celery tasks in
backend.app.tasks.backup wrap each one with task_run-tracked
@@ -15,33 +14,19 @@ lifecycle + soft/hard time limits + retention bookkeeping.
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
import tempfile
import time
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
_BACKUPS_DIRNAME = "_backups"
log = logging.getLogger(__name__)
# Excluded from the images tarball, and each for its own reason (#4233, #4234):
#
# _backups — the archive would otherwise contain every previous archive.
# This is not hypothetical: the 2026-05-23/24 runs, taken before
# this exclude existed, grew 43G -> 107G -> ... -> 2123G as each
# swallowed its predecessors, and cost 4.3T of the images
# filesystem until they were reclaimed on 2026-09-21.
# _quarantine — holds files deliberately pulled OUT of the library.
# secrets — `credential_key.b64`, the key that decrypts the stored
# Patreon/SubscribeStar session credentials.
# cookies — those session cookies themselves.
#
# The last two are the ones worth stating plainly: an images tarball is a media
# archive, and a media archive that carries the key to the operator's accounts
# is a credential leak wearing a backup's name. Encryption at rest buys nothing
# when the key rides along in the same file. A restore therefore does NOT
# re-establish credentials — you sign in again, which is the correct outcome.
_IMAGES_EXCLUDED_DIRNAMES = ("_backups", "_quarantine", "secrets", "cookies")
_BACKUPS_DIRNAME = "_backups"
# Subprocess-level guardrails BEYOND the Celery soft_time_limit. The Celery
# soft limit signals the Python process; subprocess.Popen in a blocking syscall
@@ -51,9 +36,7 @@ _IMAGES_EXCLUDED_DIRNAMES = ("_backups", "_quarantine", "secrets", "cookies")
# matters because an NFS D-state hang defeats even Celery's SIGKILL (the failure
# that wedged the maintenance lane for hours, #739).
# backup_db_task: soft=1800s / hard=2100s → 1700s
# backup_images_task: soft=21600s / hard=23400s → 21000s
_DB_SUBPROCESS_TIMEOUT_S = 1700 # ~28 min, under the 30-min DB soft limit
_IMAGES_SUBPROCESS_TIMEOUT_S = 21000 # ~5.8 hr, under the 6-hr images soft limit
# Grace after SIGKILL to reap the child. If it can't be reaped in this window
# (an uninterruptible NFS D-state — the failure mode that wedged the
# concurrency-1 maintenance lane for hours, operator-flagged 2026-06-07), we
@@ -62,6 +45,85 @@ _IMAGES_SUBPROCESS_TIMEOUT_S = 21000 # ~5.8 hr, under the 6-hr images soft
_KILL_REAP_GRACE_S = 10
# Tables whose rows stay out of every dump. `credential` holds account logins,
# sealed with the key in /images/secrets: a restored install keeps the table,
# empty, and you sign in to each account again.
DATA_LEFT_OUT = ("credential",)
# Every database dump, local or off-site: custom format (-Fc, compressed and
# restored with pg_restore), without ownership or grants so it loads into any
# install.
_PG_DUMP = ["pg_dump", "--no-owner", "--no-acl", "-Fc",
*(f"--exclude-table-data={t}" for t in DATA_LEFT_OUT)]
def table_counts(conn) -> dict[str, int]:
"""How many rows each table in the public schema holds, read on a psycopg
connection, in the order of the tables' names."""
from psycopg import sql
names = [r[0] for r in conn.execute(
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename")]
return {
n: conn.execute(sql.SQL("SELECT count(*) FROM {}").format(sql.Identifier(n))).fetchone()[0]
for n in names
}
def database_counts(conn) -> dict:
"""{"alembic": the schema's revision, "tables": {name: rows}}."""
has_version = conn.execute("SELECT to_regclass('public.alembic_version')").fetchone()[0]
alembic = (conn.execute("SELECT version_num FROM alembic_version").fetchone()
if has_version else None)
return {"alembic": alembic[0] if alembic else None, "tables": table_counts(conn)}
def read_counts(db_url: str) -> dict:
"""database_counts for the database at `db_url`, as it is now."""
import psycopg
with psycopg.connect(_libpq_url(db_url)) as conn:
return database_counts(conn)
class DumpSnapshot:
"""A read-only transaction whose snapshot pg_dump dumps from (it is handed
over with --snapshot), so rows counted here are exactly the rows the dump
holds. A test restore checks every table against these counts."""
def __init__(self, conn):
self.conn = conn
self.id = conn.execute("SELECT pg_export_snapshot()").fetchone()[0]
def counts(self) -> dict | None:
"""The counts, or None when they can't be read: the dump is good
without them, and a test restore then says none were recorded."""
try:
return self._counts()
except Exception: # noqa: BLE001 — see above
log.exception("backup: the dump's row counts couldn't be read")
return None
def _counts(self) -> dict:
counts = database_counts(self.conn)
for t in DATA_LEFT_OUT:
if t in counts["tables"]:
counts["tables"][t] = 0 # the dump holds the table, not its rows
return counts
@contextmanager
def dump_snapshot(db_url: str):
"""Open the snapshot a dump is taken from. It stays open until the dump
is finished: pg_dump can only take it up while it is."""
import psycopg
with psycopg.connect(_libpq_url(db_url)) as conn:
conn.isolation_level = psycopg.IsolationLevel.REPEATABLE_READ
conn.read_only = True
yield DumpSnapshot(conn)
def _run_bounded(cmd: list[str], timeout: int) -> None:
"""subprocess.run(check=True, timeout) whose reaper can't itself hang.
@@ -117,7 +179,7 @@ def _file_size_or_none(path: Path) -> int | None:
def _write_manifest(
out_dir: Path, *, kind: str, ts: str,
tag: str | None, triggered_by: str,
artifact_path: Path,
artifact_path: Path, counts: dict | None = None,
) -> Path:
manifest = {
"kind": kind,
@@ -127,6 +189,8 @@ def _write_manifest(
"created_at": datetime.now(UTC).isoformat(),
"artifact_path": str(artifact_path),
}
if counts is not None:
manifest["counts"] = counts
mf = out_dir / f"fc_{kind}_{ts}.json"
mf.write_text(json.dumps(manifest, indent=2))
return mf
@@ -152,104 +216,215 @@ def backup_db(
os.close(fd)
tmp_path = Path(tmp_name)
try:
_run_bounded(
[
"pg_dump", "--no-owner", "--no-acl", "-Fc",
"-f", str(tmp_path), _libpq_url(db_url),
],
_DB_SUBPROCESS_TIMEOUT_S,
)
with dump_snapshot(db_url) as snap:
_run_bounded(
_gently([*_PG_DUMP, f"--snapshot={snap.id}", "-f", str(tmp_path),
_libpq_url(db_url)]),
_DB_SUBPROCESS_TIMEOUT_S,
)
counts = snap.counts()
shutil.move(str(tmp_path), str(sql_path))
finally:
if tmp_path.exists():
tmp_path.unlink(missing_ok=True)
manifest_path = _write_manifest(
out_dir, kind="db", ts=ts, tag=tag, triggered_by=triggered_by,
artifact_path=sql_path,
artifact_path=sql_path, counts=counts,
)
return {
"kind": "db",
"ts": ts,
"sql_path": str(sql_path),
"tar_path": None,
"manifest_path": str(manifest_path),
"size_bytes": _file_size_or_none(sql_path),
"counts": counts,
}
def backup_images(
*, images_root: Path,
tag: str | None = None, triggered_by: str = "manual",
) -> dict:
"""Run tar --zstd over images_root; write .tar.zst + manifest."""
class DumpFailed(Exception):
"""pg_dump exited with an error; the message is its last line of output."""
def offsite_dump_key(ts: str) -> str:
"""Where a database dump lands in the off-site bucket. Dated names sit
side by side under one prefix, which is what lets the bucket's retention
thin them by age."""
return f"database/fabledcurator-{ts}.dump"
def _gently(cmd: list[str]) -> list[str]:
"""`cmd` at low CPU priority, and low disk priority where `ionice` is
installed, so a long dump leaves the server responsive."""
io = ["ionice", "-c", "2", "-n", "7"] if shutil.which("ionice") else []
return ["nice", "-n", "10", *io, *cmd]
def _last_line(f) -> str | None:
f.seek(0)
lines = f.read().decode(errors="replace").strip().splitlines()
return lines[-1].strip() if lines else None
def backup_db_offsite(*, db_url: str, target, limit_mbps: int | None = None) -> dict:
"""Stream `pg_dump` straight into the off-site bucket, in parts, with no
copy staged on this server. The object appears only once pg_dump has
exited cleanly and every part has arrived; a dump that fails partway
leaves nothing in the bucket, and raises DumpFailed with pg_dump's own
last line. Returns a dict for the caller to record in BackupRun, with
`counts`: each table's rows in the dump, for the caller to store beside
it."""
ts = _now_ts()
out_dir = _backups_dir(images_root)
tar_path = out_dir / f"fc_images_{ts}.tar.zst"
# No local-temp here (the archive is hundreds of GB — it can't stage in
# /tmp), but bounded-kill still applies so a tar wedged on NFS fails fast
# rather than holding the lane for hours.
_run_bounded(
[
"tar", "--zstd", "-cf", str(tar_path),
"-C", str(images_root.parent), images_root.name,
*(
f"--exclude={images_root.name}/{name}"
for name in _IMAGES_EXCLUDED_DIRNAMES
),
],
_IMAGES_SUBPROCESS_TIMEOUT_S,
)
manifest_path = _write_manifest(
out_dir, kind="images", ts=ts, tag=tag, triggered_by=triggered_by,
artifact_path=tar_path,
)
with dump_snapshot(db_url) as snap:
result = _stream_dump(db_url, target, ts, limit_mbps, snap.id)
result["counts"] = snap.counts()
return result
def _stream_dump(db_url: str, target, ts: str, limit_mbps: int | None, snapshot: str) -> dict:
from .backup_s3 import PART_SIZE, Pacer, Upload
key = offsite_dump_key(ts)
deadline = time.monotonic() + _DB_SUBPROCESS_TIMEOUT_S
with tempfile.TemporaryFile() as err:
proc = subprocess.Popen(
_gently([*_PG_DUMP, f"--snapshot={snapshot}", _libpq_url(db_url)]),
stdout=subprocess.PIPE, stderr=err,
)
upload = None
try:
upload = Upload(target, key, pacer=Pacer(limit_mbps))
while part := proc.stdout.read(PART_SIZE):
if time.monotonic() > deadline:
raise subprocess.TimeoutExpired(proc.args, _DB_SUBPROCESS_TIMEOUT_S)
upload.add(part)
proc.stdout.close()
proc.wait(timeout=_KILL_REAP_GRACE_S * 6)
if proc.returncode != 0:
raise DumpFailed(_last_line(err) or f"pg_dump exited with {proc.returncode}")
upload.complete()
except BaseException:
if proc.poll() is None:
proc.kill()
try:
proc.wait(timeout=_KILL_REAP_GRACE_S)
except subprocess.TimeoutExpired:
pass # unkillable (D-state): abandon the reap, fail fast
if upload is not None:
upload.abort()
raise
return {
"kind": "images",
"kind": "db",
"ts": ts,
"sql_path": None,
"tar_path": str(tar_path),
"manifest_path": str(manifest_path),
"size_bytes": _file_size_or_none(tar_path),
"key": key,
"bucket": target.bucket,
"endpoint": target.endpoint,
"parts": upload.parts,
"size_bytes": upload.size,
}
class RestoreFailed(Exception):
"""psql or pg_restore exited with an error; the message is its last line."""
# A restore waits this long for other connections to let go of the tables it
# replaces, then gives up with Postgres' own reason instead of queueing every
# other query behind it.
_RESTORE_LOCK_TIMEOUT = "60s"
# pg_restore of a large library (vector tables, rebuilt indexes) runs well past
# a dump's length; it is bounded on its own.
_RESTORE_SUBPROCESS_TIMEOUT_S = 3 * 3600
def _run_reporting(cmd: list[str], timeout: int, env: dict | None = None) -> None:
"""Run `cmd`; raise RestoreFailed with the last line it printed when it
exits non-zero."""
with tempfile.TemporaryFile() as err:
proc = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=err, timeout=timeout,
env=env)
if proc.returncode != 0:
raise RestoreFailed(_last_line(err) or f"{cmd[0]} exited with {proc.returncode}")
# Every other connection to this database: the web server's, the other
# workers'. Ended before the tables are dropped, so none holds a lock the drop
# waits on, and again once the restore is in, so none keeps statements it
# prepared against the old tables. Each reconnects on its next query.
_END_OTHER_CONNECTIONS = (
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
"WHERE datname = current_database() AND pid <> pg_backend_pid();"
)
def end_other_connections(*, db_url: str) -> None:
_run_reporting(["psql", _libpq_url(db_url), "-v", "ON_ERROR_STOP=1",
"-c", _END_OTHER_CONNECTIONS], 60)
def wipe_db(*, db_url: str) -> None:
"""Drop and recreate the public schema: an empty database, ready for a
dump or for `alembic upgrade head`."""
_run_reporting(
[
"psql", _libpq_url(db_url), "-v", "ON_ERROR_STOP=1", "-c",
f"{_END_OTHER_CONNECTIONS} DROP SCHEMA IF EXISTS public CASCADE; "
"CREATE SCHEMA public;",
],
120,
# Set for the session, not as a SET inside the same -c string.
env={**os.environ, "PGOPTIONS": f"-c lock_timeout={_RESTORE_LOCK_TIMEOUT}"},
)
# A pg_restore newer than the server sets parameters the server doesn't have
# (pg_restore 17 against Postgres 16: `SET transaction_timeout = 0`). The
# statement fails and nothing depends on it, so its error is not a failed
# restore.
_HARMLESS_RESTORE_ERRORS = (
'unrecognized configuration parameter "transaction_timeout"',
)
def _restore_errors(stderr: str) -> list[str]:
"""pg_restore's error lines, less the harmless ones."""
return [
line.strip() for line in stderr.splitlines()
if "error:" in line and not any(h in line for h in _HARMLESS_RESTORE_ERRORS)
]
def restore_db(*, db_url: str, sql_path: Path) -> None:
"""Wipe public schema, then load from the custom-format dump. Raises on
subprocess failure; partial-restore state is the caller's concern."""
libpq = _libpq_url(db_url)
subprocess.run(
[
"psql", libpq, "-c",
"DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;",
],
capture_output=True, check=True, timeout=120,
)
# Custom-format (-Fc) dumps are restored with pg_restore, not psql.
subprocess.run(
["pg_restore", "--no-owner", "--no-acl", "-d", libpq, str(sql_path)],
capture_output=True, check=True,
timeout=_DB_SUBPROCESS_TIMEOUT_S,
)
def restore_images(*, images_root: Path, tar_path: Path) -> None:
"""Untar over images_root.parent. Additive — files NOT in the
tarball are NOT removed. Caller wipes first if a clean restore
is needed."""
subprocess.run(
[
"tar", "--zstd", "-xf", str(tar_path),
"-C", str(images_root.parent),
],
capture_output=True, check=True,
timeout=_IMAGES_SUBPROCESS_TIMEOUT_S,
)
"""Wipe the public schema, then load the custom-format dump. Any error
pg_restore reports, other than a harmless one, raises RestoreFailed with
the first; the database is then partly loaded, and the caller decides what
to put back."""
wipe_db(db_url=db_url)
# Custom-format (-Fc) dumps are restored with pg_restore, not psql. It runs
# on past errors (it can't skip the harmless one and stop at the rest), so
# its errors are judged once it is done.
cmd = _gently(["pg_restore", "--no-owner", "--no-acl",
"-d", _libpq_url(db_url), str(sql_path)])
with tempfile.TemporaryFile() as err:
proc = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=err,
timeout=_RESTORE_SUBPROCESS_TIMEOUT_S)
if proc.returncode == 0:
return
err.seek(0)
said = err.read().decode(errors="replace")
errors = _restore_errors(said)
if errors:
raise RestoreFailed(errors[0] if len(errors) == 1
else f"{errors[0]} ({len(errors)} errors in all)")
if not any(h in said for h in _HARMLESS_RESTORE_ERRORS):
# A failure with no error line to show for it.
lines = said.strip().splitlines()
raise RestoreFailed(lines[-1].strip() if lines
else f"pg_restore exited with {proc.returncode}")
def unlink_artifact_files(
*,
sql_path: str | None,
tar_path: str | None,
manifest_path: str | None,
) -> dict:
"""Best-effort unlink of all on-disk files for a BackupRun row.
@@ -258,7 +433,6 @@ def unlink_artifact_files(
deleted: dict = {}
for label, p in (
("sql", sql_path),
("tar", tar_path),
("manifest", manifest_path),
):
if not p:
+162
View File
@@ -0,0 +1,162 @@
"""Testing a database dump without touching this install.
The dump is loaded into a separate, temporary database on the same Postgres
server, using the same steps a real restore takes. Every table is counted
there and compared, the copy is brought up to this version, and the copy is
then dropped. The live database is only read, to show its counts alongside.
Every dump records how many rows each table held, counted in the same
snapshot pg_dump dumped from, so the comparison is exact. For a dump in the
bucket the counts sit beside it as `<dump name>.counts.json`; for a dump on
this server they are in its manifest file. A dump taken before counts were
recorded can still be tested: it is then compared with the live database,
where a difference is what changed after the dump was taken, not a fault.
The temporary database needs about as much free space on the database's disk
as the live one takes, and only for the length of the test.
"""
from __future__ import annotations
import json
import tempfile
from collections.abc import Callable
from pathlib import Path
from sqlalchemy.engine import make_url
from . import backup_restore, backup_service
from .backup_s3 import ObjectMissing, S3Target, get_file, put_file
SCRATCH_SUFFIX = "_restore_test"
def counts_key(dump_key: str) -> str:
"""Where a dump's row counts sit in the bucket, beside the dump."""
return dump_key.removesuffix(".dump") + ".counts.json"
def store_counts(target: S3Target, dump_key: str, counts: dict) -> None:
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "counts.json"
path.write_text(json.dumps(counts))
put_file(target, counts_key(dump_key), path, content_type="application/json")
def fetch_counts(target: S3Target, dump_key: str) -> dict | None:
"""The counts recorded with a dump in the bucket, or None for a dump that
has none."""
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "counts.json"
try:
get_file(target, counts_key(dump_key), path)
except ObjectMissing:
return None
return json.loads(path.read_text())
def local_counts(manifest_path: str | None) -> dict | None:
"""The counts in a dump's manifest file on this server, or None."""
try:
return json.loads(Path(manifest_path).read_text()).get("counts")
except (TypeError, OSError, ValueError):
return None
def scratch_name(db_url: str) -> str:
"""The temporary database's name: the live one's, plus SCRATCH_SUFFIX."""
return f"{make_url(db_url).database}{SCRATCH_SUFFIX}"
def scratch_url(db_url: str) -> str:
return make_url(db_url).set(database=scratch_name(db_url)).render_as_string(
hide_password=False)
def _on_server(db_url: str, statement) -> None:
"""Run a CREATE or DROP DATABASE, which can't run inside a transaction."""
import psycopg
with psycopg.connect(backup_service._libpq_url(db_url), autocommit=True) as conn:
conn.execute(statement)
def drop_scratch(db_url: str) -> None:
"""Drop the temporary database if it is there, ending any connection to it."""
from psycopg import sql
_on_server(db_url, sql.SQL("DROP DATABASE IF EXISTS {} WITH (FORCE)").format(
sql.Identifier(scratch_name(db_url))))
def _create_scratch(db_url: str) -> None:
from psycopg import sql
_on_server(db_url, sql.SQL("CREATE DATABASE {} TEMPLATE template0").format(
sql.Identifier(scratch_name(db_url))))
def trial_restore(*, db_url: str, dump_path: Path, recorded: dict | None,
note: Callable[[str], None] = lambda phase: None) -> dict:
"""Load the dump at `dump_path` into the temporary database, compare it,
bring it up to this version, and drop it. `note` is told each phase as it
starts: "loading", "comparing", "upgrading". Raises RestoreFailed (or
S3Error from the upgrade) when the dump doesn't load or can't be brought
up; the temporary database is dropped either way."""
scratch = scratch_url(db_url)
if make_url(scratch).database == make_url(db_url).database:
raise ValueError("The test database would be the live one.")
drop_scratch(db_url) # one left by a test cut off by a restart
_create_scratch(db_url)
try:
note("loading")
backup_service.restore_db(db_url=scratch, sql_path=dump_path)
note("comparing")
restored = backup_service.read_counts(scratch)
live = backup_service.read_counts(db_url)
note("upgrading")
backup_restore.migrate(db_name=scratch_name(db_url))
upgraded = backup_service.read_counts(scratch)["alembic"]
finally:
drop_scratch(db_url)
return {**compare(recorded, restored, live),
"alembic_dump": restored["alembic"], "alembic_now": upgraded}
def compare(recorded: dict | None, restored: dict, live: dict) -> dict:
"""Each table's rows in the dump (as recorded), in the restored copy, and
in the live database. With counts recorded, every table must come back
with exactly its count; `mismatched` names those that didn't."""
rec = (recorded or {}).get("tables")
names = sorted(set(restored["tables"]) | set(rec or {}) | set(live["tables"]))
tables = [
{"table": n, "dump": rec.get(n) if rec is not None else None,
"restored": restored["tables"].get(n), "live": live["tables"].get(n)}
for n in names
]
mismatched = [t["table"] for t in tables
if rec is not None and t["table"] in rec and t["restored"] != t["dump"]]
return {
"counted": rec is not None,
"tables": tables,
"mismatched": mismatched,
"rows_restored": sum(v for v in restored["tables"].values()),
}
def summary(result: dict) -> str:
"""One sentence on how a test went."""
n = len([t for t in result["tables"] if t["restored"] is not None])
rows = f"{result['rows_restored']:,}"
if result["mismatched"]:
shown = ", ".join(
f"{t['table']} ({t['dump']:,} in the dump, "
+ ("table missing)" if t["restored"] is None else f"{t['restored']:,} restored)")
for t in result["tables"] if t["table"] in result["mismatched"][:5]
)
more = len(result["mismatched"]) - 5
return (f"{len(result['mismatched'])} table(s) came back different: {shown}"
+ (f", and {more} more" if more > 0 else "") + ".")
if result["counted"]:
return f"All {n} tables came back with the {rows} rows the dump recorded."
return (f"The dump loaded cleanly: {n} tables, {rows} rows. It was taken before "
"dumps recorded their counts, so it is shown beside the live database instead.")
+83 -35
View File
@@ -1044,45 +1044,75 @@ def cancel_audit_run(session: Session, *, audit_id: int) -> None:
_ARCHIVE_EXT_FOR_FORMAT = {"zip": ".zip", "rar": ".rar", "7z": ".7z"}
def _stage_archive(archive_path: Path, staged: Path) -> None:
"""Put the stored archive at `staged` for the importer to read: a symlink,
so a multi-gigabyte archive isn't copied, or a copy where links aren't
possible."""
import shutil
staged.unlink(missing_ok=True)
try:
staged.symlink_to(archive_path)
except OSError:
shutil.copy2(archive_path, staged)
def _archive_folder_name(original_filename: str | None, attachment_id: int) -> str:
"""The library folder a post-less archive's members land in: the archive's
own name without its extension, made safe for a path."""
stem = Path(original_filename or "").stem.strip().strip(".")
stem = "".join(c if c not in '/\\:*?"<>|' else "_" for c in stem)[:120].strip()
return stem or f"archive-{attachment_id}"
def _reextract_archive_to_post(
importer, archive_path: Path, post, source_row, artist, images_root: Path,
*, folder: str = "",
) -> list[int]:
"""Extract one stored archive and link its members to `post`.
"""Extract one stored archive and import its members.
The stored attachment has no adjacent sidecar (it lives in the sha-addressed
attachment store). Stage a copy + a reconstructed sidecar UNDER the artist's
library dir (`images_root/<slug>/<platform>/<post>/`) — the importer
re-derives the artist from the path AND copies members relative to it, so the
members land in the real library and resolve to the right artist — then re-run
`attach_in_place`: the archive extracts and `find_or_create_post` re-attaches
the members to the SAME Post (source_id + external_post_id). Removes only the
staged archive + sidecar afterward; the imported member files stay. Returns
the new member image ids.
attachment store), so the archive is staged under the artist's library dir
and `attach_in_place` re-run on it: the importer derives the artist from the
path and copies members relative to it, so they land in the real library.
- With a `post`, the stage is `images_root/<slug>/<platform>/<post>/` beside
a reconstructed sidecar, so `find_or_create_post` re-attaches the members
to that same Post (source_id + external_post_id).
- Without one (a filesystem import that had no sidecar), the stage is
`images_root/<slug>/<folder>/` with no sidecar, and the members belong to
the artist alone, as a filesystem import of the archive would have left
them.
Only the staged archive and sidecar are removed afterward; the imported
member files stay. Returns the new member image ids.
"""
import json
import shutil
from .archive_extractor import detect_archive_format
fmt = detect_archive_format(archive_path)
ext = _ARCHIVE_EXT_FOR_FORMAT.get(fmt or "", ".zip")
platform = source_row.platform if source_row is not None else "imported"
sidecar = {
"category": source_row.platform if source_row is not None
else (post.raw_metadata or {}).get("category"),
"id": post.external_post_id,
"title": post.post_title or "",
"content": post.description or "",
"published_at": post.post_date.isoformat() if post.post_date else None,
"url": post.post_url,
}
work = images_root / artist.slug / platform / str(post.external_post_id)
if post is not None:
platform = source_row.platform if source_row is not None else "imported"
work = images_root / artist.slug / platform / str(post.external_post_id)
else:
work = images_root / artist.slug / folder
work.mkdir(parents=True, exist_ok=True)
staged = work / f"archive{ext}" # clean ext → is_archive + find_sidecar
sidecar_path = staged.with_suffix(".json")
try:
shutil.copy2(archive_path, staged)
sidecar_path.write_text(json.dumps(sidecar))
_stage_archive(archive_path, staged)
if post is not None:
sidecar_path.write_text(json.dumps({
"category": source_row.platform if source_row is not None
else (post.raw_metadata or {}).get("category"),
"id": post.external_post_id,
"title": post.post_title or "",
"content": post.description or "",
"published_at": post.post_date.isoformat() if post.post_date else None,
"url": post.post_url,
}))
res = importer.attach_in_place(staged, artist=artist, source=source_row)
return list(res.member_image_ids or [])
finally:
@@ -1124,8 +1154,9 @@ def reextract_archive_attachments(
summary = {
"scanned": 0, "archives": 0, "members_imported": 0,
"posts_touched": 0, "skipped_no_post": 0, "skipped_no_artist": 0,
"errors": 0, "partial": False, "resume_after_id": after_id,
"posts_touched": 0, "without_post": 0, "already_extracted": 0,
"skipped_no_artist": 0, "errors": 0, "partial": False,
"resume_after_id": after_id,
}
settings = ImportSettings.load_sync(session)
importer = Importer(
@@ -1150,15 +1181,17 @@ def reextract_archive_attachments(
except OSError:
continue
summary["archives"] += 1
if att.post_id is None:
summary["skipped_no_post"] += 1
continue
post = session.get(Post, att.post_id)
if post is None:
summary["skipped_no_post"] += 1
# Members already recorded as coming from this archive: done before.
if session.execute(
select(ImageProvenance.id)
.where(ImageProvenance.from_attachment_id == att.id)
.limit(1)
).first() is not None:
summary["already_extracted"] += 1
continue
post = session.get(Post, att.post_id) if att.post_id else None
artist = session.get(Artist, att.artist_id) if att.artist_id else None
if artist is None and post.artist_id:
if artist is None and post is not None and post.artist_id:
artist = session.get(Artist, post.artist_id)
if artist is None or not artist.slug:
# The importer re-derives the artist from the staged path, so we need
@@ -1166,20 +1199,35 @@ def reextract_archive_attachments(
# subscription posts; skip rather than orphan the members.)
summary["skipped_no_artist"] += 1
continue
source_row = session.get(Source, post.source_id) if post.source_id else None
source_row = (
session.get(Source, post.source_id)
if post is not None and post.source_id else None
)
if post is None:
summary["without_post"] += 1
# Logged on the way in as well as out, so an archive that stalls a
# chunk is named in the log even if the worker dies holding it.
size_mb = (att.size_bytes or 0) / 1e6
log.info("re-extract: attachment %s (%.0f MB) starting", att.id, size_mb)
archive_start = time.monotonic()
try:
ids = _reextract_archive_to_post(
importer, stored, post, source_row, artist, images_root,
folder=_archive_folder_name(att.original_filename, att.id),
)
session.commit()
except Exception as exc: # one bad archive must not strand the rest
session.rollback()
summary["errors"] += 1
log.warning("re-extract failed for attachment %s: %s", att.id, exc)
log.warning("re-extract failed for attachment %s after %.1fs: %s",
att.id, time.monotonic() - archive_start, exc)
continue
log.info("re-extract: attachment %s done in %.1fs, %d new image(s)",
att.id, time.monotonic() - archive_start, len(ids))
if ids:
summary["members_imported"] += len(ids)
summary["posts_touched"] += 1
if post is not None:
summary["posts_touched"] += 1
enqueue_ids.extend(ids)
# Time-box the chunk. resume_after_id already points at this attachment,
+1 -1
View File
@@ -21,7 +21,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import Source
from .gallery_dl import ErrorType
from .download_result import ErrorType
async def get_or_create[T](
+1 -24
View File
@@ -49,13 +49,13 @@ import logging
import re
import time
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import date
from urllib.parse import unquote
import requests
from .native_ingest_common import (
MediaItem,
NativeAuthError,
NativeDriftError,
NativeIngestError,
@@ -256,29 +256,6 @@ def rank_posters(server_name: str | None, owner_id, posters: list[dict]) -> list
return ranked
@dataclass
class MediaItem:
"""One file of a Discord message. `filename`/`extension` are gallery-dl's
split of the URL; `num` is its 1-based position across the message's files,
which is what names it on disk.
`media_id` is what the seen-ledger keys on, and it is deliberately NOT
`num`: an edit that removes a file renumbers the ones after it, and a
positional key would then call a different file seen. It is the
attachment's id, or for an embed (which has none) a hash of its URL path —
the query string is a signature that changes on every fetch. `filehash` is
always None; nothing in a signed CDN URL is a content hash."""
url: str
filename: str
extension: str
kind: str
post_id: str
num: int
media_id: str
filehash: str | None = None
class DiscordClient:
"""Synchronous Discord API v10 read client for one user token."""
+12 -65
View File
@@ -30,10 +30,7 @@ PURE: no DB; the seen-skip is an injected predicate.
from __future__ import annotations
import json
import logging
import re
import time
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
@@ -42,13 +39,11 @@ import requests
from .discord_client import firefox_user_agent, message_text
from .native_ingest_common import (
BaseNativeDownloader,
MediaOutcome,
MediaItem,
PostRecordOutcome,
make_session,
)
log = logging.getLogger(__name__)
PLATFORM = "discord"
_CONTROL = re.compile("[\x00-\x1f\x7f]")
# gallery-dl falls back to the response's type for a URL with no extension;
@@ -111,67 +106,19 @@ class DiscordDownloader(BaseNativeDownloader):
}),
)
def download_post(
self,
post: dict,
media_items: list,
artist_slug: str,
*,
is_seen: Callable[[object], bool] = lambda m: False,
should_stop: Callable[[], bool] = lambda: False,
recapture: bool = False,
) -> list[MediaOutcome]:
"""Every file of one message; per-file outcomes, one failure isolated."""
folder = channel_dir(self.images_root, artist_slug, post)
outcomes: list[MediaOutcome] = []
for media in media_items:
if should_stop():
break
try:
outcomes.append(self._download_one(
post, media, folder, artist_slug, is_seen, recapture=recapture,
))
except Exception as exc: # resilient: isolate one item's failure
log.warning(
"Discord media failed (message %s, file %d): %s",
post.get("id"), media.num, exc,
)
outcomes.append(
MediaOutcome(media=media, status="error", path=None, error=str(exc))
)
return outcomes
# The download loop is BaseNativeDownloader's; these seams give it
# gallery-dl's Discord layout.
def _download_one(
self,
post: dict,
media,
folder: Path,
artist_slug: str,
is_seen: Callable[[object], bool],
*,
recapture: bool = False,
) -> MediaOutcome:
seen = is_seen(media)
if seen and not recapture:
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
stem = media_stem(post, media)
path = folder / f"{stem}.{media.extension or _NO_EXTENSION}"
if path.exists(): # tier-2: gallery-dl (or an earlier walk) wrote it
return MediaOutcome(media=media, status="skipped_disk", path=path, error=None)
if seen: # recapture never re-fetches a seen file that is gone
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
def _post_folder(self, post: dict, artist_slug: str) -> Path:
return channel_dir(self.images_root, artist_slug, post)
folder.mkdir(parents=True, exist_ok=True)
if self._rate_limit > 0:
time.sleep(self._rate_limit)
out = self._fetch_get(media.url, path)
reason, quarantined = self._validate_path(out, artist_slug, media.url)
if reason is not None:
return MediaOutcome(media=media, status="quarantined", path=quarantined, error=reason)
sidecar = {"category": PLATFORM, "message_id": str(post.get("id") or "")}
sidecar["source_url"] = media.url
(folder / f"{stem}.json").write_text(json.dumps(sidecar, indent=2))
return MediaOutcome(media=media, status="downloaded", path=out, error=None)
def _media_name(self, post: dict, media: MediaItem, index: int) -> str:
return f"{media_stem(post, media)}.{media.extension or _NO_EXTENSION}"
def _media_sidecar(self, post: dict, media: MediaItem) -> dict:
# `message_id`, never `id`: `id` outranks it in the post-id chain.
return {"category": PLATFORM, "message_id": str(post.get("id") or ""),
"source_url": media.url}
def write_post_record(
self, post: dict, artist_slug: str, *, revisit: bool = False,
+3 -8
View File
@@ -32,23 +32,18 @@ from pathlib import Path
from sqlalchemy import select, update
from ..models import DiscordFailedMedia, DiscordSeenMedia, Source
from .discord_client import DiscordAPIError, DiscordClient, MediaItem
from .discord_client import DiscordAPIError, DiscordClient
from .discord_downloader import DiscordDownloader
from .ingest_core import Ingester
from .native_ingest_common import ledger_key
log = logging.getLogger(__name__)
_LEDGER_KEY_MAX = 128
# `config_overrides` key: the people whose messages this source takes (user
# ids, usernames or display names). Absent or empty takes everyone's.
AUTHORS_KEY = "discord_authors"
def _ledger_key(media: MediaItem) -> str:
"""`<message_id>:<media_id>` — stable across edits (see MediaItem)."""
return f"{media.post_id}:{media.media_id}"[:_LEDGER_KEY_MAX]
class DiscordIngester(Ingester):
"""Walk a Discord source's channels, download unseen files, return a
`DownloadResult`. `client` / `downloader` are injectable for tests."""
@@ -80,7 +75,7 @@ class DiscordIngester(Ingester):
failed_model=DiscordFailedMedia,
seen_constraint="uq_discord_seen_media_source_id",
failed_constraint="uq_discord_failed_media_source_id",
ledger_key=_ledger_key,
ledger_key=ledger_key,
platform="discord",
error_base=DiscordAPIError,
drift_label="Discord API",
+1 -1
View File
@@ -24,7 +24,7 @@ import asyncio
from pathlib import Path
from .discord_ingester import DiscordIngester
from .gallery_dl import DownloadResult, ErrorType
from .download_result import DownloadResult, ErrorType
from .ingest_core import DEFAULT_REVISIT_DAYS
from .patreon_ingester import PatreonIngester
from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
+222
View File
@@ -0,0 +1,222 @@
"""What a download walk reports, whichever backend ran it.
The native ingesters (Patreon, SubscribeStar, Discord) and the gallery-dl
subprocess (HentaiFoundry) both return a `DownloadResult`, classify failures
with the same `ErrorType`s and build `run_stats` through `make_run_stats`, so
phase 3, the source's status chip and the Logs UI read one shape from either.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import StrEnum
class ErrorType(StrEnum):
AUTH_ERROR = "auth_error"
RATE_LIMITED = "rate_limited"
NOT_FOUND = "not_found"
ACCESS_DENIED = "access_denied"
NETWORK_ERROR = "network_error"
NO_NEW_CONTENT = "no_new_content"
TIER_LIMITED = "tier_limited"
TIMEOUT = "timeout"
HTTP_ERROR = "http_error"
UNSUPPORTED_URL = "unsupported_url"
VALIDATION_FAILED = "validation_failed"
# Native Patreon ingester only (plan #697): a response parsed as JSON but
# didn't match the JSON:API shape the ingester depends on (missing `data`,
# media lacking `file_name`/`url`, etc.). Distinct from AUTH_ERROR — the fix
# is updating the ingester's field-set/parser, not rotating credentials. The
# contract test guards against silently shipping this.
API_DRIFT = "api_drift"
# Run made real progress (downloaded ≥1 file) but did not finish in the
# subprocess budget. Distinct from UNKNOWN_ERROR — the downstream status
# mapping classifies this as "ok" because the next tick continues.
PARTIAL = "partial"
UNKNOWN_ERROR = "unknown_error"
@dataclass
class DownloadResult:
success: bool
url: str
artist_slug: str
platform: str
files_downloaded: int = 0
files_quarantined: int = 0
quarantined_paths: list[str] = field(default_factory=list)
written_paths: list[str] = field(default_factory=list)
# Native ingester only: post-only sidecar paths for posts that have NO
# downloadable media (pure-text posts), so the importer can still upsert the
# Post + its body. Empty on the gallery-dl path. Phase 3 imports these via
# Importer.upsert_post_record (keyed on external_post_id → updates, never
# doubles, the same Post a media import would create).
post_record_paths: list[str] = field(default_factory=list)
# Native ingester, recapture mode only (#830): (on-disk path, CDN source_url)
# pairs for already-present media whose ImageRecord.source_filehash should be
# backfilled (inline-image localization) WITHOUT re-download or unlink. Empty
# on the gallery-dl path and outside recapture.
relink_source_paths: list[tuple[str, str, str]] = field(default_factory=list)
# Native ingester only: one entry per `written_paths` entry, the backfill
# cursor of the page that file came from. Phase 3 resumes the next chunk
# there when it leaves files unimported at its deadline. Empty on gallery-dl.
written_cursors: list[str | None] = field(default_factory=list)
stdout: str = ""
stderr: str = ""
return_code: int = 0
error_type: ErrorType | None = None
error_message: str | None = None
duration_seconds: float = 0.0
started_at: str | None = None
completed_at: str | None = None
# Plan #704 — structured fields the NATIVE ingester populates directly (None
# on the gallery-dl path, which keeps the regex-over-stdout route). When set,
# phase 3 reads these instead of scraping the text it would otherwise have to
# reconstruct: `run_stats` mirrors _compute_run_stats' shape; `cursor` is the
# backfill checkpoint the ingester knows exactly (no parse_last_cursor).
run_stats: dict | None = None
cursor: str | None = None
posts_processed: int = 0
# Plan #708 B1 — the server's Retry-After seconds on a RATE_LIMITED result, so
# the platform cooldown matches the hint instead of a flat default. None when
# unknown (no header, or not a rate-limit failure).
retry_after_seconds: float | None = None
# Native ingester only: marks this walk's fetched media seen in its ledger.
# Phase 3 calls it AFTER the import loop, never before — a file marked seen
# but not yet imported is invisible to every later walk, so a run killed in
# between orphaned it for good (TamadaHeijun's 12PCG post lost 7 of 13
# images to a stranded run, 2026-09-24). Unmarked, the next walk finds the
# file on disk with no ImageRecord and imports it. Takes `skip_paths`: the
# written paths phase 3 did not import, which stay unmarked for the same
# reason. None on gallery-dl.
mark_seen_after_import: Callable[..., None] | None = None
def extract_errors_warnings(stderr: str) -> str:
"""Keep only the `[error]`/`[warning]` lines from a captured stderr blob.
A generic download-log helper (module-level so the native-ingester result
path can shape its logs without reaching through a GalleryDLService instance).
"""
if not stderr:
return ""
kept = [
line for line in stderr.splitlines()
if "][error]" in line.lower() or "][warning]" in line.lower()
]
return "\n".join(kept)
def truncate_log(text: str, max_bytes: int = 500_000) -> str:
"""Cap a captured log to `max_bytes`, eliding the middle (head + tail kept)."""
if not text:
return text
encoded = text.encode("utf-8")
if len(encoded) <= max_bytes:
return text
half = max_bytes // 2
head = encoded[:half].decode("utf-8", errors="ignore")
tail = encoded[-half:].decode("utf-8", errors="ignore")
head_lines = head.count("\n")
tail_lines = tail.count("\n")
total_lines = text.count("\n")
elided = max(0, total_lines - head_lines - tail_lines)
marker = f"\n\n... [{elided} lines elided, {len(encoded) - max_bytes} bytes] ...\n\n"
return head + marker + tail
def make_run_stats(
*,
exit_code: int = 0,
downloaded_count: int = 0,
skipped_count: int = 0,
per_item_failures: int = 0,
warning_count: int = 0,
tier_gated_count: int = 0,
quarantined_count: int = 0,
dead_lettered_count: int = 0,
) -> dict:
"""The canonical `run_stats` dict shape, in ONE place.
Both result producers — gallery-dl's `_compute_run_stats` (log scrape) and the
native ingester's per-outcome tally (ingest_core) — build through this so the
key set can't drift between backends. Phase 3 + the Logs UI read these keys.
"""
return {
"exit_code": exit_code,
"downloaded_count": downloaded_count,
"skipped_count": skipped_count,
"per_item_failures": per_item_failures,
"warning_count": warning_count,
"tier_gated_count": tier_gated_count,
"quarantined_count": quarantined_count,
"dead_lettered_count": dead_lettered_count,
}
# --- tier-gated classification, shared by BOTH backends ---------------------
#
# These three live together because the native ingester and the gallery-dl
# subprocess must reach the same verdict from the same number. They did not:
# gallery-dl classified TIER_LIMITED while ingest_core counted gated posts and
# threw the count away, so the platforms FC owns reported a paywalled creator as
# a silent one (#874 follow-up). One predicate, spread into both, rather than
# the condition re-derived per backend.
def classify_tier_gated(tier_gated_count: int) -> ErrorType | None:
"""TIER_LIMITED when a walk saw tier-gated posts and nothing else failed.
Deliberately NOT conditioned on `downloaded == 0`. A creator whose top-tier
posts we cannot see is tier-limited even in a week we did get their cheaper
ones — the fact the operator needs ("there is content here you are not
paying for") is true either way. gallery-dl has classified it this way since
the paywall-as-"needs attention" complaint (see `_categorize_error`), and the
native path now matches rather than inventing a stricter rule.
Callers must apply this only AFTER the real error categories (auth, rate
limit, drift, …) have had their turn; tier-gating is the weakest signal and
must never mask a genuine failure.
"""
return ErrorType.TIER_LIMITED if tier_gated_count else None
def tier_gated_message(count: int) -> str:
"""The one wording for the tier-gated verdict, so the two backends can't
describe the same state differently in the Logs UI."""
return (
f"Subscription tier does not grant access to "
f"{count} post{'s' if count != 1 else ''}"
)
# `Source.error_type` doubles as the failure-class chip, and a status of "ok"
# CLEARS it (alembic 0032). TIER_LIMITED breaks that assumption: it rides an
# otherwise-successful run, so without an exemption the chip is wiped the moment
# it is set and `FailingSourcesCard`'s `tier_limited` palette entry can never
# render. Informational classes are the exemption — they describe the source,
# not a failure of the run.
INFORMATIONAL_ERROR_TYPES = frozenset({ErrorType.TIER_LIMITED.value})
def is_informational(error_type) -> bool:
"""True for a class that reports a state rather than a failure. Accepts an
ErrorType or the plain string persisted on Source.error_type."""
return error_type is not None and str(error_type) in INFORMATIONAL_ERROR_TYPES
def walk_completed(result: DownloadResult) -> bool:
"""Did this walk reach the bottom cleanly?
The backfill lifecycle's completion test. An informational error_type still
counts as complete: a fully-paywalled creator's backfill DID finish, and
treating it as unfinished re-walks the same wall until the stall counter
trips — the creator we can see least becoming the one we fetch most.
"""
return (
result.success
and result.return_code == 0
and (result.error_type is None or is_informational(result.error_type))
)
+34 -38
View File
@@ -3,8 +3,9 @@
Three-phase pipeline:
Phase 1 — DB setup (brief session): load source, mark event running,
fetch credentials + settings.
Phase 2 — Execute (no DB connection): call GalleryDLService; on
Patreon campaign-ID failure, resolve vanity and retry once.
Phase 2 — Execute (no DB connection): run the source's backend (a native
ingester, or gallery-dl for HentaiFoundry); on a Patreon
campaign-ID failure, resolve the vanity and retry once.
Phase 3 — Import + persist (fresh session): attach_in_place each
written file; clean up duplicates; persist rich metadata.
"""
@@ -26,19 +27,21 @@ from sqlalchemy.orm import joinedload
from ..models import Artist, DownloadEvent, Source
from .credential_service import CredentialService
from .download_backends import run_download, uses_native_ingester
from .gallery_dl import (
BACKFILL_CHUNK_SECONDS,
BACKFILL_SKIP_VALUE,
TICK_SKIP_VALUE,
from .download_result import (
DownloadResult,
ErrorType,
GalleryDLService,
SourceConfig,
extract_errors_warnings,
is_informational,
truncate_log,
walk_completed,
)
from .gallery_dl import (
BACKFILL_CHUNK_SECONDS,
BACKFILL_SKIP_VALUE,
TICK_SKIP_VALUE,
GalleryDLService,
SourceConfig,
)
from .importer import Importer
from .ingest_core import DEFAULT_REVISIT_DAYS, PHASE3_DEADLINE_SECONDS
from .platforms import auth_type_for
@@ -47,6 +50,16 @@ from .scheduler_service import set_platform_cooldown
log = logging.getLogger(__name__)
def credential_proven(ctx: dict, dl_result) -> bool:
"""Did this walk prove the platform's stored credential still works?
A clean walk made with a credential is the evidence the Verify button looks
for. A walk with any error type proves nothing, tier-limited included:
content hidden behind a tier is also what an expired session looks like."""
used = bool(ctx.get("cookies_path") or ctx.get("auth_token"))
return used and bool(dl_result.success) and dl_result.error_type is None
class DownloadService:
"""Async orchestrator. The Celery task runs `asyncio.run(svc.download_source(N))`.
@@ -369,11 +382,8 @@ class DownloadService:
if do_embed:
embed_image.delay(img_id)
elif result.status == "attached":
# Non-media or extracted archive captured as PostAttachment
# (FC-2d-iii). The canonical copy lives in the attachments
# store; the original download path is now redundant —
# mirror duplicate_hash cleanup so we don't keep two copies.
# Operator-flagged 2026-06-02 (Lustria OST zip).
# Non-media, or an archive with no new images, captured as a
# PostAttachment; the attachments store holds the copy.
import_summary["attached"] += 1
# An archive captured WITHOUT extracting any image carries a
# reason on result.error — record it so the event explains the
@@ -390,35 +400,13 @@ class DownloadService:
bytes_downloaded += path.stat().st_size # noqa: ASYNC240
except OSError:
pass
try:
path.unlink(missing_ok=True) # noqa: ASYNC240
except OSError:
pass
elif result.status == "skipped" and result.skip_reason and result.skip_reason.value in (
"duplicate_hash", "duplicate_phash",
):
import_summary["skipped"] += 1
try:
path.unlink(missing_ok=True) # noqa: ASYNC240
except OSError:
pass
elif result.status == "skipped":
# Soft skip (too_small, too_transparent, invalid_image) —
# the file just didn't qualify, not a download/ingest
# failure. Don't flag the run as error; the file stays
# on disk for operator inspection.
# A duplicate, or a file the import filters rejected. Neither
# is a download failure, so the run is not flagged.
import_summary["skipped"] += 1
elif result.status == "failed":
# Hard failure (today only: archive probe crash/timeout).
# The original archive sits in /images/ as an orphan; the
# filesystem scanner would re-import and re-crash on the
# same file, so delete the source file and surface the
# error in import_summary. Audit 2026-06-02.
# Hard failure: an archive probe crashed or timed out.
import_summary["errors"] += 1
try:
path.unlink(missing_ok=True) # noqa: ASYNC240
except OSError:
pass
elif result.status == "refreshed":
# Currently unreachable from attach_in_place (the download
# path never runs in deep=True mode), but the importer's
@@ -427,6 +415,10 @@ class DownloadService:
import_summary["attached"] += 1
else:
import_summary["errors"] += 1
# Remove the download when the library no longer needs it at this
# path: an attachment or archive now in the store, a duplicate, a
# rejected file. One policy for every download path.
self.importer.discard_after_attach(path, result)
# Post-only records: media-less posts (pure-text) the native ingester
# captured so the artist archive is complete. Upsert each (keyed on
@@ -557,6 +549,10 @@ class DownloadService:
)
await self._apply_backfill_lifecycle(ctx, dl_result)
await self.async_session.commit()
if credential_proven(ctx, dl_result):
# The credentials page then shows when the account last worked,
# not only when someone last pressed Verify.
await self.cred_service.mark_verified(ctx["platform"])
return event_id
@staticmethod
+10 -2
View File
@@ -241,11 +241,19 @@ def _fetch_gdrive(url: str, dest_dir: Path, *, read_timeout: float,
def _fetch_mega(url: str, dest_dir: Path, *, read_timeout: float,
total_timeout: float,
should_stop: Callable[[], bool]) -> FetchResult:
before = set(dest_dir.iterdir()) if dest_dir.exists() else set()
# A folder link keeps its subfolders, so files are found at any depth.
# Dotfiles are megatools' `.megatmp.*` staging names.
def _complete_files() -> set[Path]:
return {
p for p in dest_dir.rglob("*")
if p.is_file() and not p.name.startswith(".")
} if dest_dir.exists() else set()
before = _complete_files()
# megatools is a subprocess: its timeout IS a total wall-clock cap (the read
# timeout has no analogue here), so the total budget applies directly.
_run_mega_get(url, str(dest_dir), timeout=total_timeout)
new = [p for p in dest_dir.iterdir() if p not in before and p.is_file()]
new = sorted(_complete_files() - before)
if not new:
return FetchResult(error="mega-get wrote no new file")
return FetchResult(files=new, bytes=sum(p.stat().st_size for p in new))
+4 -3
View File
@@ -21,9 +21,9 @@ log = logging.getLogger(__name__)
def refetch_links_for_post(session: Session, post_id: int) -> dict:
"""Reset every settled ExternalLink on a post to pending (fresh attempt
budget) and dispatch its fetch. In-flight ('downloading') links are left
alone. Commits. Returns {links_total, links_reset}."""
"""Reset every settled ExternalLink on a post to pending (fresh attempt and
recovery budgets) and dispatch its fetch. In-flight ('downloading') links
are left alone. Commits. Returns {links_total, links_reset}."""
links = session.execute(
select(ExternalLink).where(ExternalLink.post_id == post_id)
).scalars().all()
@@ -33,6 +33,7 @@ def refetch_links_for_post(session: Session, post_id: int) -> dict:
continue
link.status = "pending"
link.attempts = 0
link.recoveries = 0
link.last_error = None
link.completed_at = None
reset_ids.append(link.id)
+10 -226
View File
@@ -6,6 +6,9 @@ backend/app/services/gallery_dl.py). FC adaptations:
- Validation uses backend/app/services/file_validator.py
- Config dir under `<images_root>/.gallery-dl/`, not a separate config_path
- PLATFORM_DEFAULTS scoped to the GS-6 platforms registered in FC-3b
The result it returns (`DownloadResult`, `ErrorType`) is shared with the native
ingesters and lives in download_result.
"""
from __future__ import annotations
@@ -17,42 +20,22 @@ import subprocess
import sys
import tempfile
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import StrEnum
from pathlib import Path
from .download_result import (
DownloadResult,
ErrorType,
classify_tier_gated,
make_run_stats,
tier_gated_message,
)
from .file_validator import is_validatable, quarantine_file, validate_file
log = logging.getLogger(__name__)
class ErrorType(StrEnum):
AUTH_ERROR = "auth_error"
RATE_LIMITED = "rate_limited"
NOT_FOUND = "not_found"
ACCESS_DENIED = "access_denied"
NETWORK_ERROR = "network_error"
NO_NEW_CONTENT = "no_new_content"
TIER_LIMITED = "tier_limited"
TIMEOUT = "timeout"
HTTP_ERROR = "http_error"
UNSUPPORTED_URL = "unsupported_url"
VALIDATION_FAILED = "validation_failed"
# Native Patreon ingester only (plan #697): a response parsed as JSON but
# didn't match the JSON:API shape the ingester depends on (missing `data`,
# media lacking `file_name`/`url`, etc.). Distinct from AUTH_ERROR — the fix
# is updating the ingester's field-set/parser, not rotating credentials. The
# contract test guards against silently shipping this.
API_DRIFT = "api_drift"
# Run made real progress (downloaded ≥1 file) but did not finish in the
# subprocess budget. Distinct from UNKNOWN_ERROR — the downstream status
# mapping classifies this as "ok" because the next tick continues.
PARTIAL = "partial"
UNKNOWN_ERROR = "unknown_error"
# Tick mode (routine cron polls): skip ≤20 contiguous already-archived
# items, then exit gallery-dl. Established subscription with zero new
# content exits in ~30s of HEAD requests instead of walking to the bottom
@@ -148,95 +131,6 @@ class SourceConfig:
)
@dataclass
class DownloadResult:
success: bool
url: str
artist_slug: str
platform: str
files_downloaded: int = 0
files_quarantined: int = 0
quarantined_paths: list[str] = field(default_factory=list)
written_paths: list[str] = field(default_factory=list)
# Native ingester only: post-only sidecar paths for posts that have NO
# downloadable media (pure-text posts), so the importer can still upsert the
# Post + its body. Empty on the gallery-dl path. Phase 3 imports these via
# Importer.upsert_post_record (keyed on external_post_id → updates, never
# doubles, the same Post a media import would create).
post_record_paths: list[str] = field(default_factory=list)
# Native ingester, recapture mode only (#830): (on-disk path, CDN source_url)
# pairs for already-present media whose ImageRecord.source_filehash should be
# backfilled (inline-image localization) WITHOUT re-download or unlink. Empty
# on the gallery-dl path and outside recapture.
relink_source_paths: list[tuple[str, str, str]] = field(default_factory=list)
# Native ingester only: one entry per `written_paths` entry, the backfill
# cursor of the page that file came from. Phase 3 resumes the next chunk
# there when it leaves files unimported at its deadline. Empty on gallery-dl.
written_cursors: list[str | None] = field(default_factory=list)
stdout: str = ""
stderr: str = ""
return_code: int = 0
error_type: ErrorType | None = None
error_message: str | None = None
duration_seconds: float = 0.0
started_at: str | None = None
completed_at: str | None = None
# Plan #704 — structured fields the NATIVE ingester populates directly (None
# on the gallery-dl path, which keeps the regex-over-stdout route). When set,
# phase 3 reads these instead of scraping the text it would otherwise have to
# reconstruct: `run_stats` mirrors _compute_run_stats' shape; `cursor` is the
# backfill checkpoint the ingester knows exactly (no parse_last_cursor).
run_stats: dict | None = None
cursor: str | None = None
posts_processed: int = 0
# Plan #708 B1 — the server's Retry-After seconds on a RATE_LIMITED result, so
# the platform cooldown matches the hint instead of a flat default. None when
# unknown (no header, or not a rate-limit failure).
retry_after_seconds: float | None = None
# Native ingester only: marks this walk's fetched media seen in its ledger.
# Phase 3 calls it AFTER the import loop, never before — a file marked seen
# but not yet imported is invisible to every later walk, so a run killed in
# between orphaned it for good (TamadaHeijun's 12PCG post lost 7 of 13
# images to a stranded run, 2026-09-24). Unmarked, the next walk finds the
# file on disk with no ImageRecord and imports it. Takes `skip_paths`: the
# written paths phase 3 did not import, which stay unmarked for the same
# reason. None on gallery-dl.
mark_seen_after_import: Callable[..., None] | None = None
def extract_errors_warnings(stderr: str) -> str:
"""Keep only the `[error]`/`[warning]` lines from a captured stderr blob.
A generic download-log helper (module-level so the native-ingester result
path can shape its logs without reaching through a GalleryDLService instance).
"""
if not stderr:
return ""
kept = [
line for line in stderr.splitlines()
if "][error]" in line.lower() or "][warning]" in line.lower()
]
return "\n".join(kept)
def truncate_log(text: str, max_bytes: int = 500_000) -> str:
"""Cap a captured log to `max_bytes`, eliding the middle (head + tail kept)."""
if not text:
return text
encoded = text.encode("utf-8")
if len(encoded) <= max_bytes:
return text
half = max_bytes // 2
head = encoded[:half].decode("utf-8", errors="ignore")
tail = encoded[-half:].decode("utf-8", errors="ignore")
head_lines = head.count("\n")
tail_lines = tail.count("\n")
total_lines = text.count("\n")
elided = max(0, total_lines - head_lines - tail_lines)
marker = f"\n\n... [{elided} lines elided, {len(encoded) - max_bytes} bytes] ...\n\n"
return head + marker + tail
def _summarize_validation_failures(failures: list[dict]) -> str:
if not failures:
return "Validation failed"
@@ -251,106 +145,6 @@ def _summarize_validation_failures(failures: list[dict]) -> str:
return f"{n} files quarantined ({top_count}× {top_reason}, mixed)"
# (parse_last_cursor was removed in plan #704: the native ingester now carries
# its checkpoint cursor as a structured DownloadResult.cursor field, so there is
# no log text to scrape — and gallery-dl platforms never had a cursor.)
def make_run_stats(
*,
exit_code: int = 0,
downloaded_count: int = 0,
skipped_count: int = 0,
per_item_failures: int = 0,
warning_count: int = 0,
tier_gated_count: int = 0,
quarantined_count: int = 0,
dead_lettered_count: int = 0,
) -> dict:
"""The canonical `run_stats` dict shape, in ONE place.
Both result producers — gallery-dl's `_compute_run_stats` (log scrape) and the
native ingester's per-outcome tally (ingest_core) — build through this so the
key set can't drift between backends. Phase 3 + the Logs UI read these keys.
"""
return {
"exit_code": exit_code,
"downloaded_count": downloaded_count,
"skipped_count": skipped_count,
"per_item_failures": per_item_failures,
"warning_count": warning_count,
"tier_gated_count": tier_gated_count,
"quarantined_count": quarantined_count,
"dead_lettered_count": dead_lettered_count,
}
# --- tier-gated classification, shared by BOTH backends ---------------------
#
# These three live together because the native ingester and the gallery-dl
# subprocess must reach the same verdict from the same number. They did not:
# gallery-dl classified TIER_LIMITED while ingest_core counted gated posts and
# threw the count away, so the platforms FC owns reported a paywalled creator as
# a silent one (#874 follow-up). One predicate, spread into both, rather than
# the condition re-derived per backend.
def classify_tier_gated(tier_gated_count: int) -> ErrorType | None:
"""TIER_LIMITED when a walk saw tier-gated posts and nothing else failed.
Deliberately NOT conditioned on `downloaded == 0`. A creator whose top-tier
posts we cannot see is tier-limited even in a week we did get their cheaper
ones — the fact the operator needs ("there is content here you are not
paying for") is true either way. gallery-dl has classified it this way since
the paywall-as-"needs attention" complaint (see `_categorize_error`), and the
native path now matches rather than inventing a stricter rule.
Callers must apply this only AFTER the real error categories (auth, rate
limit, drift, …) have had their turn; tier-gating is the weakest signal and
must never mask a genuine failure.
"""
return ErrorType.TIER_LIMITED if tier_gated_count else None
def tier_gated_message(count: int) -> str:
"""The one wording for the tier-gated verdict, so the two backends can't
describe the same state differently in the Logs UI."""
return (
f"Subscription tier does not grant access to "
f"{count} post{'s' if count != 1 else ''}"
)
# `Source.error_type` doubles as the failure-class chip, and a status of "ok"
# CLEARS it (alembic 0032). TIER_LIMITED breaks that assumption: it rides an
# otherwise-successful run, so without an exemption the chip is wiped the moment
# it is set and `FailingSourcesCard`'s `tier_limited` palette entry can never
# render. Informational classes are the exemption — they describe the source,
# not a failure of the run.
INFORMATIONAL_ERROR_TYPES = frozenset({ErrorType.TIER_LIMITED.value})
def is_informational(error_type) -> bool:
"""True for a class that reports a state rather than a failure. Accepts an
ErrorType or the plain string persisted on Source.error_type."""
return error_type is not None and str(error_type) in INFORMATIONAL_ERROR_TYPES
def walk_completed(result: DownloadResult) -> bool:
"""Did this walk reach the bottom cleanly?
The backfill lifecycle's completion test. An informational error_type still
counts as complete: a fully-paywalled creator's backfill DID finish, and
treating it as unfinished re-walks the same wall until the stall counter
trips — the creator we can see least becoming the one we fetch most.
"""
return (
result.success
and result.return_code == 0
and (result.error_type is None or is_informational(result.error_type))
)
class GalleryDLService:
"""Service for executing gallery-dl downloads."""
@@ -728,16 +522,6 @@ class GalleryDLService:
tier_gated_count=tier_gated_count,
)
@staticmethod
def _extract_errors_warnings(stderr: str) -> str:
# Thin delegator to the module-level helper (kept for existing callers).
return extract_errors_warnings(stderr)
@staticmethod
def _truncate_log(text: str, max_bytes: int = 500_000) -> str:
# Thin delegator to the module-level helper (kept for existing callers).
return truncate_log(text, max_bytes)
async def download(
self,
url: str,
+259 -154
View File
@@ -16,6 +16,7 @@ from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from celery.exceptions import SoftTimeLimitExceeded
from PIL import Image
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
@@ -43,7 +44,8 @@ from ..utils.paths import (
from ..utils.phash import compute_phash, find_similar, fingerprint_path, fingerprints_match
from ..utils.sidecar import find_sidecar, parse_sidecar
from ..utils.slug import slugify
from .archive_extractor import extract_archive, is_archive
from ..utils.staging import is_temp_staging_file
from .archive_extractor import extract_archive, is_archive, software_marker
from .attachment_store import AttachmentStore
from .audits import single_color
from .link_extract import extract_external_links
@@ -115,6 +117,21 @@ def is_video(path: Path) -> bool:
return path.suffix.lower() in VIDEO_EXTS
def _not_content(path: Path) -> ImportResult | None:
"""The skip result for a file that is never library content: a sidecar
`.json` (metadata) or a downloader's staging file (an unfinished download,
which becomes content only under its final name). None for anything else."""
if path.suffix.lower() == ".json":
error = "sidecar json is metadata, not content"
elif is_temp_staging_file(path):
error = "unfinished download (staging file), not content"
else:
return None
return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image, error=error,
)
def _safe_ext(path: Path) -> str:
"""Conservatively extract a file extension for PostAttachment.ext
(varchar(32)). Thin wrapper over the shared `utils.paths.safe_ext` (kept for
@@ -291,6 +308,44 @@ class Importer:
sp.rollback()
return self.session.execute(stmt).scalar_one()
def _store_new_record(self, record: ImageRecord) -> ImageRecord | None:
"""Insert `record`, or return the row that already holds its bytes.
The sha256 lookup before an insert can lose a race: two copies of one
picture in different folders, imported side by side by two workers,
both find no row, and the unique index refuses the second insert. Only
that insert is undone (a savepoint), so the session stays usable and
the caller files its file as a duplicate of the winner. None means
`record` was stored."""
sp = self.session.begin_nested()
try:
self.session.add(record)
self.session.flush()
sp.commit()
return None
except IntegrityError:
sp.rollback()
winner = self.session.execute(
select(ImageRecord).where(ImageRecord.sha256 == record.sha256)
).scalar_one_or_none()
if winner is None:
raise
return winner
def _file_as_duplicate(
self, existing: ImageRecord, path: Path, artist: Artist | None,
explicit_source: Source | None,
) -> ImportResult:
"""A byte-identical copy of `existing`: link the copy's post to the
image (enrich-on-duplicate), so a picture posted twice shows on both
posts, and report the skip."""
self._apply_sidecar(existing, path, artist, explicit_source=explicit_source)
self.session.commit()
return ImportResult(
status="skipped", skip_reason=SkipReason.duplicate_hash,
image_id=existing.id, error="sha256 already present",
)
def _find_or_create_source(
self, *, artist_id: int, platform: str, url: str,
) -> Source:
@@ -410,11 +465,8 @@ class Importer:
media members (one Post via the archive-adjacent sidecar) and
preserve the archive itself. Other non-media → PostAttachment.
Nothing is skipped for being non-media (FC-2d-iii)."""
if source.suffix.lower() == ".json":
return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image,
error="sidecar json is metadata, not content",
)
if (refused := _not_content(source)) is not None:
return refused
if is_archive(source):
return self._import_archive(source)
if not is_supported(source):
@@ -542,28 +594,42 @@ class Importer:
# lays files out under /images/<artist_slug>/...). Filesystem
# import still calls bare _import_archive(source) and falls
# back to the path-walk derivation as before.
probe = safe_probe.probe_archive(source)
#
# A game or app build is recognised first, from the archive's directory
# alone. The probe reads and checksums every member, which for a
# multi-gigabyte build is minutes of work on a file that is kept whole
# and never extracted anyway.
marker = software_marker(source)
probe = None if marker is not None else safe_probe.probe_archive(source)
if probe is not None and probe.crashed:
return ImportResult(
status="failed",
error=f"archive probe crashed/timed out: {probe.reason}",
)
artist_use = artist if artist is not None else self._resolve_artist(source)
post = self._post_for_sidecar(source, artist_use, source_row=source_row)
if marker is not None:
# A game or app build: kept whole, its bundled images left alone.
self._capture_attachment(
source, post=post, artist=artist_use, resolved=True,
)
log.info("%s: a software build (holds %s), kept unextracted",
source.name, marker)
return ImportResult(status="attached")
if not probe.ok:
if probe.crashed:
return ImportResult(
status="failed",
error=f"archive probe crashed/timed out: {probe.reason}",
)
# Clean rejection (bomb cap exceeded, integrity mismatch):
# still preserve the archive file itself as an attachment so
# nothing silently vanishes, matching extract_archive's
# fail-soft contract.
artist_use = artist if artist is not None else self._resolve_artist(source)
post = self._post_for_sidecar(source, artist_use, source_row=source_row)
# Clean rejection (bomb cap exceeded, integrity mismatch): the
# archive file itself is still kept as an attachment so nothing
# silently vanishes, matching extract_archive's fail-soft contract.
self._capture_attachment(
source, post=post, artist=artist_use, resolved=True,
)
reason = f"archive probe rejected, captured unextracted: {probe.reason}"
log.warning("%s: %s", source.name, reason)
return ImportResult(status="attached", error=reason)
artist_use = artist if artist is not None else self._resolve_artist(source)
post = self._post_for_sidecar(source, artist_use, source_row=source_row)
# Committed before extraction, so a member that fails (and is rolled
# back on its own) can never take the archive's post with it.
self.session.commit()
member_ids: list[int] = []
# Every member image touched (new + superseded + deduped), so the
# from_attachment_id stamp below covers files that already existed in the
@@ -576,7 +642,7 @@ class Importer:
# or failed/corrupt members — instead of one catch-all string.
counts = {
"media": 0, "deduped": 0, "unsupported": 0,
"failed": 0, "nested": 0, "nested_rejected": 0,
"failed": 0, "nested": 0, "nested_rejected": 0, "nested_software": 0,
}
self._collect_archive_members(
source, attribution=source, source_row=source_row,
@@ -608,20 +674,21 @@ class Importer:
if counts["media"] == 0 and counts["nested"] == 0:
reason = ("archive yielded no members (unsupported/corrupt, or the "
"extractor backend failed)")
elif counts["deduped"] and not (
elif (counts["deduped"] or counts["nested_software"]) and not (
counts["unsupported"] or counts["failed"] or counts["nested_rejected"]
):
log.info(
"%s: all %d image member(s) already in library (deduped + linked "
"to this post) — archive preserved, no new import",
source.name, counts["deduped"],
"%s: no new images — %d already in the library (linked to this "
"post), %d nested software build(s) skipped; archive preserved",
source.name, counts["deduped"], counts["nested_software"],
)
return ImportResult(status="attached")
else:
reason = (
f"no new images — media={counts['media']} deduped={counts['deduped']} "
f"unsupported/non-media={counts['unsupported']} failed={counts['failed']} "
f"nested={counts['nested']} nested-rejected={counts['nested_rejected']}"
f"nested={counts['nested']} nested-rejected={counts['nested_rejected']} "
f"nested-software={counts['nested_software']}"
)
log.warning("%s: %s", source.name, reason)
return ImportResult(status="attached", error=reason)
@@ -654,6 +721,15 @@ class Importer:
_ARCHIVE_MAX_DEPTH, member_path.name,
)
continue
# Recognised from its directory before the probe reads
# every member, as for the outer archive.
if (marker := software_marker(member_path)) is not None:
counts["nested_software"] += 1
log.info(
"nested archive is a software build (holds %s), "
"skipped: %s", marker, member_path.name,
)
continue
probe = safe_probe.probe_archive(member_path)
if not probe.ok:
counts["nested_rejected"] += 1
@@ -690,7 +766,15 @@ class Importer:
member_record_ids.add(res.image_id)
else:
counts["failed"] += 1
except SoftTimeLimitExceeded:
# The task's time budget is spent: stop here so the task marks
# its row failed, rather than carrying on until the hard kill.
raise
except Exception as exc: # noqa: BLE001 — defensive per level; keep going
# Each member commits on its own, so this undoes only the one in
# progress; without it the next query fails on the dead transaction
# and the archive is never stored.
self.session.rollback()
log.warning(
"archive extraction failed for %s (depth %d): %s",
archive_path.name, depth, exc,
@@ -790,7 +874,6 @@ class Importer:
# Compute file dimensions (images only) and apply filters.
width = height = None
duration = None # video container duration (Tier-1 near-dup key, #871)
has_alpha = False
if is_video(source):
# Layer-3 isolation: validate the container via ffprobe (a
# separate process) before the rest of the pipeline touches
@@ -809,52 +892,9 @@ class Importer:
)
width, height, duration = probe.width, probe.height, probe.duration
else:
try:
with Image.open(source) as im:
im.verify()
with Image.open(source) as im:
width, height = im.size
has_alpha = im.mode in ("RGBA", "LA") or (
im.mode == "P" and "transparency" in im.info
)
except Exception as exc:
return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image, error=str(exc),
)
if (self.settings.min_width and width < self.settings.min_width) or (
self.settings.min_height and height < self.settings.min_height
):
return ImportResult(
status="skipped", skip_reason=SkipReason.too_small,
error=f"{width}x{height} below min {self.settings.min_width}x{self.settings.min_height}",
)
if self.settings.skip_transparent and has_alpha:
try:
pct = self._transparency_pct(source)
except OSError as exc:
# PIL.verify() at line 263 only validates header structure;
# truncated/corrupt pixel data only surfaces when load()
# actually decodes (here via getchannel('A')). Convert to
# invalid_image skip so the Celery autoretry loop doesn't
# bounce the same broken file forever.
return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image,
error=f"PIL load failed during transparency check: {exc}",
)
if pct >= self.settings.transparency_threshold:
return ImportResult(
status="skipped", skip_reason=SkipReason.too_transparent,
error=f"{pct:.2%} transparent",
)
if self.settings.skip_single_color and self._single_color_hit(source):
return ImportResult(
status="skipped", skip_reason=SkipReason.single_color,
error=(f"one color dominates >"
f"{self.settings.single_color_threshold:.0%}"),
)
refusal, width, height = self._screen_still(source)
if refusal is not None:
return refusal
# Artist anchored to the attribution path (folder→artist), resolved
# UP-FRONT so the enrich-on-duplicate branches link provenance with the
@@ -871,34 +911,17 @@ class Importer:
existing = self.session.execute(existing_stmt).scalar_one_or_none()
if existing:
if not self.deep:
# Enrich-on-duplicate (parity with attach_in_place): a re-scanned
# file that is a byte-dup of an existing image still links its
# post via _apply_sidecar, so a cross-posted image shows on every
# post.
self._apply_sidecar(
existing, attribution_path, path_artist,
explicit_source=explicit_source,
)
self.session.commit()
return ImportResult(
status="skipped", skip_reason=SkipReason.duplicate_hash,
image_id=existing.id, error="sha256 already present",
return self._file_as_duplicate(
existing, attribution_path, path_artist, explicit_source,
)
return self._deep_rederive(existing, source, attribution_path)
# Perceptual near-dup (images only; videos keep phash NULL).
phash = None
if not is_video(source):
try:
with Image.open(source) as im:
phash = compute_phash(im)
except OSError as exc:
# Same rationale as the transparency-check guard above:
# broken-pixel-data files pass verify() but blow up here.
return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image,
error=f"PIL load failed during phash compute: {exc}",
)
refusal, phash = self._phash_of(source)
if refusal is not None:
return refusal
if phash is not None:
candidates = self._phash_candidates_cache()
rel, match_id = find_similar(
@@ -968,8 +991,14 @@ class Importer:
origin="imported_filesystem",
integrity_status="unknown",
)
self.session.add(record)
self.session.flush()
if (winner := self._store_new_record(record)) is not None:
# The copy just written is a stray unless it is the winner's own
# file (the same source imported twice lands on the same path).
if Path(winner.path) != dest:
dest.unlink(missing_ok=True)
return self._file_as_duplicate(
winner, attribution_path, path_artist, explicit_source,
)
self._phash_cache_append(phash, width, height, record.id)
# Folder→artist (path_artist resolved up-front above); bind it to the
@@ -1219,11 +1248,8 @@ class Importer:
caller may delete the on-disk original (mirrors duplicate_hash)
- failed → file untouched; caller decides
"""
if path.suffix.lower() == ".json":
return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image,
error="sidecar json is metadata, not content",
)
if (refused := _not_content(path)) is not None:
return refused
if is_archive(path):
return self._import_archive(
path, artist=artist, source_row=source,
@@ -1237,47 +1263,13 @@ class Importer:
path, post=post, artist=artist, resolved=True,
)
# Format / dimension / transparency filters (mirror _import_media).
# The same content filters as _import_media.
width = height = None
duration = None # video container duration (Tier-1 near-dup key, #871)
has_alpha = False
if not is_video(path):
try:
with Image.open(path) as im:
im.verify()
with Image.open(path) as im:
width, height = im.size
has_alpha = im.mode in ("RGBA", "LA") or (
im.mode == "P" and "transparency" in im.info
)
except Exception as exc:
return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image,
error=str(exc),
)
if (self.settings.min_width and width < self.settings.min_width) or (
self.settings.min_height and height < self.settings.min_height
):
return ImportResult(
status="skipped", skip_reason=SkipReason.too_small,
error=f"{width}x{height} below min {self.settings.min_width}x{self.settings.min_height}",
)
if self.settings.skip_transparent and has_alpha:
pct = self._transparency_pct(path)
if pct >= self.settings.transparency_threshold:
return ImportResult(
status="skipped", skip_reason=SkipReason.too_transparent,
error=f"{pct:.2%} transparent",
)
if self.settings.skip_single_color and self._single_color_hit(path):
return ImportResult(
status="skipped", skip_reason=SkipReason.single_color,
error=(f"one color dominates >"
f"{self.settings.single_color_threshold:.0%}"),
)
refusal, width, height = self._screen_still(path)
if refusal is not None:
return refusal
else:
# Best-effort probe for dims + duration so downloaded videos can dedup
# (#871). LENIENT: unlike _import_media this path does not reject on a
@@ -1300,21 +1292,14 @@ class Importer:
# only sets it when NULL. Without this the new post is left with no
# image AND (if its other content also deduped) no attachment, i.e. a
# bare shell — operator-flagged 2026-06-08 (1589 empty Anduo posts).
self._apply_sidecar(existing, path, artist, explicit_source=source)
self.session.commit()
return ImportResult(
status="skipped", skip_reason=SkipReason.duplicate_hash,
image_id=existing.id, error="sha256 already present",
)
return self._file_as_duplicate(existing, path, artist, source)
# phash dedup + supersede
phash = None
if not is_video(path):
try:
with Image.open(path) as im:
phash = compute_phash(im)
except Exception:
phash = None
refusal, phash = self._phash_of(path)
if refusal is not None:
return refusal
if phash is not None:
candidates = self._phash_candidates_cache()
rel, match_id = find_similar(
@@ -1385,8 +1370,8 @@ class Importer:
)
if artist is not None:
record.artist_id = artist.id
self.session.add(record)
self.session.flush()
if (winner := self._store_new_record(record)) is not None:
return self._file_as_duplicate(winner, path, artist, source)
self._phash_cache_append(phash, width, height, record.id)
# Sidecar provenance (best-effort). When `source` is passed, link
@@ -1401,6 +1386,51 @@ class Importer:
self.session.commit()
return ImportResult(status="imported", image_id=record.id)
def discard_after_attach(self, path: Path, result: ImportResult) -> bool:
"""Delete a downloaded file that `attach_in_place` has finished with,
when keeping it would only leave a second copy or a rejected file on
disk. Returns True when the file was removed. Both download paths (a
subscription walk's import step and the external-link worker) apply
this one policy:
- attached: the attachments store holds the canonical copy.
- imported or superseded ARCHIVE: its new images are in the library and
the archive itself is in the attachments store.
- duplicate skip: the library already holds the content.
- soft skip (too small, too transparent, single colour, not a readable
image): the import filters rejected it. A file left on disk with no
record is picked up by the next walk and imported again; the seen
ledger is what stops it being downloaded again.
- failed: the archive probe crashed, and a retry would crash again.
An imported or superseded media file is kept: it IS the record's file.
So is any path an ImageRecord points at, whatever the status, because a
re-fetch can write identical bytes over a record's own file. A staging
file is kept too: it may be a download still being written, and the
temp-file sweep removes it once it is old enough to be abandoned."""
if is_temp_staging_file(path):
return False
if result.status in ("imported", "superseded"):
redundant = is_archive(path)
else:
redundant = result.status in ("attached", "skipped", "failed")
if not redundant:
return False
owned = self.session.execute(
select(ImageRecord.id).where(
ImageRecord.path.in_({str(path), str(path.resolve())})
).limit(1)
).first()
if owned is not None:
log.info("keeping %s: it is image %s's file", path.name, owned.id)
return False
try:
path.unlink(missing_ok=True)
except OSError as exc:
log.warning("could not remove %s: %s", path, exc)
return False
return True
def _upsert_artist(self, name: str) -> Artist:
slug = slugify(name)
artist = self.session.execute(
@@ -1778,6 +1808,81 @@ class Importer:
# is operator-tunable per surface.
_SINGLE_COLOR_TOLERANCE = 30
def _screen_still(
self, source: Path,
) -> tuple[ImportResult | None, int | None, int | None]:
"""Read a still image's size and apply the library's content filters.
Returns `(refusal, width, height)`. `refusal` is a skip result when
the file is not a readable image, is below the minimum size, is mostly
transparent or is one colour, and None when the file passes.
A file whose header verifies but whose pixel data is truncated only
fails once PIL decodes it, as the transparency check does. That decode
error is a skip too: raised, it would retry the import task forever,
or abort a download run in the middle of its files.
"""
try:
with Image.open(source) as im:
im.verify()
with Image.open(source) as im:
width, height = im.size
has_alpha = im.mode in ("RGBA", "LA") or (
im.mode == "P" and "transparency" in im.info
)
except Exception as exc:
return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image,
error=str(exc),
), None, None
if (self.settings.min_width and width < self.settings.min_width) or (
self.settings.min_height and height < self.settings.min_height
):
return ImportResult(
status="skipped", skip_reason=SkipReason.too_small,
error=f"{width}x{height} below min {self.settings.min_width}x{self.settings.min_height}",
), width, height
if self.settings.skip_transparent and has_alpha:
try:
pct = self._transparency_pct(source)
except OSError as exc:
return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image,
error=f"PIL load failed during transparency check: {exc}",
), width, height
if pct >= self.settings.transparency_threshold:
return ImportResult(
status="skipped", skip_reason=SkipReason.too_transparent,
error=f"{pct:.2%} transparent",
), width, height
if self.settings.skip_single_color and self._single_color_hit(source):
return ImportResult(
status="skipped", skip_reason=SkipReason.single_color,
error=(f"one color dominates >"
f"{self.settings.single_color_threshold:.0%}"),
), width, height
return None, width, height
def _phash_of(self, source: Path) -> tuple[ImportResult | None, object]:
"""The perceptual hash of a still image, as `(refusal, phash)`.
Hashing decodes every pixel, so this is where a file with truncated
pixel data can first fail; that is a skip rather than an exception,
for the same reason as in `_screen_still`.
"""
try:
with Image.open(source) as im:
return None, compute_phash(im)
except OSError as exc:
return ImportResult(
status="skipped", skip_reason=SkipReason.invalid_image,
error=f"PIL load failed during phash compute: {exc}",
), None
def _single_color_hit(self, source: Path) -> bool:
"""True when one color dominates beyond the configured threshold — the
same canonical predicate the Cleanup audit runs (audits.single_color,
+1 -1
View File
@@ -37,7 +37,7 @@ from sqlalchemy import delete, func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..models import ImageRecord
from .gallery_dl import (
from .download_result import (
DownloadResult,
ErrorType,
classify_tier_gated,
+181
View File
@@ -0,0 +1,181 @@
"""Files in the artists' folders that the library has no record of.
A download writes its files into the artist's folder and imports them in a
later step. A run that stops between the two (a restart, a time limit) leaves
complete files on disk that no image or attachment points at, and a walk that
has already marked them seen never comes back for them. `find_unimported`
lists those files; `queue_unimported` turns the same list into an import batch
that attaches each file where it lies (`scan_mode="library"`), so a file the
library already holds, or one the import filters reject, is removed instead.
Only files under a folder named for an existing artist (matched by slug, so a
differently-cased folder still matches) are considered, and only images,
videos and archives. Files changed in the last day are left for the download
that may still be writing them.
"""
from __future__ import annotations
import os
import stat
import time
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..models import Artist, ImageRecord, ImportBatch, ImportTask, PostAttachment, Source
from ..utils.slug import slugify
from ..utils.staging import is_temp_staging_file
from .archive_extractor import is_archive
from .importer import is_supported
# Top-level folders that hold the library's own stores, not an artist's files.
_STORE_DIRS = frozenset({"thumbs", "attachments", "cookies", "secrets"})
MIN_AGE_SECONDS = 24 * 3600
_ACTIVE_TASK_STATUSES = ("pending", "queued", "processing")
_SAMPLES_PER_ARTIST = 3
_ARTISTS_LISTED = 50
@dataclass(frozen=True)
class Unimported:
path: Path
artist_slug: str
task_type: str # "media" or "archive", as ImportTask.task_type
size: int
def _referenced_paths(session: Session) -> set[str]:
paths: set[str] = set()
for p, thumb in session.execute(select(ImageRecord.path, ImageRecord.thumbnail_path)):
paths.add(p)
if thumb:
paths.add(thumb)
paths.update(session.execute(select(PostAttachment.path)).scalars())
paths.update(session.execute(
select(ImportTask.source_path).where(ImportTask.status.in_(_ACTIVE_TASK_STATUSES))
).scalars())
return paths
def find_unimported(
session: Session, images_root: Path, *, now: float | None = None,
) -> tuple[list[Unimported], dict]:
"""Walk the artists' folders and return (files to import, summary).
The summary counts what was found and what was passed over, and lists the
artists with the most to import (bytes first) with a few sample paths each:
{"files", "bytes", "archives", "artists": [{slug, files, bytes,
samples}], "recent", "not_media", "no_artist"}"""
now = time.time() if now is None else now
referenced = _referenced_paths(session)
slugs = set(session.execute(select(Artist.slug)).scalars())
found: list[Unimported] = []
passed = {"recent": 0, "not_media": 0, "no_artist": 0}
root = Path(images_root)
for top in sorted(os.listdir(root)):
if top in _STORE_DIRS or top.startswith(("_", ".")):
continue
top_dir = root / top
if not top_dir.is_dir():
continue
slug = slugify(top)
known = slug in slugs
for dirpath, dirnames, filenames in os.walk(top_dir):
dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
for name in sorted(filenames):
path = Path(dirpath) / name
if (
name.startswith(".") or path.suffix.lower() == ".json"
or is_temp_staging_file(path)
):
continue
if str(path) in referenced:
continue
try:
st = path.lstat()
except OSError:
continue
if not stat.S_ISREG(st.st_mode):
continue
kind = "archive" if is_archive(path) else ("media" if is_supported(path) else None)
if kind is None:
passed["not_media"] += 1
elif now - st.st_mtime < MIN_AGE_SECONDS:
passed["recent"] += 1
elif not known:
passed["no_artist"] += 1
else:
found.append(Unimported(path, slug, kind, st.st_size))
per_artist: dict[str, dict] = defaultdict(lambda: {"files": 0, "bytes": 0, "samples": []})
for u in found:
a = per_artist[u.artist_slug]
a["files"] += 1
a["bytes"] += u.size
if len(a["samples"]) < _SAMPLES_PER_ARTIST:
a["samples"].append(str(u.path.relative_to(root)))
artists = sorted(
({"slug": s, **v} for s, v in per_artist.items()), key=lambda a: -a["bytes"],
)[:_ARTISTS_LISTED]
summary = {
"files": len(found),
"bytes": sum(u.size for u in found),
"archives": sum(1 for u in found if u.task_type == "archive"),
"artists": artists,
**passed,
}
return found, summary
def queue_unimported(
session: Session, images_root: Path, *, triggered_by: str = "manual",
) -> dict:
"""Create a `library` import batch holding every file `find_unimported`
lists, and queue it. Returns the same summary plus `batch_id` (None when
there was nothing to import)."""
from ..tasks.import_file import enqueue_import # tasks import services
found, summary = find_unimported(session, images_root)
summary["batch_id"] = None
if not found:
return summary
batch = ImportBatch(
triggered_by=triggered_by, source_path=str(images_root),
scan_mode="library", status="running", total_files=len(found),
)
session.add(batch)
session.flush()
tasks = [
ImportTask(
batch_id=batch.id, source_path=str(u.path), task_type=u.task_type,
status="queued", size_bytes=u.size,
)
for u in found
]
session.add_all(tasks)
session.commit()
for t in tasks:
enqueue_import(t.id, t.task_type)
summary["batch_id"] = batch.id
return summary
def folder_owner(
session: Session, path: Path, images_root: Path,
) -> tuple[Artist | None, Source | None]:
"""The artist whose folder holds `path`, and that artist's source for the
platform folder below it when there is exactly one (a Discord artist has a
source per channel, so there the post's sidecar decides)."""
parts = Path(path).relative_to(images_root).parts
artist = session.execute(
select(Artist).where(Artist.slug == slugify(parts[0]))
).scalar_one_or_none()
if artist is None or len(parts) < 3:
return artist, None
sources = session.execute(
select(Source).where(Source.artist_id == artist.id, Source.platform == parts[1])
).scalars().all()
return artist, (sources[0] if len(sources) == 1 else None)
+171 -15
View File
@@ -19,6 +19,7 @@ import json
import logging
import os
import time
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@@ -184,6 +185,56 @@ def post_dir_name(post: dict) -> str:
return sanitize_segment(raw)
# -- one downloadable file, and its seen-ledger identity -------------------
@dataclass
class MediaItem:
"""One resolved downloadable file belonging to a post, as every platform
client returns it.
- `url`: what to fetch. `filename`: the media's own name when the platform
gives one, else the URL basename (never a network call).
- `kind`: the content type, using gallery-dl's `files` names ("images",
"attachments", …) so per-source content_types filter the same way.
- `post_id`: the owning post, so a flattened list stays traceable.
- `filehash`: the 32-hex content hash in a content-addressed CDN URL
(Patreon), else None.
- `media_id`: the platform's stable per-file id where the URL has no hash:
SubscribeStar's gallery id, Discord's attachment id (or, for an embed, a
hash of its URL path; the query string is a signature that changes on
every fetch). Empty when the platform has none.
- `extension` / `num`: Discord only. gallery-dl splits the name from its
extension and numbers files 1-based across the message; both name the
file on disk.
"""
url: str
filename: str
kind: str
post_id: str
filehash: str | None = None
media_id: str = ""
extension: str = ""
num: int = 0
# The seen-ledger tables' key column width.
_LEDGER_KEY_MAX = 128
def ledger_key(media: MediaItem) -> str:
"""A file's identity in the cross-run seen-ledger, stable across walks.
The content hash when the URL carries one. Otherwise the file is keyed
within its post: by the platform's per-file id, or by filename when there is
none (a Patreon Mux video or a hashless inline image). A positional key is
never used, because an edit that removes a file renumbers the ones after it
and would make a different file read as seen."""
if media.filehash:
return media.filehash
return f"{media.post_id}:{media.media_id or media.filename}"[:_LEDGER_KEY_MAX]
# -- per-item download outcomes (shared dataclasses) -----------------------
@dataclass
@@ -267,11 +318,16 @@ class Membership:
# -- base downloader (shared fetch/validate plumbing) ----------------------
class BaseNativeDownloader:
"""Shared download plumbing for native-platform downloaders: the streaming
GET (transient-retry + Range-resume) and file validation/quarantine. Platform
downloaders subclass this and implement `download_post` / `write_post_record`
/ the per-media sidecar (and any platform-specific fetch, e.g. Patreon's
Mux/yt-dlp video branch). PURE: no DB; the seen-skip is an injected predicate.
"""Shared download plumbing for native-platform downloaders: one post's
files (`download_post`, with the seen/disk skips and the time-box), the
streaming GET (transient-retry + Range-resume), and file
validation/quarantine.
A platform downloader subclasses this, implements `write_post_record`, and
overrides only the seams where its layout differs: `_post_folder`,
`_media_name`, `_media_sidecar`, and for Patreon's Mux/yt-dlp video,
`_existing_output` and `_fetch_media`. PURE: no DB; the seen-skip is an
injected predicate.
"""
def __init__(
@@ -383,18 +439,118 @@ class BaseNativeDownloader:
)
return reason, (dest or path)
# -- sidecar (per-media, minimal) --------------------------------------
# -- one post's files ---------------------------------------------------
def _write_minimal_sidecar(
self, post: dict, media_path: Path, *, source_url: str | None = None
) -> Path:
"""Post-first per-media sidecar (#856): image identity ONLY
(category/id/source_url). The post body/links live solely in _post.json."""
data: dict = {"category": self.platform, "id": str(post.get("id") or "")}
if source_url:
data["source_url"] = source_url
def download_post(
self,
post: dict,
media_items: list[MediaItem],
artist_slug: str,
*,
is_seen: Callable[[MediaItem], bool] = lambda m: False,
should_stop: Callable[[], bool] = lambda: False,
recapture: bool = False,
) -> list[MediaOutcome]:
"""Download every file of one post and return one outcome per file.
- Two-tier skip: the injected seen-ledger predicate first, then the disk.
- `should_stop()` is polled before each file, so a file-dense post
cannot run a time-boxed chunk far past its deadline. The files not
reached are not marked seen and are fetched by the next chunk.
- One file's failure is an "error" outcome for that file; the rest
proceed.
- `recapture` re-reads what is on disk without re-downloading: a seen
file that is on disk comes back `skipped_disk` with its path (so the
engine can backfill its source filehash), and a seen file that is gone
is left alone, since refetching is recovery's job.
"""
folder = self._post_folder(post, artist_slug)
outcomes: list[MediaOutcome] = []
for index, media in enumerate(media_items, start=1):
if should_stop():
break
try:
outcomes.append(self._download_one(
post, media, folder, artist_slug, index, is_seen,
recapture=recapture,
))
except Exception as exc: # resilient: isolate one item's failure
log.warning(
"%s media failed (post %s, item %d): %s",
self.platform, post.get("id"), index, exc,
)
outcomes.append(
MediaOutcome(media=media, status="error", path=None, error=str(exc))
)
return outcomes
def _download_one(
self,
post: dict,
media: MediaItem,
folder: Path,
artist_slug: str,
index: int,
is_seen: Callable[[MediaItem], bool],
*,
recapture: bool = False,
) -> MediaOutcome:
seen = is_seen(media)
if seen and not recapture:
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
path = folder / self._media_name(post, media, index)
existing = self._existing_output(path, media)
if existing is not None:
return MediaOutcome(media=media, status="skipped_disk", path=existing, error=None)
if seen:
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
folder.mkdir(parents=True, exist_ok=True)
# Real downloads are paced; the skips above have already returned.
if self._rate_limit > 0:
time.sleep(self._rate_limit)
return self._fetch_media(post, media, path, artist_slug)
# -- platform seams ----------------------------------------------------
# Where a platform lays its files out. Each matches the layout gallery-dl
# wrote, so files from before a platform moved to its native downloader
# are found on disk instead of fetched again.
def _post_folder(self, post: dict, artist_slug: str) -> Path:
return self.images_root / artist_slug / self.platform / post_dir_name(post)
def _media_name(self, post: dict, media: MediaItem, index: int) -> str:
return sanitize_segment(f"{index:02d}_{media.filename}")
def _existing_output(self, path: Path, media: MediaItem) -> Path | None:
"""The file already on disk for this media, or None."""
return path if path.exists() else None
def _fetch_media(
self, post: dict, media: MediaItem, path: Path, artist_slug: str,
) -> MediaOutcome:
"""Fetch one file, validate it, and write its sidecar."""
out = self._fetch_get(media.url, path)
reason, quarantined = self._validate_path(out, artist_slug, media.url)
if reason is not None:
# Distinct from an error, so the run reports a real
# files_quarantined count and the paths.
return MediaOutcome(media=media, status="quarantined", path=quarantined, error=reason)
self._write_media_sidecar(post, out, media)
return MediaOutcome(media=media, status="downloaded", path=out, error=None)
def _media_sidecar(self, post: dict, media: MediaItem) -> dict:
"""The per-file sidecar's content: the file's identity only. The post's
body, title and links live in its post record alone, so they are never
copied beside every file."""
return {"category": self.platform, "id": str(post.get("id") or ""),
"source_url": media.url}
def _write_media_sidecar(self, post: dict, media_path: Path, media: MediaItem) -> Path:
"""Write the per-file sidecar beside the file, named like it minus the
extension, which is what `find_sidecar` pairs first."""
sidecar_path = media_path.with_suffix(".json")
sidecar_path.write_text(json.dumps(data, indent=2))
sidecar_path.write_text(json.dumps(self._media_sidecar(post, media), indent=2))
return sidecar_path
# --- membership status vocabulary (#387) ------------------------------------
+26 -23
View File
@@ -38,14 +38,15 @@ FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
from __future__ import annotations
import base64
import json
import logging
import re
import time
from collections.abc import Iterator
from dataclasses import dataclass
from html import unescape
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
from urllib.parse import parse_qs, unquote, urlsplit
import requests
@@ -53,6 +54,7 @@ from ..utils.paths import filehash_from_url
from ..utils.prosemirror import post_body_html
from .native_ingest_common import (
_MAX_429_RETRIES,
MediaItem,
Membership,
NativeAuthError,
NativeDriftError,
@@ -140,29 +142,25 @@ class PatreonDriftError(PatreonAPIError, NativeDriftError):
"""
@dataclass
class MediaItem:
"""One resolved downloadable item belonging to a post.
# Patreon's CDN URLs carry a base64 JSON segment of render options, such as
# eyJ3Ijo2MjB9 for {"w":620}. A locked preview is rendered blurred and says so
# with a "b" (blur) option. A post the account can view can still embed media
# from a post it can't, such as a public teaser showing a paid post's image,
# and that media arrives blurred even though the post itself isn't gated.
_RENDER_OPTIONS_RE = re.compile(r"/(eyJ[A-Za-z0-9_\-]+(?:=|%3D)*)(?:/|$)")
Fields:
url — the CDN/download URL to fetch.
filename — media `file_name` when present; otherwise the URL basename
(NEVER a network call). Bounded/sane extension.
kind — one of: "images", "image_large", "attachments", "postfile",
"content". Mirrors gallery-dl's `files` content-type names so
the later step can honor the same per-source content_types.
filehash — the 32-char hex (MD5) segment from the CDN URL, or None if
the URL carries no such segment. Used for in-post dedup and
the cross-run seen-ledger.
post_id — the owning post's id (so a flattened media list stays
traceable to its post).
"""
url: str
filename: str
kind: str
filehash: str | None
post_id: str
def is_blurred_preview(url: str) -> bool:
"""True when a Patreon CDN URL is a blurred locked preview."""
m = _RENDER_OPTIONS_RE.search(urlsplit(url).path)
if not m:
return False
token = unquote(m.group(1))
try:
options = json.loads(base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)))
except (ValueError, TypeError):
return False
return isinstance(options, dict) and bool(options.get("b"))
def _filehash(url: str) -> str | None:
@@ -479,6 +477,11 @@ class PatreonClient:
)
)
blurred = [i for i in items if is_blurred_preview(i.url)]
if blurred:
log.info("Patreon post %s: skipped %d blurred locked preview(s)",
post_id, len(blurred))
items = [i for i in items if not is_blurred_preview(i.url)]
return _dedup_by_filehash(items)
@staticmethod
+27 -166
View File
@@ -43,10 +43,10 @@ from .native_ingest_common import (
_BACKOFF_CAP_SECONDS,
_MAX_MEDIA_RETRIES,
BaseNativeDownloader,
MediaItem,
MediaOutcome,
PostRecordOutcome,
post_dir_name,
sanitize_segment,
)
log = logging.getLogger(__name__)
@@ -102,139 +102,33 @@ class PatreonDownloader(BaseNativeDownloader):
# None in unit tests / when enrichment isn't wanted.
self._content_fetcher = content_fetcher
# -- public ------------------------------------------------------------
# The download loop, layout and per-file sidecar are BaseNativeDownloader's.
# Patreon adds one branch: a Mux/HLS video is fetched with yt-dlp, which may
# remux it to a container of its own choosing.
def download_post(
self,
post: dict,
media_items: list,
artist_slug: str,
*,
is_seen: Callable[[object], bool] = lambda m: False,
should_stop: Callable[[], bool] = lambda: False,
recapture: bool = False,
) -> list[MediaOutcome]:
"""Download every media item of one post; return per-item outcomes.
def _existing_output(self, path: Path, media: MediaItem) -> Path | None:
if path.exists():
return path
if _is_video_url(media.url):
# A remux lands at a different extension; honour one already there.
return self._existing_video_output(path)
return None
Builds the post directory, iterates media (1-based NN), applies the
two-tier skip (injected is_seen, then disk), downloads (plain GET or
yt-dlp for video), writes the sidecar for each freshly-downloaded item,
validates, and returns outcomes. Resilient: one media's failure yields
an "error" outcome for that item; the rest proceed.
`should_stop()` is polled BEFORE each media item: a media-dense post can
otherwise run a backfill chunk far past its time-box (the engine only
re-checks the budget between posts), so we honour the deadline mid-post
and return the items done so far — the rest re-fetch next chunk (they
were never marked seen). Bounds chunk overrun to one media download.
`recapture` (#830): don't re-download already-present media, but DO surface
on-disk media as `skipped_disk` (with its path) even when the seen-ledger
would tier-1 skip it — so the engine can backfill source_filehash for
inline-image localization. Genuinely-missing seen media is NOT refetched.
"""
post_dir = self.images_root / artist_slug / "patreon" / post_dir_name(post)
outcomes: list[MediaOutcome] = []
for i, media in enumerate(media_items, start=1):
if should_stop():
break
try:
outcomes.append(
self._download_one(
post, media, post_dir, artist_slug, i, is_seen,
recapture=recapture,
)
)
except Exception as exc: # resilient: isolate one item's failure
log.warning(
"Patreon media failed (post %s, item %d): %s",
post.get("id"), i, exc,
)
outcomes.append(
MediaOutcome(media=media, status="error", path=None, error=str(exc))
)
return outcomes
# -- per-item ----------------------------------------------------------
def _download_one(
self,
post: dict,
media,
post_dir: Path,
artist_slug: str,
index: int,
is_seen: Callable[[object], bool],
*,
recapture: bool = False,
def _fetch_media(
self, post: dict, media: MediaItem, path: Path, artist_slug: str,
) -> MediaOutcome:
# tier-1: seen ledger (injected; no DB here). In recapture mode we DON'T
# short-circuit here — we fall through to the disk check so an on-disk
# seen file is surfaced as skipped_disk (with its path) for source_filehash
# backfill; a seen file that's NOT on disk is left alone (not refetched).
seen = is_seen(media)
if seen and not recapture:
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
nn = f"{index:02d}"
final_name = sanitize_segment(f"{nn}_{media.filename}")
media_path = post_dir / final_name
# tier-2: already on disk.
if media_path.exists():
if not _is_video_url(media.url):
return super()._fetch_media(post, media, path, artist_slug)
out_path = self._run_ytdlp(media.url, path, _VIDEO_HEADERS)
if out_path is None or not Path(out_path).exists():
return MediaOutcome(
media=media, status="skipped_disk", path=media_path, error=None
media=media, status="error", path=None, error="yt-dlp produced no output",
)
# Video may land at a different extension; honor a pre-existing remux.
if _is_video_url(media.url):
existing = self._existing_video_output(media_path)
if existing is not None:
return MediaOutcome(
media=media, status="skipped_disk", path=existing, error=None
)
# recapture: a seen item that isn't on disk is NOT re-downloaded (that's
# recovery's job) — recapture only re-grabs post text + localizes existing
# files. Returns skipped_seen so the run-of-seen / counts stay consistent.
if seen:
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
post_dir.mkdir(parents=True, exist_ok=True)
# Pace real downloads only (the skips above already returned). plan #703.
if self._rate_limit > 0:
time.sleep(self._rate_limit)
if _is_video_url(media.url):
out_path = self._run_ytdlp(media.url, media_path, _VIDEO_HEADERS)
if out_path is None or not Path(out_path).exists():
return MediaOutcome(
media=media,
status="error",
path=None,
error="yt-dlp produced no output",
)
out_path = Path(out_path)
else:
out_path = self._fetch_get(media.url, media_path)
reason, quarantine_dest = self._validate_path(
out_path, artist_slug, media.url
)
if reason is not None:
# Quarantined (corrupt/invalid) — distinct from a download error
# so the run can report a real files_quarantined count + paths.
return MediaOutcome(
media=media, status="quarantined",
path=quarantine_dest, error=reason,
)
self._write_sidecar(post, out_path, source_url=media.url)
out_path = Path(out_path)
self._write_media_sidecar(post, out_path, media)
return MediaOutcome(media=media, status="downloaded", path=out_path, error=None)
# -- video (Mux/HLS via yt-dlp) ----------------------------------------
# The plain-GET streaming path (_fetch_get / _fetch_to_file) and
# _validate_path are inherited from BaseNativeDownloader.
def _run_ytdlp(self, url: str, dest: Path, headers: dict) -> Path | None:
"""Invoke yt-dlp to fetch a Mux/HLS stream to (around) `dest`.
@@ -316,43 +210,12 @@ class PatreonDownloader(BaseNativeDownloader):
return cand
return None
# -- sidecar -----------------------------------------------------------
def _write_sidecar(
self, post: dict, media_path: Path, *, source_url: str | None = None
def _write_post_record_file(
self, post: dict, sidecar_path: Path, *, detail_fetch: bool = True,
) -> Path:
"""Write the per-media sidecar next to `media_path` — post-first (#856).
On the native ingester the POST-RECORD (`write_post_record` → `_post.json`)
is the sole writer of the post body/links/metadata, captured once per post
BEFORE its media in the walk. So the per-media sidecar carries ONLY
image-specific identity: `category` (platform) + `id` (external_post_id, to
link provenance to the right Post) + this file's `source_url` (its CDN URL,
#830 Phase 2 — the importer persists its filehash so the body's inline
`<img src>` remaps to the local copy at render time). No body: writing it
next to every image duplicated the post body N+1× and risked divergence
(milestone #67). The importer skips post fields for these (post_first).
"""
return self._write_sidecar_data(
post, media_path.with_suffix(".json"), source_url=source_url,
minimal=True,
)
def _write_sidecar_data(
self, post: dict, sidecar_path: Path, *, source_url: str | None = None,
minimal: bool = False, detail_fetch: bool = True,
) -> Path:
"""Serialize the post's metadata to `sidecar_path`. The post-only record
(`write_post_record`) writes the FULL post (body/title/date/url); the
per-media sidecar (`_write_sidecar`, minimal=True) writes only image
identity (category/id/source_url) — post-first (#856). `source_url` is set
only for the per-media sidecar — a media-less post has no source file."""
if minimal:
data = {"category": "patreon", "id": str(post.get("id") or "")}
if source_url:
data["source_url"] = source_url
sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path
"""Serialize the full post record (body, title, date, url) to
`sidecar_path`. The per-file sidecars carry only the file's identity
(BaseNativeDownloader._media_sidecar)."""
attrs = post.get("attributes") or {}
title = attrs.get("title")
# Resolve the body HTML from the feed attrs: legacy flat `content`, else
@@ -387,8 +250,6 @@ class PatreonDownloader(BaseNativeDownloader):
"published_at": published if isinstance(published, str) else None,
"url": url if isinstance(url, str) else None,
}
if source_url:
data["source_url"] = source_url
sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path
@@ -434,10 +295,10 @@ class PatreonDownloader(BaseNativeDownloader):
)
post_dir = self.images_root / artist_slug / "patreon" / post_dir_name(post)
post_dir.mkdir(parents=True, exist_ok=True)
path = self._write_sidecar_data(
path = self._write_post_record_file(
post, post_dir / "_post.json", detail_fetch=not revisit,
)
# _write_sidecar_data has by now memoized any detail-fetched body onto
# _write_post_record_file has by now memoized any detail-fetched body onto
# post["attributes"]["content"], so re-read it for the FINAL char count.
body = (post.get("attributes") or {}).get("content")
body_chars = len(body) if isinstance(body, str) else 0
+5 -25
View File
@@ -6,9 +6,8 @@ media → tiered skip → download → mark-seen / record-failures / checkpoint-
now lives in `ingest_core.Ingester` — it's identical for every platform. This
module is the thin Patreon adapter: it wires the Patreon `client`/`downloader`/
ledger models/constraints/key into the core and supplies the Patreon-specific
failure mapping. `download_service.download_source` calls `PatreonIngester.run`
exactly as before; the public surface (this class, `_ledger_key`,
`DEAD_LETTER_THRESHOLD`, `verify_patreon_credential`) is unchanged.
failure mapping. `download_service.download_source` calls `PatreonIngester.run`;
the seen-ledger key is the shared `native_ingest_common.ledger_key`.
Three modes (selected by `download_service` from `config_overrides` state):
- tick — newest→oldest, skip seen (tier-1 ledger + tier-2 disk), early-out
@@ -36,38 +35,19 @@ from pathlib import Path
from ..models import PatreonFailedMedia, PatreonSeenMedia
from .ingest_core import DEAD_LETTER_THRESHOLD, Ingester
from .patreon_client import MediaItem, PatreonAPIError, PatreonClient
from .native_ingest_common import ledger_key
from .patreon_client import PatreonAPIError, PatreonClient
from .patreon_downloader import PatreonDownloader
from .patreon_resolver import extract_vanity, resolve_campaign_id_for_source
__all__ = [
"DEAD_LETTER_THRESHOLD",
"PatreonIngester",
"_ledger_key",
"verify_patreon_credential",
]
log = logging.getLogger(__name__)
# Ledger keys are stored in patreon_seen_media.filehash VARCHAR(128); bound any
# synthesized key so a pathologically long file_name can't overflow the column.
_LEDGER_KEY_MAX = 128
def _ledger_key(media: MediaItem) -> str:
"""Stable per-media identity for the cross-run seen-ledger.
A Patreon CDN URL carries a 32-char MD5 (`media.filehash`) — that is the
natural key. Some media have none: Mux/HLS video (`stream.mux.com`, no
content hash at discovery) and the odd inline-content `<img>` pointing at a
hashless URL. The plan calls the video case the ``video:<post_id>:<media_id>``
sentinel; `MediaItem` carries no media_id, so the post-scoped filename is the
stable proxy. Bounded to the column width.
"""
if media.filehash:
return media.filehash
return f"{media.post_id}:{media.filename}"[:_LEDGER_KEY_MAX]
class PatreonIngester(Ingester):
"""Walk a Patreon campaign's posts, download unseen media, return a
@@ -124,7 +104,7 @@ class PatreonIngester(Ingester):
failed_model=PatreonFailedMedia,
seen_constraint="uq_patreon_seen_media_source_id",
failed_constraint="uq_patreon_failed_media_source_id",
ledger_key=_ledger_key,
ledger_key=ledger_key,
platform="patreon",
error_base=PatreonAPIError,
# API_DRIFT message phrasing; the base Ingester._failure_result owns
+6 -6
View File
@@ -17,16 +17,16 @@ import logging
import redis
from ..config import get_config
from .download_backends import NATIVE_INGESTER_PLATFORMS
log = logging.getLogger(__name__)
# Platforms walked one-at-a-time. gallery-dl platforms are intentionally NOT
# here: each runs as a self-pacing subprocess and they're lower-volume. The
# native-ingester platforms are serialized (one paced scrape/API walk at a time).
# Add a platform here to cap it to a single concurrent walk. Discord most of
# all: every source walks on the operator's ONE user token, and parallel walks
# Platforms walked one-at-a-time: every native-ingester platform, one paced
# scrape/API walk at a time. gallery-dl platforms are not: each runs as a
# self-pacing subprocess and they are lower-volume. Discord most of all needs
# it: every source walks on the operator's ONE user token, and parallel walks
# on a user account are both how its rate limit trips and what gets it flagged.
SERIALIZED_PLATFORMS = frozenset({"patreon", "subscribestar", "discord"})
SERIALIZED_PLATFORMS = NATIVE_INGESTER_PLATFORMS
_LOCK_PREFIX = "fc:download_lock:"
+1 -1
View File
@@ -19,7 +19,7 @@ from ..models import (
)
from .db_helpers import failing_sources_clause
from .download_backends import uses_native_ingester
from .gallery_dl import ErrorType
from .download_result import ErrorType
from .membership_reconcile import KEPT_KEY, STOPPED_KEY
from .membership_roster import gated_reasons_for_sources
from .platforms import known_platform_keys
+1 -19
View File
@@ -32,7 +32,6 @@ import logging
import re
import time
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
from html import unescape
from pathlib import Path
@@ -43,6 +42,7 @@ import requests
from ..utils.paths import filehash_from_url
from .native_ingest_common import (
_MAX_429_RETRIES,
MediaItem,
Membership,
NativeAuthError,
NativeDriftError,
@@ -163,24 +163,6 @@ class SubscribeStarDriftError(SubscribeStarAPIError, NativeDriftError):
silently importing nothing."""
@dataclass
class MediaItem:
"""One resolved downloadable item belonging to a SubscribeStar post.
Fields mirror patreon_client.MediaItem (so the downloader is structurally the
same) plus `media_id` — the stable per-upload gallery id. SubscribeStar's
full-res URL is an opaque `/post_uploads?payload=...` (not content-addressed),
so `filehash` is usually None and the ledger keys on `<post_id>:<media_id>`.
"""
url: str
filename: str
kind: str
filehash: str | None
post_id: str
media_id: str
def _extr(text: str, start: str, end: str) -> str:
"""Substring between the first `start` and the next `end` after it (gallery-
dl's `text.extr`); '' when either marker is absent."""
@@ -23,30 +23,23 @@ FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API.
from __future__ import annotations
import json
import logging
import time
from collections.abc import Callable
from pathlib import Path
import requests
from .native_ingest_common import (
BaseNativeDownloader,
MediaOutcome,
PostRecordOutcome,
post_dir_name,
sanitize_segment,
)
log = logging.getLogger(__name__)
class SubscribeStarDownloader(BaseNativeDownloader):
"""Download resolved SubscribeStar media to gallery-dl's on-disk layout.
Subclasses BaseNativeDownloader for the shared streaming GET (transient-retry +
Range-resume) and validation/quarantine. No video branch (SubscribeStar serves
files directly via /post_uploads) and no detail-fetch (the body is already in
the feed HTML). PURE: no DB."""
The download loop, per-file layout and sidecars are BaseNativeDownloader's
defaults, unchanged: there is no video branch (SubscribeStar serves files
directly via /post_uploads) and no detail-fetch (the body is already in the
feed HTML). This class adds only the post record. PURE: no DB."""
def __init__(
self,
@@ -62,110 +55,8 @@ class SubscribeStarDownloader(BaseNativeDownloader):
validate=validate, rate_limit=rate_limit, session=session,
)
# -- public ------------------------------------------------------------
def download_post(
self,
post: dict,
media_items: list,
artist_slug: str,
*,
is_seen: Callable[[object], bool] = lambda m: False,
should_stop: Callable[[], bool] = lambda: False,
recapture: bool = False,
) -> list[MediaOutcome]:
"""Download every media item of one post; return per-item outcomes.
Mirrors PatreonDownloader.download_post (two-tier skip, mid-post time-box,
recapture surfacing) minus the video branch."""
post_dir = self.images_root / artist_slug / "subscribestar" / post_dir_name(post)
outcomes: list[MediaOutcome] = []
for i, media in enumerate(media_items, start=1):
if should_stop():
break
try:
outcomes.append(
self._download_one(
post, media, post_dir, artist_slug, i, is_seen,
recapture=recapture,
)
)
except Exception as exc: # resilient: isolate one item's failure
log.warning(
"SubscribeStar media failed (post %s, item %d): %s",
post.get("id"), i, exc,
)
outcomes.append(
MediaOutcome(media=media, status="error", path=None, error=str(exc))
)
return outcomes
# -- per-item ----------------------------------------------------------
def _download_one(
self,
post: dict,
media,
post_dir: Path,
artist_slug: str,
index: int,
is_seen: Callable[[object], bool],
*,
recapture: bool = False,
) -> MediaOutcome:
seen = is_seen(media)
if seen and not recapture:
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
nn = f"{index:02d}"
media_path = post_dir / sanitize_segment(f"{nn}_{media.filename}")
if media_path.exists(): # tier-2: already on disk
return MediaOutcome(
media=media, status="skipped_disk", path=media_path, error=None
)
# recapture: a seen item not on disk is NOT re-downloaded (recovery's job).
if seen:
return MediaOutcome(media=media, status="skipped_seen", path=None, error=None)
post_dir.mkdir(parents=True, exist_ok=True)
if self._rate_limit > 0:
time.sleep(self._rate_limit)
out_path = self._fetch_get(media.url, media_path)
reason, quarantine_dest = self._validate_path(out_path, artist_slug, media.url)
if reason is not None:
return MediaOutcome(
media=media, status="quarantined", path=quarantine_dest, error=reason,
)
self._write_sidecar(post, out_path, source_url=media.url)
return MediaOutcome(media=media, status="downloaded", path=out_path, error=None)
# The plain-GET streaming path (_fetch_get / _fetch_to_file) and
# _validate_path are inherited from BaseNativeDownloader.
# -- sidecar -----------------------------------------------------------
def _write_sidecar(
self, post: dict, media_path: Path, *, source_url: str | None = None
) -> Path:
"""Per-media sidecar — post-first (#856): image identity ONLY
(category/id/source_url). The post body/links live solely in _post.json."""
return self._write_sidecar_data(
post, media_path.with_suffix(".json"), source_url=source_url, minimal=True,
)
def _write_sidecar_data(
self, post: dict, sidecar_path: Path, *, source_url: str | None = None,
minimal: bool = False,
) -> Path:
"""Serialize the post's metadata. minimal=True → per-media sidecar (image
identity only); else the full post record (body/title/date/url)."""
if minimal:
data = {"category": "subscribestar", "id": str(post.get("id") or "")}
if source_url:
data["source_url"] = source_url
sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path
def _write_post_record_file(self, post: dict, sidecar_path: Path) -> Path:
"""Serialize the full post record: body, title and date."""
attrs = post.get("attributes") or {}
content = attrs.get("content")
# SubscribeStar synthesizes the post permalink from the id (matches the
@@ -179,8 +70,6 @@ class SubscribeStarDownloader(BaseNativeDownloader):
"content": content if isinstance(content, str) else "",
"published_at": attrs.get("published_at"),
}
if source_url:
data["source_url"] = source_url
sidecar_path.write_text(json.dumps(data, indent=2))
return sidecar_path
@@ -214,7 +103,7 @@ class SubscribeStarDownloader(BaseNativeDownloader):
)
post_dir = self.images_root / artist_slug / "subscribestar" / post_dir_name(post)
post_dir.mkdir(parents=True, exist_ok=True)
path = self._write_sidecar_data(post, post_dir / "_post.json")
path = self._write_post_record_file(post, post_dir / "_post.json")
body = attrs.get("content")
body_chars = len(body) if isinstance(body, str) else 0
return PostRecordOutcome(
+3 -15
View File
@@ -22,30 +22,18 @@ from pathlib import Path
from ..models import SubscribeStarFailedMedia, SubscribeStarSeenMedia
from .ingest_core import DEAD_LETTER_THRESHOLD, Ingester
from .subscribestar_client import MediaItem, SubscribeStarAPIError, SubscribeStarClient
from .native_ingest_common import ledger_key
from .subscribestar_client import SubscribeStarAPIError, SubscribeStarClient
from .subscribestar_downloader import SubscribeStarDownloader
__all__ = [
"DEAD_LETTER_THRESHOLD",
"SubscribeStarIngester",
"_ledger_key",
"verify_subscribestar_credential",
]
log = logging.getLogger(__name__)
_LEDGER_KEY_MAX = 128
def _ledger_key(media: MediaItem) -> str:
"""Stable per-media identity for the cross-run seen-ledger. SubscribeStar's
full-res URL is an opaque `/post_uploads?payload=...` (no content hash), so
`media.filehash` is normally None and the stable proxy is the gallery item id
scoped to its post: `<post_id>:<media_id>`. Bounded to the column width."""
if media.filehash:
return media.filehash
return f"{media.post_id}:{media.media_id}"[:_LEDGER_KEY_MAX]
class SubscribeStarIngester(Ingester):
"""Walk a SubscribeStar creator's posts, download unseen media, return a
@@ -91,7 +79,7 @@ class SubscribeStarIngester(Ingester):
failed_model=SubscribeStarFailedMedia,
seen_constraint="uq_subscribestar_seen_media_source_id",
failed_constraint="uq_subscribestar_failed_media_source_id",
ledger_key=_ledger_key,
ledger_key=ledger_key,
platform="subscribestar",
error_base=SubscribeStarAPIError,
# API_DRIFT message phrasing; the base Ingester._failure_result owns
+32
View File
@@ -74,6 +74,12 @@ def prune_missing_file_records_task(self) -> dict:
thumbs: list[str] = []
last_id = 0
with SessionLocal() as session:
# While a restore is fetching the library back, files are missing
# because they haven't arrived yet, not because they are gone.
from .backup import restore_going
if restore_going(session):
return {"checked": 0, "missing": 0, "deleted": 0,
"skipped": "a restore is fetching the library's files"}
while True:
rows = session.execute(
select(ImageRecord.id, ImageRecord.path, ImageRecord.thumbnail_path)
@@ -459,3 +465,29 @@ def reclaim_orphaned_attachments_task(self, dry_run: bool = True) -> dict:
return cleanup_service.reclaim_orphaned_attachments(
session, images_root=IMAGES_ROOT, dry_run=dry_run,
)
@celery.task(
name="backend.app.tasks.admin.import_unimported_files_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
# One walk of the artists' folders: a stat per file, so minutes on a large
# library on network storage. The limits are the backstop for a wedged
# filesystem, not the expected exit.
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
)
def import_unimported_files_task(self, dry_run: bool = True) -> dict:
"""Find files in the artists' folders that the library has no record of.
dry_run (the default) returns what was found, per artist, without changing
anything; apply queues them as a `library` import batch, which attaches each
file where it lies and removes the ones the library already holds or the
import filters reject."""
from ..services import library_scan
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
if dry_run:
_found, summary = library_scan.find_unimported(session, IMAGES_ROOT)
return {**summary, "batch_id": None}
return library_scan.queue_unimported(session, IMAGES_ROOT)
+788 -117
View File
@@ -8,17 +8,27 @@ BackupRun.
from __future__ import annotations
import logging
from datetime import UTC, datetime
import time
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from celery.exceptions import SoftTimeLimitExceeded
from sqlalchemy import select
from sqlalchemy import select, text, update
from sqlalchemy.exc import DBAPIError, OperationalError
from ..celery_app import celery
from ..config import get_config
from ..models import BackupRun, ImportSettings
from ..services import backup_service
from ..services import (
backup_destination,
backup_media,
backup_restore,
backup_s3,
backup_service,
backup_verify,
)
from ._sync_engine import get_sync_engine
from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__)
@@ -49,11 +59,19 @@ def _mark_failed(session, row: BackupRun, exc: BaseException) -> None:
)
def backup_db_task(self, *, tag: str | None = None,
triggered_by: str = "manual") -> dict:
"""Create one DB backup. Returns {'backup_run_id': N}."""
"""Create one DB backup. Returns {'backup_run_id': N}.
With an off-site destination set up, the dump streams to that bucket and
nothing is written here; the row's manifest says where it went
(`offsite`: endpoint, bucket, key). Without one, the dump is written to
/images/_backups."""
SessionLocal = _sync_session_factory()
cfg = get_config()
now = datetime.now(UTC)
with SessionLocal() as session:
# A restore replaces the database this would dump.
if restore_going(session):
return {"skipped": "a restore is going"}
row = BackupRun(
kind="db", status="running", tag=tag,
triggered_by=triggered_by, started_at=now, manifest={},
@@ -64,10 +82,17 @@ def backup_db_task(self, *, tag: str | None = None,
run_id = row.id
try:
result = backup_service.backup_db(
db_url=cfg.database_url_sync, images_root=IMAGES_ROOT,
tag=tag, triggered_by=triggered_by,
)
with SessionLocal() as session:
settings = ImportSettings.load_sync(session)
target = backup_destination.saved_target(settings)
limit_mbps = settings.backup_upload_limit_mbps
if target is not None:
result = _dump_offsite(cfg, target, limit_mbps)
else:
result = backup_service.backup_db(
db_url=cfg.database_url_sync, images_root=IMAGES_ROOT,
tag=tag, triggered_by=triggered_by,
)
except (SoftTimeLimitExceeded, Exception) as exc:
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
@@ -79,61 +104,23 @@ def backup_db_task(self, *, tag: str | None = None,
row = session.get(BackupRun, run_id)
row.status = "ok"
row.finished_at = datetime.now(UTC)
row.sql_path = result["sql_path"]
row.size_bytes = result["size_bytes"]
row.manifest = {
"manifest_path": result["manifest_path"],
"ts": result["ts"],
}
session.commit()
return {"backup_run_id": run_id}
@celery.task(
name="backend.app.tasks.backup.backup_images_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=30, retry_backoff_max=300, max_retries=1,
soft_time_limit=21600, time_limit=23400,
)
def backup_images_task(self, *, tag: str | None = None,
triggered_by: str = "manual") -> dict:
"""Create one images backup. Same shape as backup_db_task; uses
tar_path instead of sql_path."""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
with SessionLocal() as session:
row = BackupRun(
kind="images", status="running", tag=tag,
triggered_by=triggered_by, started_at=now, manifest={},
)
session.add(row)
session.commit()
session.refresh(row)
run_id = row.id
try:
result = backup_service.backup_images(
images_root=IMAGES_ROOT,
tag=tag, triggered_by=triggered_by,
)
except (SoftTimeLimitExceeded, Exception) as exc:
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
if row is not None:
_mark_failed(session, row, exc)
raise
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
row.status = "ok"
row.finished_at = datetime.now(UTC)
row.tar_path = result["tar_path"]
row.size_bytes = result["size_bytes"]
row.manifest = {
"manifest_path": result["manifest_path"],
"ts": result["ts"],
}
if target is not None:
row.manifest = {
"ts": result["ts"],
"offsite": {
"endpoint": result["endpoint"],
"bucket": result["bucket"],
"key": result["key"],
"parts": result["parts"],
},
}
else:
row.sql_path = result["sql_path"]
row.manifest = {
"manifest_path": result["manifest_path"],
"ts": result["ts"],
}
session.commit()
return {"backup_run_id": run_id}
@@ -189,52 +176,6 @@ def restore_db_task(self, *, source_backup_run_id: int) -> dict:
return {"backup_run_id": marker_id}
@celery.task(
name="backend.app.tasks.backup.restore_images_task",
bind=True, max_retries=0,
soft_time_limit=21600, time_limit=23400,
)
def restore_images_task(self, *, source_backup_run_id: int) -> dict:
"""Mirrors restore_db_task; uses backup_service.restore_images."""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
with SessionLocal() as session:
src = session.get(BackupRun, source_backup_run_id)
if src is None or src.kind != "images" or not src.tar_path:
raise ValueError(
f"BackupRun id={source_backup_run_id} is not a valid images backup"
)
marker = BackupRun(
kind="images", status="restoring",
triggered_by="restore", started_at=now,
restored_from_id=src.id,
manifest={"source_tar_path": src.tar_path},
)
session.add(marker)
session.commit()
session.refresh(marker)
marker_id = marker.id
tar_path = src.tar_path
try:
backup_service.restore_images(
images_root=IMAGES_ROOT, tar_path=Path(tar_path),
)
except (SoftTimeLimitExceeded, Exception) as exc:
with SessionLocal() as session:
row = session.get(BackupRun, marker_id)
if row is not None:
_mark_failed(session, row, exc)
raise
with SessionLocal() as session:
row = session.get(BackupRun, marker_id)
row.status = "restored"
row.finished_at = datetime.now(UTC)
session.commit()
return {"backup_run_id": marker_id}
@celery.task(
name="backend.app.tasks.backup.prune_backups",
soft_time_limit=300, time_limit=600,
@@ -242,34 +183,39 @@ def restore_images_task(self, *, source_backup_run_id: int) -> dict:
def prune_backups() -> dict:
"""Daily Beat. Per-kind retention from ImportSettings.
Returns {"db_deleted": N, "images_deleted": M, "files_unlinked": K}.
Returns {"db_deleted": N, "media_deleted": L, "restore_deleted": R, "reconcile_deleted": C, "test_deleted": T,
"files_unlinked": K}. Media, restore, reconcile and test runs leave no
files here; only their history is trimmed, to the latest MEDIA_RUNS_KEPT,
RESTORE_RUNS_KEPT, RECONCILE_RUNS_KEPT and TEST_RUNS_KEPT.
Tagged rows (tag IS NOT NULL) are never pruned.
Status='running' / 'restoring' rows are never pruned — the
recover_stalled_backup_runs sweep flips truly-stuck ones to
'error' first. (Earlier docstring claimed the FC-3i TaskRun sweep
handled those, but TaskRun cleanup never touched BackupRun rows.
Audit 2026-06-02 added the dedicated sweep.)
'error' first.
"""
SessionLocal = _sync_session_factory()
counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0}
counts = {"db_deleted": 0, "media_deleted": 0,
"restore_deleted": 0, "reconcile_deleted": 0, "test_deleted": 0,
"files_unlinked": 0}
with SessionLocal() as session:
s = ImportSettings.load_sync(session)
for kind, keep in (
("db", s.backup_db_keep_last_n),
("images", s.backup_images_keep_last_n),
("media", MEDIA_RUNS_KEPT),
("restore", RESTORE_RUNS_KEPT),
("reconcile", RECONCILE_RUNS_KEPT),
("test", TEST_RUNS_KEPT),
):
candidates = session.execute(
select(BackupRun)
.where(BackupRun.kind == kind)
.where(BackupRun.tag.is_(None))
.where(BackupRun.status.in_(["ok", "error"]))
.where(BackupRun.status.in_(["ok", "error", "restored"]))
.order_by(BackupRun.started_at.desc())
.offset(keep)
).scalars().all()
for row in candidates:
result = backup_service.unlink_artifact_files(
sql_path=row.sql_path,
tar_path=row.tar_path,
manifest_path=(row.manifest or {}).get("manifest_path"),
)
counts["files_unlinked"] += sum(
@@ -300,4 +246,729 @@ def backup_db_nightly() -> dict:
if now_hour != configured_hour:
return {"skipped": f"hour={now_hour} != configured={configured_hour}"}
res = backup_db_task.delay(triggered_by="nightly")
return {"dispatched": res.id}
out = {"dispatched": res.id}
# With an off-site destination, the night's backup is the whole library:
# the database dump, then any images and attachments not yet in the bucket.
# Both run on the long-maintenance lane, one after the other.
with SessionLocal() as session:
try:
offsite = backup_destination.saved_target(ImportSettings.load_sync(session))
except Exception: # noqa: BLE001 — the media run reports it itself
offsite = True
if offsite is not None:
out["media_dispatched"] = backup_media_task.delay(triggered_by="nightly").id
return out
# ── Off-site copy of the library's files ─────────────────────────────────────
# Each task invocation sends files for this long, then queues the next chunk
# of the same run, so other long-maintenance jobs get their turn in between.
MEDIA_CHUNK_SECONDS = 45 * 60
MEDIA_RUNS_KEPT = 60
# A failed file is listed on the run, up to this many; the count covers all.
MEDIA_FAILURES_LISTED = 20
# The weekly bucket check keeps about a quarter's history.
RECONCILE_RUNS_KEPT = 12
def _dump_offsite(cfg, target, limit_mbps) -> dict:
"""Dump the database to the bucket, with its row counts beside it for a
test restore to check against. The dump stands without its counts, so a
failure to store them is logged, not raised."""
result = backup_service.backup_db_offsite(
db_url=cfg.database_url_sync, target=target, limit_mbps=limit_mbps)
if result.get("counts"):
try:
backup_verify.store_counts(target, result["key"], result["counts"])
except (backup_s3.S3Error, OSError):
log.warning("backup: the row counts for %s weren't stored", result["key"],
exc_info=True)
return result
def _offsite_manifest(target) -> dict:
return {"endpoint": target.endpoint, "bucket": target.bucket,
"key": backup_media.PREFIX}
# ── One chunk at a time ──────────────────────────────────────────────────────
#
# A media backup and a restore's file phase run as a chain of chunks, each
# queuing the next. The chunk carrying a run writes its token to the run's
# manifest as "chunk", and hands that token to the chunk it queues as `after`.
# A chunk queued to resume a run after a restart carries `resume` instead and
# takes the run over. Any other message is stale (a killed chunk's own message
# delivered again, say) and is dropped, so a run never goes on twice and the
# chain never doubles.
def _chunk_token(task) -> str:
return task.request.id or uuid.uuid4().hex
def _claim_run(session, run_id: int, status: str, token: str, *, after: str | None,
resume: bool) -> str | None:
"""Take the run for this chunk. Returns None when taken, or why not."""
row = session.get(BackupRun, run_id, with_for_update=True)
if row is None or row.status != status:
session.rollback()
return "the run is no longer going"
held = (row.manifest or {}).get("chunk")
if not resume and held is not None and held != after:
session.rollback()
return "another chunk carries this run on"
row.manifest = {**(row.manifest or {}), "chunk": token}
session.commit()
return None
def _still_ours(session, run_id: int, token: str) -> bool:
"""Lock the run's row and say whether this chunk still carries it. A chunk
that was taken over stops at its next file rather than write over the
newer chunk's place; on False the transaction is already rolled back."""
held = session.execute(
select(BackupRun.manifest).where(BackupRun.id == run_id).with_for_update()
).scalar_one_or_none()
if (held or {}).get("chunk") == token:
return True
session.rollback()
return False
def resume_offsite_runs() -> dict:
"""Queue the next chunk of every off-site run a restart cut short: a media
backup still running, and a restore fetching its files. Called when the
long-maintenance lane starts, where those chunks run, so none of theirs is
alive then; the queued chunk carries on from the run's saved place. A
restore stopped while loading its database isn't resumed: the stall sweep
ends it, and the screen offers the restore again."""
with _sync_session_factory()() as session:
media = list(session.execute(
select(BackupRun.id).where(BackupRun.kind == "media")
.where(BackupRun.status == "running")
).scalars())
restores = [
r.id for r in session.execute(
select(BackupRun).where(BackupRun.kind == "restore")
.where(BackupRun.status == "restoring")
).scalars()
if (r.manifest or {}).get("phase") == "files"
]
try:
tests = end_cut_off_tests()
except Exception: # noqa: BLE001 — resuming the runs below matters more
log.exception("couldn't end a test restore cut off by the restart")
tests = 0
for run_id in media:
backup_media_task.delay(run_id=run_id, resume=True)
for run_id in restores:
restore_offsite_task.delay(run_id=run_id, resume=True)
if media or restores:
log.info("resumed off-site runs after a restart: media %s, restore %s",
media, restores)
return {"media": media, "restore": restores, "tests_ended": tests}
@celery.task(
name="backend.app.tasks.backup.backup_media_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=30, retry_backoff_max=300, max_retries=2,
# A chunk stops starting files after MEDIA_CHUNK_SECONDS; the limits leave
# room for the file in flight, a multi-gigabyte archive at a low upload
# limit included.
soft_time_limit=21600, time_limit=23400,
)
def backup_media_task(self, *, run_id: int | None = None, triggered_by: str = "manual",
after: str | None = None, resume: bool = False) -> dict:
"""Send the library's images and attachments that aren't in the off-site
bucket yet, one chunk of a run. Called without `run_id` it starts a run
(unless one is already going); each chunk queues the next until nothing is
left, and a restart resumes the run where it stopped. A file that can't be
sent is listed on the run and tried again by the next run. Returns
{'backup_run_id': N, ...}."""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
token = _chunk_token(self)
with SessionLocal() as session:
if run_id is None:
going = session.execute(
select(BackupRun.id).where(BackupRun.kind == "media")
.where(BackupRun.status == "running").limit(1)
).scalar_one_or_none()
if going is not None:
return {"backup_run_id": going, "skipped": "a media backup is already running"}
# During a restore the library's files are still arriving.
if restore_going(session):
return {"skipped": "a restore is going"}
row = BackupRun(kind="media", status="running", triggered_by=triggered_by,
started_at=now, last_progress_at=now, size_bytes=0,
manifest={"sent": 0, "failed": 0, "failures": [], "cursor": "",
"chunk": token})
session.add(row)
session.commit()
run_id = row.id
else:
why = _claim_run(session, run_id, "running", token, after=after, resume=resume)
if why:
return {"backup_run_id": run_id, "skipped": why}
try:
with SessionLocal() as session:
settings = ImportSettings.load_sync(session)
target = backup_destination.saved_target(settings)
limit_mbps = settings.backup_upload_limit_mbps
if target is None:
raise backup_s3.S3Error("No off-site destination is set up.")
more = _send_media_chunk(SessionLocal, run_id, target, backup_s3.Pacer(limit_mbps),
token)
except (SoftTimeLimitExceeded, Exception) as exc:
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
if row is not None:
_mark_failed(session, row, exc)
raise
if more is None:
return {"backup_run_id": run_id, "skipped": "another chunk took the run over"}
if more:
backup_media_task.delay(run_id=run_id, after=token)
return {"backup_run_id": run_id, "continues": True}
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
m = dict(row.manifest or {})
row.status = "ok"
row.finished_at = datetime.now(UTC)
if m.get("failed"):
first = (m.get("failures") or [{}])[0]
row.error = (f"{m['failed']} file(s) weren't sent; the next run tries them again. "
f"First: {first.get('path')}: {first.get('error')}")[:2000]
session.commit()
return {"backup_run_id": run_id, "continues": False}
def _send_media_chunk(SessionLocal, run_id: int, target, pacer, token: str) -> bool | None:
"""Send files until the chunk's time is up or none are left. Progress is
committed after every file, so a chunk that dies loses at most the file
in flight. Returns True when files remain for another chunk, and None when
another chunk has taken the run over."""
tid = backup_media.target_id(target)
deadline = time.monotonic() + MEDIA_CHUNK_SECONDS
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
m = dict(row.manifest or {})
m["offsite"] = _offsite_manifest(target)
cursor = m.get("cursor") or ""
while True:
batch = backup_media.pending(session, tid, after=cursor)
# End the read before any upload starts: a transaction left open
# across a long transfer holds back vacuum for its whole length.
session.commit()
if not batch:
if not _still_ours(session, run_id, token):
return None
m["cursor"] = cursor
row.manifest = dict(m)
row.last_progress_at = datetime.now(UTC)
session.commit()
return False
for blob in batch:
if time.monotonic() > deadline:
if not _still_ours(session, run_id, token):
return None
m["cursor"] = cursor
row.manifest = dict(m)
session.commit()
return True
key = None
try:
key = backup_media.send(target, blob, IMAGES_ROOT, pacer)
except (backup_s3.S3Error, OSError) as exc:
m["failed"] = m.get("failed", 0) + 1
if len(m.get("failures") or []) < MEDIA_FAILURES_LISTED:
m["failures"] = [*(m.get("failures") or []),
{"path": blob.path, "error": str(exc)[:300]}]
if not _still_ours(session, run_id, token):
return None
if key is not None:
backup_media.record(session, tid, [(blob, key)])
m["sent"] = m.get("sent", 0) + 1
row.size_bytes = (row.size_bytes or 0) + blob.size_bytes
cursor = blob.sha256
m["cursor"] = cursor
row.manifest = dict(m)
row.last_progress_at = datetime.now(UTC)
session.commit()
@celery.task(
name="backend.app.tasks.backup.reconcile_media_backup",
soft_time_limit=1800, time_limit=2100,
)
def reconcile_media_backup() -> dict:
"""Weekly Beat. Compare what the off-site bucket holds under `media/` with
what FabledCurator believes it sent: a missing object is sent again by
the next run, and an object already there is not sent twice. Skipped
while a media run is going, and when no destination is set up.
Each check is kept as a run of kind "reconcile" with its counts
({listed, dropped, adopted}), so the Backups card lists it and the System
view can warn when files were missing from the bucket."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
target = backup_destination.saved_target(ImportSettings.load_sync(session))
if target is None:
return {"skipped": "no destination"}
going = session.execute(
select(BackupRun.id).where(BackupRun.kind == "media")
.where(BackupRun.status == "running").limit(1)
).scalar_one_or_none()
if going is not None:
return {"skipped": "a media backup is running"}
now = datetime.now(UTC)
row = BackupRun(kind="reconcile", status="running", triggered_by="weekly",
started_at=now, last_progress_at=now,
manifest={"offsite": _offsite_manifest(target)})
session.add(row)
session.commit()
run_id = row.id
try:
counts = backup_media.reconcile(session, target)
except (SoftTimeLimitExceeded, Exception) as exc:
session.rollback()
row = session.get(BackupRun, run_id)
if row is not None:
_mark_failed(session, row, exc)
raise
row = session.get(BackupRun, run_id)
row.status = "ok"
row.finished_at = datetime.now(UTC)
row.manifest = {**(row.manifest or {}), **counts}
session.commit()
return {"backup_run_id": run_id, **counts}
# ── Restoring from the off-site bucket ───────────────────────────────────────
# The file phase fetches for this long per task, then queues its next chunk,
# like a media run.
RESTORE_CHUNK_SECONDS = 45 * 60
RESTORE_RUNS_KEPT = 20
RESTORE_FAILURES_LISTED = 20
# A run over files already on disk notes its place this often, not per file.
_RESTORE_NOTE_SECONDS = 5
_RESTORE_COUNTS = ("restored", "copied", "present", "missing", "failed")
def backup_busy(session) -> str | None:
"""Why a backup or a restore can't start right now, or None."""
row = session.execute(
select(BackupRun.kind).where(BackupRun.status.in_(["running", "restoring"])).limit(1)
).first()
if row is None:
return None
if row.kind == "restore":
return "a restore is going"
if row.kind == "test":
return "a test restore is running"
return f"a {row.kind} backup is running"
def restore_going(session) -> bool:
return session.execute(
select(BackupRun.id).where(BackupRun.kind == "restore")
.where(BackupRun.status == "restoring").limit(1)
).first() is not None
# ── Test restore ─────────────────────────────────────────────────────────────
TEST_RUNS_KEPT = 20
@celery.task(
name="backend.app.tasks.backup.restore_test_task",
bind=True,
max_retries=0,
# Downloading a dump, loading it and rebuilding its indexes; a restore of
# the database alone, with room to spare.
soft_time_limit=4 * 3600, time_limit=4 * 3600 + 900,
)
def restore_test_task(self, *, dump_key: str | None = None,
source_backup_run_id: int | None = None) -> dict:
"""Test a database dump without touching this install: load it into a
temporary database, check every table against the counts recorded with
the dump, bring it up to this version, and drop it again
(services/backup_verify.py). Tests the bucket's dump at `dump_key` (the
newest when None), or, given `source_backup_run_id`, a dump on this
server. The run ends "ok" when every table matched, "error" with what
didn't otherwise. Returns {'backup_run_id': N, ...}."""
SessionLocal = _sync_session_factory()
cfg = get_config()
now = datetime.now(UTC)
with SessionLocal() as session:
if why := restore_test_blocked(session):
return {"skipped": why}
row = BackupRun(kind="test", status="running", triggered_by="manual",
started_at=now, last_progress_at=now, manifest={"phase": "fetching"})
session.add(row)
session.commit()
run_id = row.id
def note(phase: str, **more) -> None:
with SessionLocal() as session:
r = session.get(BackupRun, run_id)
_note(session, r, {**r.manifest, "phase": phase, **more})
staged = offsite = None
try:
if source_backup_run_id is not None:
dump_path, recorded, source = _local_dump(SessionLocal, source_backup_run_id)
else:
with SessionLocal() as session:
settings = ImportSettings.load_sync(session)
target = backup_destination.saved_target(settings)
limit_mbps = settings.backup_upload_limit_mbps
if target is None:
raise backup_s3.S3Error("No off-site destination is set up.")
dump = backup_restore.find_dump(target, dump_key)
source = {"key": dump.key, "ts": dump.ts, "size_bytes": dump.size_bytes}
offsite = {**_offsite_manifest(target), "key": dump.key}
note("fetching", dump=source, offsite=offsite)
recorded = backup_verify.fetch_counts(target, dump.key)
staged = backup_restore.fetch_dump(
target, dump, IMAGES_ROOT / "_backups", backup_s3.Pacer(limit_mbps))
dump_path = staged
note("loading", dump=source)
result = backup_verify.trial_restore(
db_url=cfg.database_url_sync, dump_path=dump_path, recorded=recorded,
note=note)
except (SoftTimeLimitExceeded, Exception) as exc:
with SessionLocal() as session:
r = session.get(BackupRun, run_id)
if r is not None:
_mark_failed(session, r, exc)
raise
finally:
if staged is not None:
staged.unlink(missing_ok=True)
with SessionLocal() as session:
r = session.get(BackupRun, run_id)
r.status = "error" if result["mismatched"] else "ok"
r.error = backup_verify.summary(result) if result["mismatched"] else None
r.finished_at = datetime.now(UTC)
r.size_bytes = source.get("size_bytes")
r.manifest = {"phase": "done", "dump": source, "offsite": offsite,
"summary": backup_verify.summary(result), **result}
session.commit()
return {"backup_run_id": run_id, "mismatched": result["mismatched"]}
def _local_dump(SessionLocal, run_id: int) -> tuple[Path, dict | None, dict]:
"""A dump on this server: its path, its recorded counts, and what the
test run says it tested."""
with SessionLocal() as session:
src = session.get(BackupRun, run_id)
if src is None or src.kind != "db" or src.status != "ok" or not src.sql_path:
raise backup_s3.S3Error(f"Backup {run_id} isn't a database dump on this server.")
path = Path(src.sql_path)
if not path.is_file():
raise backup_s3.S3Error(f"{path} is no longer on this server.")
recorded = backup_verify.local_counts((src.manifest or {}).get("manifest_path"))
ts = (src.manifest or {}).get("ts")
return path, recorded, {"run_id": run_id, "ts": ts, "size_bytes": src.size_bytes}
def restore_test_blocked(session) -> str | None:
"""Why a test restore can't start right now, or None. A backup going
doesn't stop one: the test only reads this install's database."""
if restore_going(session):
return "a restore is going"
going = session.execute(
select(BackupRun.id).where(BackupRun.kind == "test")
.where(BackupRun.status == "running").limit(1)
).first()
return "a test restore is already running" if going else None
def end_cut_off_tests() -> int:
"""End every test restore a restart cut short, and drop the temporary
database it left. Called when the long-maintenance lane starts, where the
tests run, so none is alive then."""
with _sync_session_factory()() as session:
ended = session.execute(
update(BackupRun).where(BackupRun.kind == "test")
.where(BackupRun.status == "running")
.values(status="error", finished_at=datetime.now(UTC),
error="Interrupted: the server restarted during the test. Test again.")
).rowcount or 0
session.commit()
if ended:
backup_verify.drop_scratch(get_config().database_url_sync)
return ended
def _library_is_empty(session) -> bool:
return session.execute(text(
"SELECT NOT EXISTS (SELECT 1 FROM image_record) "
"AND NOT EXISTS (SELECT 1 FROM post_attachment)"
)).scalar()
def _note(session, row: BackupRun, m: dict, token: str | None = None) -> bool:
"""Write the run's progress. Given a chunk's token, writes only while that
chunk still carries the run, and returns False when it doesn't."""
if token is not None and not _still_ours(session, row.id, token):
return False
row.manifest = dict(m)
row.last_progress_at = datetime.now(UTC)
session.commit()
return True
@celery.task(
name="backend.app.tasks.backup.restore_offsite_task",
bind=True,
max_retries=0, # a half-applied restore is never retried on its own
soft_time_limit=21600, time_limit=23400,
)
def restore_offsite_task(self, *, dump_key: str | None = None, files_only: bool = False,
run_id: int | None = None, after: str | None = None,
resume: bool = False) -> dict:
"""Restore this install from the off-site bucket, one chunk at a time.
Started without `run_id`, it replaces the database with the dump at
`dump_key` (the newest when None), then fetches every library file the
restored rows name. With `files_only`, the database stays and only the
missing files are fetched, which also repairs a library that lost files.
Each chunk queues the next, and a restart resumes the file phase where it
stopped; the run's row counts files restored from the bucket, copied from
a duplicate already on disk, already present, missing from the bucket,
and failed. Returns {'backup_run_id': N, ...}."""
SessionLocal = _sync_session_factory()
token = _chunk_token(self)
if run_id is not None:
return _restore_files(SessionLocal, run_id, token, after=after, resume=resume)
now = datetime.now(UTC)
with SessionLocal() as session:
busy = backup_busy(session)
if busy:
return {"skipped": busy}
settings = ImportSettings.load_sync(session)
limit_mbps = settings.backup_upload_limit_mbps
row = BackupRun(kind="restore", status="restoring", triggered_by="restore",
started_at=now, last_progress_at=now, size_bytes=0,
manifest={"phase": "files" if files_only else "database",
"cursor": "", "failures": [],
**dict.fromkeys(_RESTORE_COUNTS, 0)})
session.add(row)
session.commit()
run_id = row.id
try:
target = backup_destination.saved_target(settings)
if target is None:
raise backup_s3.S3Error("No off-site destination is set up.")
except backup_s3.S3Error as exc:
_mark_failed(session, row, exc)
raise
row.manifest = {**row.manifest, "offsite": _offsite_manifest(target)}
session.commit()
if files_only:
return _restore_files(SessionLocal, run_id, token, after=None, resume=False)
run_id = _restore_database(SessionLocal, run_id, target, limit_mbps, dump_key)
# The files follow as chunks of their own, on the worker.
restore_offsite_task.delay(run_id=run_id)
return {"backup_run_id": run_id, "continues": True}
def _restore_database(SessionLocal, run_id: int, target, limit_mbps, dump_key) -> int:
"""Replace the database with a dump from the bucket. Returns the run's id
in the restored database, where its row is written again."""
cfg = get_config()
pacer = backup_s3.Pacer(limit_mbps)
staged = None
try:
dump = backup_restore.find_dump(target, dump_key)
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
m = dict(row.manifest)
m["dump"] = {"key": dump.key, "ts": dump.ts, "size_bytes": dump.size_bytes}
# The database about to be replaced goes to the bucket first, so
# the restore can itself be undone. An empty one isn't worth a
# dump, and would sit in the list as the newest.
if not _library_is_empty(session):
session.commit()
saved = _dump_offsite(cfg, target, limit_mbps)
m["saved_before"] = saved["key"]
_note(session, row, m)
started_at, manifest = row.started_at, dict(m)
staged = backup_restore.fetch_dump(target, dump, IMAGES_ROOT / "_backups", pacer)
except (SoftTimeLimitExceeded, Exception) as exc:
if staged is not None:
staged.unlink(missing_ok=True)
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
if row is not None:
_mark_failed(session, row, exc)
raise
# From here on the database is being replaced; every pooled connection
# still points at the tables being dropped.
get_sync_engine().dispose()
try:
backup_service.restore_db(db_url=cfg.database_url_sync, sql_path=staged)
backup_restore.migrate()
backup_service.end_other_connections(db_url=cfg.database_url_sync)
except (SoftTimeLimitExceeded, Exception) as exc:
_leave_empty_install(SessionLocal, cfg, target, limit_mbps, started_at, manifest, exc)
raise
finally:
staged.unlink(missing_ok=True)
get_sync_engine().dispose()
with SessionLocal() as session:
backup_restore.keep_destination(session, target, limit_mbps)
# The dump holds its own run as still going: it is the dump just
# restored, so it finished. Any other run going then is over.
dump_at = datetime.strptime(manifest["dump"]["ts"], "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
own = session.execute(
select(BackupRun).where(BackupRun.kind == "db", BackupRun.status == "running")
# The name's time is truncated to the second, after the row began.
.where(BackupRun.started_at.between(dump_at - timedelta(minutes=2),
dump_at + timedelta(seconds=1)))
.order_by(BackupRun.started_at.desc()).limit(1)
).scalar_one_or_none()
if own is not None:
own.status = "ok"
own.manifest = {"ts": manifest["dump"]["ts"], "offsite": {
**manifest["offsite"], "key": manifest["dump"]["key"]}}
own.size_bytes = manifest["dump"]["size_bytes"]
session.execute(
update(BackupRun).where(BackupRun.status.in_(["running", "restoring"]))
.values(status="error", finished_at=datetime.now(UTC),
error="Interrupted: this run was going when the restored dump was taken.")
)
row = BackupRun(kind="restore", status="restoring", triggered_by="restore",
started_at=started_at, last_progress_at=datetime.now(UTC),
size_bytes=0, manifest={**manifest, "phase": "files"})
session.add(row)
session.commit()
return row.id
def _leave_empty_install(SessionLocal, cfg, target, limit_mbps, started_at, manifest,
exc: BaseException) -> None:
"""After a failed load, put back an empty database at this version, so the
install starts and can try again, and record why it stopped."""
try:
backup_service.wipe_db(db_url=cfg.database_url_sync)
backup_restore.migrate()
backup_service.end_other_connections(db_url=cfg.database_url_sync)
get_sync_engine().dispose()
with SessionLocal() as session:
backup_restore.keep_destination(session, target, limit_mbps)
session.add(BackupRun(
kind="restore", status="error", triggered_by="restore",
started_at=started_at, finished_at=datetime.now(UTC), manifest=manifest,
error=(f"The restore stopped while loading the database: {exc}. "
"The database is empty again; choose a dump and restore again.")[:2000],
))
session.commit()
except Exception: # noqa: BLE001 — the original failure is the one raised
log.exception("restore: the database couldn't be reset after a failed load")
def _restore_files(SessionLocal, run_id: int, token: str, *, after: str | None,
resume: bool) -> dict:
"""One chunk of a restore's file phase; queues the next while files remain."""
with SessionLocal() as session:
why = _claim_run(session, run_id, "restoring", token, after=after, resume=resume)
if why:
return {"backup_run_id": run_id, "skipped": why}
row = session.get(BackupRun, run_id)
settings = ImportSettings.load_sync(session)
limit_mbps = settings.backup_upload_limit_mbps
try:
target = backup_destination.saved_target(settings)
if target is None:
raise backup_s3.S3Error("No off-site destination is set up.")
except backup_s3.S3Error as exc:
_mark_failed(session, row, exc)
raise
try:
more = _fetch_files_chunk(SessionLocal, run_id, target, backup_s3.Pacer(limit_mbps),
token)
except (SoftTimeLimitExceeded, Exception) as exc:
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
if row is not None:
_mark_failed(session, row, exc)
raise
if more is None:
return {"backup_run_id": run_id, "skipped": "another chunk took the run over"}
if more:
restore_offsite_task.delay(run_id=run_id, after=token)
return {"backup_run_id": run_id, "continues": True}
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
m = dict(row.manifest or {})
row.status = "restored"
row.finished_at = datetime.now(UTC)
missed = m.get("missing", 0) + m.get("failed", 0)
if missed:
first = (m.get("failures") or [{}])[0]
row.error = (f"{m.get('missing', 0)} file(s) aren't in the bucket and "
f"{m.get('failed', 0)} couldn't be restored. "
f"First: {first.get('path')}: {first.get('error')}")[:2000]
session.commit()
# Thumbnails aren't backed up: they are made again from the restored files.
from .thumbnail import backfill_thumbnails
backfill_thumbnails.delay()
return {"backup_run_id": run_id, "continues": False}
def _fetch_files_chunk(SessionLocal, run_id: int, target, pacer, token: str) -> bool | None:
"""Put library files back until the chunk's time is up or none are left.
Returns True when files remain for another chunk, and None when another
chunk has taken the run over."""
tid = backup_media.target_id(target)
deadline = time.monotonic() + RESTORE_CHUNK_SECONDS
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
m = dict(row.manifest or {})
cursor = m.get("cursor") or ""
noted = time.monotonic()
while True:
batch = backup_restore.library_files(session, after=cursor)
keys = backup_restore.sent_keys(session, tid, [f.sha256 for f in batch])
# End the read before any download starts.
session.commit()
if not batch:
m["cursor"] = cursor
return False if _note(session, row, m, token) else None
for f in batch:
if time.monotonic() > deadline:
m["cursor"] = cursor
return True if _note(session, row, m, token) else None
try:
outcome = backup_restore.restore_file(
target, f, keys.get(f.sha256), IMAGES_ROOT, pacer)
except (backup_s3.S3Error, OSError) as exc:
outcome = "missing" if isinstance(exc, backup_s3.ObjectMissing) else "failed"
if len(m.get("failures") or []) < RESTORE_FAILURES_LISTED:
m["failures"] = [*(m.get("failures") or []),
{"path": f.paths[0], "error": str(exc)[:300]}]
m[outcome] = m.get(outcome, 0) + 1
if outcome in ("restored", "copied"):
row.size_bytes = (row.size_bytes or 0) + f.size_bytes
cursor = f.sha256
m["cursor"] = cursor
if outcome != "present" or time.monotonic() - noted > _RESTORE_NOTE_SECONDS:
if not _note(session, row, m, token):
return None
noted = time.monotonic()
+128 -41
View File
@@ -24,7 +24,7 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
import redis
from sqlalchemy import delete, select, update
from sqlalchemy import delete, or_, select, update
from ..celery_app import celery
from ..config import get_config
@@ -32,6 +32,7 @@ from ..models import Artist, ExternalLink, ImageRecord, ImportSettings, Post, So
from ..services.external_fetch import fetch_external
from ..services.importer import Importer
from ..services.thumbnailer import Thumbnailer
from ..utils.staging import is_temp_staging_file
from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__)
@@ -39,14 +40,20 @@ log = logging.getLogger(__name__)
IMAGES_ROOT = Path("/images")
# After this many failed attempts a link is dead-lettered (skipped by routine
# sweeps; an operator recovery still re-attempts). Mirrors the ingester ledger.
# sweeps; the daily recovery retries it, sparingly). Mirrors the ingester ledger.
DEAD_LETTER_THRESHOLD = 3
# Per-fetch read + total budgets now live in external_fetch (a short read
# timeout fails a stalled host fast; a generous total caps a big-but-flowing
# download). The celery soft/hard time_limit is the outer backstop above those.
# Links enqueued per sweep — bounds the burst when a big backfill records many.
_SWEEP_BATCH = 50
# Dead rows older than this are pruned (retention).
# Recovery gives a dead link one more attempt once it has been dead for
# 2**recoveries days (1, 2, 4, 8, 16), and stops after _MAX_RECOVERIES. A host
# outage gets several later chances; a link that can never succeed (too big to
# import in time, a file made private) costs a handful of downloads, not three
# a day.
_MAX_RECOVERIES = 5
# A link recovery has given up on is pruned this long after its last attempt.
_RETENTION_DAYS = 30
# Per-host serialize lock. TTL is a safety net for a worker that dies holding it
# (normal completion/error releases it in `finally`); sized just past the fetch
@@ -54,6 +61,9 @@ _RETENTION_DAYS = 30
# than one fetch would have taken.
_LOCK_PREFIX = "fc:extdl_lock:"
_LOCK_TTL = 2400
# A link 'downloading' this long has outlived fetch_external_link's hard limit
# (time_limit=3600), so its worker is gone and the sweep recovers it.
_STALL_MINUTES = 65
_SERIALIZE_COUNTDOWN = 120
_MAX_SERIALIZE_WAITS = 30
@@ -106,7 +116,7 @@ def fetch_external_link(self, link_id: int, _serialize_waits: int = 0) -> dict:
ExternalLink.id == link_id,
ExternalLink.status.in_(("pending", "failed")),
)
.values(status="downloading")
.values(status="downloading", started_at=datetime.now(UTC))
.returning(ExternalLink.id)
).first()
session.commit()
@@ -180,6 +190,9 @@ def fetch_external_link(self, link_id: int, _serialize_waits: int = 0) -> dict:
/ str(post.external_post_id) / str(link_id)
)
try:
# Clear the staging files an interrupted attempt left behind. Complete
# files it left stay, and are imported once this fetch succeeds.
_discard_partials(post_dir)
result = fetch_external(host, url, post_dir)
with SessionLocal() as session:
link = session.get(ExternalLink, link_id)
@@ -190,6 +203,7 @@ def fetch_external_link(self, link_id: int, _serialize_waits: int = 0) -> dict:
)
_record_failure(link, attempts, result.error or "fetch failed")
session.commit()
_discard_partials(post_dir)
return {"link_id": link_id, "error": result.error}
log.info(
@@ -201,7 +215,8 @@ def fetch_external_link(self, link_id: int, _serialize_waits: int = 0) -> dict:
post = session.get(Post, link.post_id)
artist = session.get(Artist, post.artist_id)
source = session.get(Source, post.source_id) if post.source_id else None
image_ids = _route_files(session, result.files, post, platform, artist, source)
files = _unimported_files(session, post_dir, first=result.files)
image_ids = _route_files(session, files, post, platform, artist, source)
link.status = "downloaded"
link.last_error = None
link.completed_at = datetime.now(UTC)
@@ -231,6 +246,7 @@ def fetch_external_link(self, link_id: int, _serialize_waits: int = 0) -> dict:
if link is not None and link.status == "downloading":
_record_failure(link, attempts, f"{type(exc).__name__}: {exc}")
session.commit()
_discard_partials(post_dir)
raise
finally:
if lock is not None:
@@ -287,10 +303,9 @@ def _route_files(session, files, post, platform, artist, source) -> list[int]:
post.id, f.name, result.status, len(ids),
)
elif result.status == "attached":
# Non-art / archive captured as a PostAttachment (copied into the
# store) — drop the on-disk original. An archive captured WITHOUT
# extracting any image carries the reason (the recurring "zip but no
# images" symptom, #718) — surface it loudly.
# Non-art, or an archive with no new images, captured as a
# PostAttachment. An archive captured WITHOUT extracting any image
# carries the reason, so the "zip but no images" case is visible.
if result.error:
log.warning(
"extdl archive captured UNEXTRACTED: post=%s file=%s reason=%s",
@@ -298,51 +313,104 @@ def _route_files(session, files, post, platform, artist, source) -> list[int]:
)
else:
log.info("extdl attachment captured: post=%s file=%s", post.id, f.name)
f.unlink(missing_ok=True)
elif result.status == "skipped":
reason = result.skip_reason.value if result.skip_reason else None
log.info(
"extdl skipped: post=%s file=%s reason=%s", post.id, f.name, reason,
)
if reason in ("duplicate_hash", "duplicate_phash"):
# Path-safe unlink: external files are imported IN PLACE, so `f`
# can BE the canonical record's backing file (same content
# re-fetched / a colliding name). Only delete `f` when it is NOT
# the existing record's file — otherwise we orphan the record and
# playback 404s. (#859)
canonical = None
if result.image_id is not None:
rec = session.get(ImageRecord, result.image_id)
if rec is not None:
canonical = (IMAGES_ROOT / rec.path).resolve()
if canonical != f.resolve():
f.unlink(missing_ok=True) # a redundant copy — safe to drop
else:
log.info(
"extdl keep: %s IS record %s's canonical file — not unlinking",
f.name, result.image_id,
)
elif result.status == "failed":
log.warning(
"extdl import FAILED: post=%s file=%s error=%s",
post.id, f.name, result.error,
)
f.unlink(missing_ok=True)
else: # 'refreshed' or any future status — work happened, no new images
log.info(
"extdl import: post=%s file=%s status=%s", post.id, f.name, result.status,
)
# Same policy as the subscription download path: drop the file unless
# the library needs it at this path. A file imported in place is kept.
importer.discard_after_attach(f, result)
# The synthesized sidecar has done its job — don't litter the tree.
Path(str(f) + ".json").unlink(missing_ok=True)
return image_ids
def _unimported_files(session, link_dir: Path, *, first=()) -> list[Path]:
"""Every complete file under a link's dir, subfolders included, that no
ImageRecord points at: what this fetch wrote plus anything an earlier
attempt finished writing but never imported. `first` (the fetch's own
files) leads the list. Staging files and sidecars are left out."""
if not link_dir.is_dir():
return []
found = {
p for p in link_dir.rglob("*")
if p.is_file() and not is_temp_staging_file(p) and p.suffix.lower() != ".json"
}
ordered = [p for p in first if p in found] + sorted(found.difference(first))
recorded = set(session.execute(
select(ImageRecord.path).where(
ImageRecord.path.in_([str(p) for p in ordered])
)
).scalars()) if ordered else set()
return [p for p in ordered if str(p) not in recorded]
def _discard_partials(link_dir: Path) -> int:
"""Delete the staging files under a link's dir (megatools' `.megatmp.*`, an
HTTP fetch's `.part`), then the dirs left empty. Every fetcher writes to a
staging name and renames on completion, so a staging file is a transfer
that never finished. Complete files stay: they may be the only copy of art
whose upstream has since gone. Returns the number of files removed."""
if not link_dir.is_dir():
return 0
removed = 0
for p in link_dir.rglob("*"):
if not (p.is_file() and is_temp_staging_file(p)):
continue
try:
p.unlink(missing_ok=True)
removed += 1
except OSError as exc:
log.warning("extdl could not remove %s: %s", p, exc)
for d in sorted((p for p in link_dir.rglob("*") if p.is_dir()), reverse=True):
try:
d.rmdir()
except OSError:
pass # not empty: it holds a complete file
try:
link_dir.rmdir()
except OSError:
pass
if removed:
log.info("extdl removed %d leftover file(s) from %s", removed, link_dir)
return removed
@celery.task(name="backend.app.tasks.external.sweep_external_links")
def sweep_external_links() -> dict:
"""Enqueue a bounded batch of actionable links (pending, or failed below the
dead-letter threshold). Driven by the post-download hook and a beat tick."""
dead-letter threshold). Driven by the post-download hook and a beat tick.
First it recovers links whose worker died mid-fetch: a link 'downloading'
for longer than the task's hard limit has no worker left, so it counts as a
failed attempt and goes back to the retry path (dead-lettered at the
threshold like any other failure). The next attempt clears its staging
files and imports the complete ones."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
now = datetime.now(UTC)
stale = session.execute(
select(ExternalLink).where(
ExternalLink.status == "downloading",
or_(
ExternalLink.started_at.is_(None),
ExternalLink.started_at < now - timedelta(minutes=_STALL_MINUTES),
),
)
).scalars().all()
for link in stale:
_record_failure(link, link.attempts, "worker stopped mid-fetch")
session.commit()
ids = session.execute(
select(ExternalLink.id).where(
ExternalLink.status.in_(("pending", "failed")),
@@ -351,34 +419,53 @@ def sweep_external_links() -> dict:
).scalars().all()
for link_id in ids:
fetch_external_link.delay(link_id)
return {"enqueued": len(ids)}
return {"enqueued": len(ids), "recovered": len(stale)}
@celery.task(name="backend.app.tasks.external.recover_external_links")
def recover_external_links() -> dict:
"""Recovery sweep (rule #89): reset dead links back to retryable so a stuck
host outage or a since-fixed bug gets another pass, then enqueue. Bounded."""
"""Recovery sweep: give each dead link one more attempt once it
has been dead for 2**recoveries days, then enqueue. A recovered link that
fails again goes straight back to dead, and after _MAX_RECOVERIES it stays
dead until retention prunes it."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
session.execute(
update(ExternalLink).where(ExternalLink.status == "dead")
.values(status="failed", attempts=0, last_error=None)
)
now = datetime.now(UTC)
candidates = session.execute(
select(ExternalLink).where(
ExternalLink.status == "dead",
ExternalLink.recoveries < _MAX_RECOVERIES,
)
).scalars().all()
recovered = 0
for link in candidates:
wait = timedelta(days=2 ** link.recoveries)
if link.completed_at is not None and link.completed_at > now - wait:
continue
link.status = "failed"
link.attempts = DEAD_LETTER_THRESHOLD - 1
link.recoveries += 1
recovered += 1
session.commit()
return sweep_external_links()
return {**sweep_external_links(), "revived": recovered}
@celery.task(name="backend.app.tasks.external.prune_external_links")
def prune_external_links() -> dict:
"""Retention (rule #89): delete long-dead links so the ledger doesn't grow
unbounded. Downloaded links are kept (they're the record of what we have)."""
"""Retention: delete links recovery has given up on, once their
last attempt is _RETENTION_DAYS old, so the ledger doesn't grow unbounded.
Downloaded links are kept (they're the record of what we have)."""
cutoff = datetime.now(UTC) - timedelta(days=_RETENTION_DAYS)
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
res = session.execute(
delete(ExternalLink).where(
ExternalLink.status == "dead",
ExternalLink.created_at < cutoff,
ExternalLink.recoveries >= _MAX_RECOVERIES,
or_(
ExternalLink.completed_at.is_(None),
ExternalLink.completed_at < cutoff,
),
)
)
session.commit()
+33 -3
View File
@@ -19,7 +19,8 @@ from sqlalchemy.exc import DBAPIError, OperationalError
from ..celery_app import celery
from ..models import ImportBatch, ImportSettings, ImportTask
from ..services.importer import Importer
from ..services.importer import Importer, ImportResult
from ..services.library_scan import folder_owner
from ..services.thumbnailer import Thumbnailer
from ._sync_engine import sync_session_factory as _sync_session_factory
@@ -45,8 +46,13 @@ def _mark_failed(session, task, error_msg: str) -> None:
"""Best-effort flip of a 'processing' row to 'failed' + batch counter
increment. Wrapped in its own try because if the DB is what just
broke, this commit will also fail — that's why the maintenance sweep
exists as a backstop."""
exists as a backstop.
The failure being recorded may have left the session's transaction dead
(an error mid-flush), so it is rolled back first; otherwise this commit
fails on it too and the row stays 'processing'."""
try:
session.rollback()
task.status = "failed"
task.error = error_msg
task.finished_at = datetime.now(UTC)
@@ -165,6 +171,27 @@ def enqueue_import(task_id: int, task_type: str) -> None:
import_media_file.delay(task_id)
def _attach_where_it_lies(session, importer: Importer, path: Path) -> ImportResult:
"""Import a file that already sits in its artist's folder (a `library`
batch, from services.library_scan). It is attached in place with that
artist and source. The post record its folder holds (`_post.json`) is filed
first, so the post gets its title and date. Afterwards the file is removed
if the library didn't keep it: a copy of something it already holds, or a
file the import filters reject. A file whose import crashed stays, with the
task marked failed: it may be the only copy of an archive the extractor
can't read yet."""
if not path.is_file():
return ImportResult(status="skipped", error="no longer on disk")
artist, source = folder_owner(session, path, IMAGES_ROOT)
record = path.parent / "_post.json"
if artist is not None and record.is_file():
importer.upsert_post_record(record, artist=artist, source=source)
result = importer.attach_in_place(path, artist=artist, source=source)
if result.status != "failed":
importer.discard_after_attach(path, result)
return result
def _do_import(session, task, import_task_id: int) -> dict:
"""Actual work, called from inside the resilience wrapper."""
settings = ImportSettings.load_sync(session)
@@ -180,7 +207,10 @@ def _do_import(session, task, import_task_id: int) -> dict:
deep=deep,
)
result = importer.import_one(Path(task.source_path))
if batch is not None and batch.scan_mode == "library":
result = _attach_where_it_lies(session, importer, Path(task.source_path))
else:
result = importer.import_one(Path(task.source_path))
if result.status in ("imported", "superseded"):
task.status = "complete"
+108 -32
View File
@@ -20,10 +20,12 @@ from ..models import (
ImportSettings,
ImportTask,
LibraryAuditRun,
PostAttachment,
Source,
TaskRun,
)
from ..utils.phash import compute_phash
from ..utils.staging import is_temp_staging_file
from ._sync_engine import get_sync_engine
from ._sync_engine import sync_session_factory as _sync_session_factory
@@ -81,13 +83,10 @@ WIP_BACKFILL_PAGE = 500
FFPROBE_TIMEOUT_SECONDS = 10
TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h
TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
# Orphaned staging files: downloads/imports stage into <name>.part / <name>.partial
# then os.replace() into place (importer / external_fetch / native_ingest_common /
# attachment_store), so a kill mid-write leaves a discardable temp, never a corrupt
# final. cleanup_orphaned_temp_files sweeps ones left behind; the min-age guard
# keeps it from deleting an in-flight download's staging file mid-write.
# Orphaned staging files (utils.staging): cleanup_orphaned_temp_files sweeps the
# ones a killed write left behind; the min-age guard keeps it from deleting an
# in-flight download's staging file mid-write.
IMAGES_ROOT = Path("/images")
TEMP_STAGING_SUFFIXES = (".part", ".partial")
ORPHAN_TEMP_MIN_AGE_HOURS = 6
# Audit 2026-06-02: per-entity recovery sweep thresholds. Each must be
@@ -169,11 +168,14 @@ QUEUE_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
}
TASK_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
"backend.app.tasks.import_file.import_archive_file": 40,
# Backup images runs hours, not minutes (6.5h hard limit). The
# task-name override beats the queue's 75-min default so a
# legitimately-running backup isn't flagged.
"backend.app.tasks.backup.backup_images_task": 420,
"backend.app.tasks.backup.restore_images_task": 420,
# One media-backup chunk stops starting files after 45 min but may finish
# a large file in flight, up to its 6.5 h hard limit.
"backend.app.tasks.backup.backup_media_task": 420,
# A restore's database load, or one chunk of its file phase, likewise.
"backend.app.tasks.backup.restore_offsite_task": 420,
# A test restore downloads a dump and loads it whole; 4 h 15 min hard
# limit. 260 = 255 + 5.
"backend.app.tasks.backup.restore_test_task": 260,
# Library audit scans the full library — 2h hard limit.
"backend.app.tasks.library_audit.scan_library_for_rule": 130,
# External file-host fetches (mega/gdrive/film packs) can run to the task's
@@ -191,6 +193,9 @@ TASK_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
# 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,
# Importing what's on disk stats every file in the artists' folders, and
# its hard limit is 40 min for the same wedged-filesystem case. 45 = 40 + 5.
"backend.app.tasks.admin.import_unimported_files_task": 45,
# Head training and the manual head apply run to 65 min (3900s) — past the
# ml queue's threshold (#4432). 70 = 65 + 5.
"backend.app.tasks.ml.train_heads": 70,
@@ -198,6 +203,23 @@ TASK_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
}
def _import_queue_depth() -> int | None:
"""Messages waiting in the broker's import queue, or None when the broker
can't be read."""
try:
import redis
from ..config import get_config
client = redis.Redis.from_url(
get_config().celery_broker_url, socket_timeout=5, socket_connect_timeout=5,
)
return int(client.llen("import"))
except Exception: # noqa: BLE001 — an unreadable broker means "don't know"
log.warning("recover_interrupted_tasks: import queue depth unreadable", exc_info=True)
return None
@celery.task(name="backend.app.tasks.maintenance.recover_interrupted_tasks")
def recover_interrupted_tasks() -> int:
"""Recover stuck ImportTask rows. Two distinct stuck states:
@@ -213,16 +235,22 @@ def recover_interrupted_tasks() -> int:
2026-05-24 after a 2224-row zombie pile; archive split out
2026-05-28.)
2. 'pending' or 'queued' > 30 min — enqueue-phase crash. scan_directory
creates rows with status='pending' (commit), then in a second pass
transitions to 'queued' and calls .delay() (commit). If the scanner
crashes between those two commits, rows are orphaned in 'pending'
(never enqueued) with no recovery path — invisible to the
'processing' sweep above. Flagged 2026-05-25 by operator hitting a
5490-row orphan pile. Flip these to 'failed' (not re-enqueue) so
the operator drains them via /api/import/retry-failed at their own
pace; bulk-re-enqueueing 5000+ rows would thundering-herd the
import worker.
2. 'pending' > 30 min — enqueue-phase crash. scan_directory creates rows
with status='pending' (commit), then in a second pass transitions to
'queued' and calls .delay() (commit). If the scanner crashes between
those two commits, rows are orphaned in 'pending' (never enqueued)
with no recovery path — invisible to the 'processing' sweep above.
Flip these to 'failed' (not re-enqueue) so the operator drains them
via /api/import/retry-failed at their own pace; bulk-re-enqueueing
5000+ rows would thundering-herd the import worker.
A 'queued' row has a broker message, and in a batch larger than half
an hour of work it is simply waiting its turn, so its age says
nothing. It counts as orphaned only when the broker's import queue
is empty: then its message is gone (a broker flush or restart) and
nothing will ever run it. When the queue can't be read, queued rows
are left alone — a queued row costs nothing to keep, and failing a
live one loses its import.
Returns total rows touched (recovered + marked failed).
"""
@@ -299,9 +327,12 @@ def recover_interrupted_tasks() -> int:
)
stuck = stuck_result.all()
orphan_statuses = ["pending"]
if _import_queue_depth() == 0:
orphan_statuses.append("queued")
orphan_result = session.execute(
update(ImportTask)
.where(ImportTask.status.in_(["pending", "queued"]))
.where(ImportTask.status.in_(orphan_statuses))
.where(ImportTask.created_at < orphan_cutoff)
.values(
status="failed",
@@ -371,22 +402,58 @@ def cleanup_old_tasks() -> int:
return result.rowcount or 0
def _paths_with_rows(paths: list[str]) -> set[str]:
"""The subset of `paths` that an image (its file or thumbnail) or an
attachment row points at."""
owned: set[str] = set()
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
for i in range(0, len(paths), 500):
chunk = paths[i:i + 500]
owned.update(session.execute(
select(ImageRecord.path).where(ImageRecord.path.in_(chunk))
).scalars())
owned.update(session.execute(
select(ImageRecord.thumbnail_path).where(ImageRecord.thumbnail_path.in_(chunk))
).scalars())
owned.update(session.execute(
select(PostAttachment.path).where(PostAttachment.path.in_(chunk))
).scalars())
return owned
@celery.task(name="backend.app.tasks.maintenance.cleanup_orphaned_temp_files")
def cleanup_orphaned_temp_files() -> int:
"""Delete orphaned .part/.partial staging files under the images root, left by
a download/import killed mid-write. Only removes files older than
ORPHAN_TEMP_MIN_AGE_HOURS so an in-flight download's staging file is never
pulled out from under it. Returns the count removed."""
"""Delete orphaned staging files (.part, .partial, megatools' .megatmp.*)
under the images root, left by a download/import killed mid-write. Only
removes files older than ORPHAN_TEMP_MIN_AGE_HOURS so an in-flight
download's staging file is never pulled out from under it, and never a
file an image or attachment row points at, whatever its name: that file
is library content. Returns the count removed."""
if not IMAGES_ROOT.is_dir():
return 0
cutoff = datetime.now(UTC).timestamp() - ORPHAN_TEMP_MIN_AGE_HOURS * 3600
removed = 0
stale: list[Path] = []
for path in IMAGES_ROOT.rglob("*"):
if path.suffix not in TEMP_STAGING_SUFFIXES or not path.is_file():
if not is_temp_staging_file(path) or not path.is_file():
continue
try:
if path.stat().st_mtime < cutoff: # fresher may be an active download
stale.append(path)
except OSError as exc:
log.warning("cleanup_orphaned_temp_files: %s: %s", path, exc)
if not stale:
return 0
owned = _paths_with_rows([str(p) for p in stale])
removed = 0
for path in stale:
if str(path) in owned:
log.warning(
"cleanup_orphaned_temp_files: keeping %s: a library row points at it",
path,
)
continue
try:
if path.stat().st_mtime >= cutoff:
continue # too fresh — may be an active download
path.unlink()
removed += 1
except OSError as exc:
@@ -777,11 +844,20 @@ def recover_stalled_backup_runs() -> int:
result = session.execute(
update(BackupRun)
.where(BackupRun.status.in_(["running", "restoring"]))
# db backups/restores are fast (40-min window); images run hours (7h).
# db backups/restores are fast (40-min window); a test restore and the
# weekly bucket check get 7 h.
# A media run or an off-site restore spans many chunks and hours on
# purpose, so its window runs from its last progress, not its start.
.where(
or_(
and_(BackupRun.kind == "db", BackupRun.started_at < db_cutoff),
and_(BackupRun.kind != "db", BackupRun.started_at < slow_cutoff),
and_(
BackupRun.kind.in_(["media", "restore"]),
func.coalesce(BackupRun.last_progress_at, BackupRun.started_at)
< slow_cutoff,
),
and_(BackupRun.kind.not_in(["db", "media", "restore"]),
BackupRun.started_at < slow_cutoff),
)
)
.values(status="error", finished_at=now, error=msg)
+15 -1
View File
@@ -175,12 +175,20 @@ def _run_probe(path_str: str) -> tuple[str, str | None]:
return ("ok", None)
def _over_cap(total) -> bool:
return total is not None and total > MAX_ARCHIVE_UNCOMPRESSED_BYTES
def _inspect_archive(path: Path):
"""Return (total_uncompressed_bytes | None, first_bad_member | None)
for the archive. Format detected by extension OR magic bytes (so a
mis-named archive is still bomb-guarded + integrity-tested, matching the
extractor's gate); raises on a structurally-broken container (caught by the
child as a clean rejection)."""
child as a clean rejection).
The size total comes from the archive's directory, so an archive over the
cap is rejected before the integrity test decompresses anything: on a
multi-gigabyte archive that test alone outlasts the probe's timeout."""
from ..services.archive_extractor import detect_archive_format
fmt = detect_archive_format(path)
@@ -189,12 +197,16 @@ def _inspect_archive(path: Path):
with zipfile.ZipFile(path) as zf:
total = sum(zi.file_size for zi in zf.infolist())
if _over_cap(total):
return total, None
return total, zf.testzip()
if fmt == "rar":
import rarfile
with rarfile.RarFile(path) as rf:
total = sum(getattr(ri, "file_size", 0) for ri in rf.infolist())
if _over_cap(total):
return total, None
rf.testrar()
return total, None
if fmt == "7z":
@@ -203,6 +215,8 @@ def _inspect_archive(path: Path):
with py7zr.SevenZipFile(path, "r") as zf:
info = zf.archiveinfo()
total = getattr(info, "uncompressed", None)
if _over_cap(total):
return total, None
ok = zf.test() # True / None when all members pass
return total, (None if ok in (True, None) else "7z test reported corruption")
# Not a recognised archive — nothing to test; treat as clean.
+20
View File
@@ -0,0 +1,20 @@
"""Downloader staging files.
Downloads and imports write into a staging name and `os.replace()` it into
place when the write completes (importer, external_fetch, native_ingest_common,
attachment_store), so a write killed midway leaves a staging file, never a
truncated final file. Any other file is complete.
"""
from pathlib import Path
TEMP_STAGING_SUFFIXES = (".part", ".partial")
# megatools stages a Mega download as `.megatmp.<id>` in the target dir.
TEMP_STAGING_PREFIXES = (".megatmp.",)
def is_temp_staging_file(path: Path) -> bool:
"""True for a downloader's staging file: one that never becomes library
content under that name, so a stale one is safe to delete."""
return path.suffix in TEMP_STAGING_SUFFIXES or path.name.startswith(
TEMP_STAGING_PREFIXES
)
-15
View File
@@ -1,11 +1,6 @@
/**
* Background script — message router + Discord token capture (webRequest).
* Direct port of GS background.js; api.js client points at FC instead of GS.
*
* pixiv's PKCE OAuth flow lived here until FC retired pixiv (milestone #406).
* It was removed together with pixiv's host permissions rather than left
* behind: a webRequest listener on a host the manifest no longer grants is at
* best dead and at worst a startup failure for the whole background script.
*/
let discordToken = null;
@@ -17,19 +12,9 @@ async function ensureInitialized() {
if (initialized) return;
await api.init();
await loadDiscordToken();
await forgetRetiredPixivToken();
initialized = true;
}
// A browser that authenticated pixiv before the retirement still holds a live
// OAuth refresh token in extension storage. Nothing reads it any more, and a
// credential for a service FC no longer uses is a liability with no benefit
// (the same reasoning as the server-side cleanup, issue #3980). Removing keys
// that are absent is a no-op, so this is safe on every startup.
async function forgetRetiredPixivToken() {
await browser.storage.local.remove(['pixivRefreshToken', 'pixivTokenCapturedAt']);
}
browser.runtime.onInstalled.addListener(() => ensureInitialized());
browser.runtime.onStartup.addListener(() => ensureInitialized());
ensureInitialized().catch(e => console.error('init failed:', e));
@@ -58,7 +58,7 @@ import { computed, ref, watch } from 'vue'
const props = defineProps({
modelValue: { type: Boolean, required: true },
action: { type: String, required: true }, // 'restore' | 'delete'
kind: { type: String, required: true }, // 'db' | 'images' | 'artist' | 'tag' | 'images-selection' | 'audit' | 'min-dim'
kind: { type: String, required: true }, // 'db' | 'offsite' | 'artist' | 'tag' | 'images-selection' | 'audit' | 'min-dim'
runId: { type: [Number, String], default: '' }, // numeric id or sha8 string
description: { type: String, default: '' },
tier: { type: String, default: 'C' }, // 'B' | 'C'
@@ -89,6 +89,7 @@ const kindLabel = computed(() => ({
artist: 'artist',
tag: 'tag',
'images-selection': 'image selection',
offsite: 'from the off-site bucket',
}[props.kind] || props.kind))
const alertType = computed(
() => props.action === 'restore' ? 'warning' : 'error',
+112 -11
View File
@@ -2,11 +2,15 @@
<div
ref="canvasEl"
class="fc-canvas"
:class="{ 'fc-canvas--zoomed': panZoom.state.scale > 1 }"
:class="{
'fc-canvas--zoomed': panZoom.state.scale > 1,
'fc-canvas--moving': !panZoom.state.smooth,
}"
@wheel="panZoom.handlers.onWheel"
@pointerdown="panZoom.handlers.onPointerDown"
@pointermove="panZoom.handlers.onPointerMove"
@pointerup="panZoom.handlers.onPointerUp"
@pointercancel="panZoom.handlers.onPointerCancel"
@click="onCanvasClick"
>
<img
@@ -18,6 +22,34 @@
draggable="false"
@load="onImgLoad"
>
<!-- Zoom controls. They sit on the canvas but are not part of it: a press,
click or scroll on them never pans, toggles or closes. -->
<div
class="fc-canvas__zoombar"
@pointerdown.stop @click.stop @wheel.stop
>
<button
type="button" class="fc-canvas__zoombtn" aria-label="Zoom out"
:disabled="panZoom.state.scale <= 1" @click="panZoom.zoomBy(-1)"
><v-icon size="18">mdi-minus</v-icon></button>
<input
type="range" class="fc-canvas__zoomslider" aria-label="Zoom"
min="0" max="1" step="0.001" :value="sliderValue"
@input="onSlider"
>
<button
type="button" class="fc-canvas__zoombtn" aria-label="Zoom in"
:disabled="panZoom.state.scale >= maxScale" @click="panZoom.zoomBy(1)"
><v-icon size="18">mdi-plus</v-icon></button>
<span class="fc-canvas__zoomlabel" :title="'Shown at ' + zoomLabel + ' of its full size'">
{{ zoomLabel }}
</span>
<button
type="button" class="fc-canvas__zoombtn" aria-label="Fit to the window"
title="Fit to the window"
:disabled="panZoom.state.scale <= 1" @click="panZoom.reset()"
><v-icon size="18">mdi-fit-to-screen-outline</v-icon></button>
</div>
<!-- #1206 grounding overlay: tracks the <img>'s live rendered rect (so it's
correct under object-fit letterboxing + pan/zoom) and draws the crop
region a hovered suggestion came from. Null grounding → a subtle
@@ -60,10 +92,37 @@ const props = defineProps({
hover: { type: Object, default: null },
})
const emit = defineEmits(['close-request'])
const panZoom = usePanZoom()
const canvasEl = ref(null)
const imgEl = ref(null)
// Where the picture sits before any zoom: the canvas size, the picture's
// layout box (the zoom is a transform on top of it) and its natural width.
// Read again when the picture loads and when the window resizes.
const fitted = ref(null)
function refit() {
const img = imgEl.value
const canvas = canvasEl.value
fitted.value = img && canvas ? {
cw: canvas.clientWidth, ch: canvas.clientHeight,
iw: img.offsetWidth, ih: img.offsetHeight, nw: img.naturalWidth,
} : null
panZoom.clamp()
}
const panZoom = usePanZoom(() => fitted.value)
// The slider runs on a log scale, so each stretch of it is the same zoom step
// whether the picture is at 1.2x or 12x.
const maxScale = computed(() => panZoom.maxScale())
const sliderValue = computed(() =>
Math.log(panZoom.state.scale) / Math.log(maxScale.value))
function onSlider(ev) {
panZoom.setScale(maxScale.value ** Number(ev.target.value))
}
// How big it is against its full-size pixels.
const zoomLabel = computed(() => {
const g = fitted.value
const pct = g?.iw && g?.nw ? (g.iw * panZoom.state.scale) / g.nw : panZoom.state.scale
return `${Math.round(pct * 100)}%`
})
const overlayBox = ref(null) // the <img>'s rect relative to the canvas
// The <img> renders aspect-fit + transformed, so its getBoundingClientRect is
@@ -107,22 +166,21 @@ watch(
() => [panZoom.state.scale, panZoom.state.x, panZoom.state.y],
() => { if (props.hover) measure() },
)
function onResize () { if (props.hover) measure() }
function onImgLoad () { if (props.hover) measure() }
function onResize () { refit(); if (props.hover) measure() }
function onImgLoad () { refit(); if (props.hover) measure() }
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
// Reset zoom when the src changes (prev/next nav).
watch(() => props.src, () => panZoom.reset())
// Click on the image → toggle zoom (IR/expected behavior). Click on
// the haze area around the image → request close. We use the image's
// own bounding rect rather than @click.self because the <img> sets
// pointer-events: none for the pan-drag pathway, so the click always
// targets the canvas div regardless of where the cursor was.
// Click on the image → toggle zoom. Click on the haze around it → request
// close. The <img> has pointer-events: none so a drag always lands on the
// canvas, which is why "on the image" is worked out from its rect. The click
// that ends a drag does neither.
function onCanvasClick(ev) {
if (panZoom.endedDrag()) return
if (panZoom.state.scale > 1) {
// When zoomed, any click resets zoom — preserve old behavior.
panZoom.handlers.onClick(ev)
return
}
@@ -160,6 +218,49 @@ function onCanvasClick(ev) {
transition: transform 100ms ease-out;
pointer-events: none;
}
.fc-canvas--moving img { transition: none; }
/* Zoom controls, bottom centre of the canvas: quiet until the canvas is
hovered or the picture is zoomed. */
.fc-canvas__zoombar {
position: absolute;
bottom: 16px; left: 50%;
transform: translateX(-50%);
z-index: 3;
display: flex; align-items: center; gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(var(--v-theme-background), 0.7);
border: 1px solid rgb(var(--v-theme-surface-light));
color: rgb(var(--v-theme-on-surface));
cursor: default;
opacity: 0;
transition: opacity 150ms ease;
}
.fc-canvas:hover .fc-canvas__zoombar,
.fc-canvas--zoomed .fc-canvas__zoombar,
.fc-canvas__zoombar:focus-within { opacity: 1; }
.fc-canvas__zoombtn {
width: 28px; height: 28px;
display: grid; place-items: center;
border-radius: 50%;
color: inherit;
cursor: pointer;
}
.fc-canvas__zoombtn:hover:not(:disabled) { background: rgb(var(--v-theme-surface)); }
.fc-canvas__zoombtn:disabled { opacity: 0.35; cursor: default; }
.fc-canvas__zoomslider {
width: 140px;
accent-color: rgb(var(--v-theme-accent));
cursor: pointer;
}
.fc-canvas__zoomlabel {
min-width: 44px;
text-align: center;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: rgb(var(--v-theme-on-surface-variant));
}
/* Grounding overlay — never intercepts pan/zoom/click. */
.fc-canvas__overlay {
@@ -18,6 +18,9 @@
<h3>Keyboard shortcuts</h3>
<dl>
<div><dt>← / →</dt><dd>Previous / next image</dd></div>
<div><dt>Click</dt><dd>Zoom in (a long strip opens at full width) / back to fit</dd></div>
<div><dt>Scroll · drag</dt><dd>Move around a zoomed image</dd></div>
<div><dt>Ctrl + scroll</dt><dd>Zoom at the pointer</dd></div>
<div><dt>T or /</dt><dd>Jump to the tag input</dd></div>
<div><dt>↑ / ↓</dt><dd>Move through tag suggestions</dd></div>
<div><dt>Enter / Tab</dt><dd>Accept the highlighted tag</dd></div>
@@ -1,17 +1,15 @@
<template>
<!-- #713: re-extract PostAttachments that are really archives but were filed
opaquely before the magic-byte gate, and attach their images to the post. -->
<MaintenanceTile
icon="mdi-folder-zip-outline"
title="Re-extract archives"
blurb="Extract images from zip attachments filed opaquely."
blurb="Import the images inside archive attachments that were never extracted."
:open="busy"
>
<p class="text-body-2 mb-3">
Some posts attached an archive (zip) whose images weren't extracted
because the downloaded file had no usable extension. This scans existing
attachments, extracts any that are really archives, and attaches their
images to the post. Idempotent — safe to run more than once.
Goes through every stored zip, rar and 7z, including ones imported from
a folder without a post, and imports the images inside. Archives already
extracted are passed over, and game or app builds stay whole. Safe to
run again.
</p>
<v-btn color="primary" rounded="pill" :loading="busy" @click="run">
<v-icon start>mdi-folder-zip-outline</v-icon> Re-extract archives now
+247 -37
View File
@@ -2,13 +2,19 @@
<MaintenanceTile
icon="mdi-database-export"
title="Backups"
blurb="Database + image backups, schedules and restore."
blurb="Database and library backups, schedules and restore."
>
<p class="fc-muted text-body-2 mb-4">
pg_dump for the database (fast, nightly-schedulable);
tar+zstd for the images (slow, manual only). Files live in
<code>/images/_backups/</code>. Tag a backup to protect it
from autoprune.
<p v-if="offsite" class="fc-muted text-body-2 mb-4">
Database dumps stream to the <strong>{{ offsite.bucket }}</strong> bucket
at {{ offsite.endpoint }}, with no copy kept on this server; the bucket's
own retention thins old dumps. Images and attachments go to the same
bucket's <code>media/</code> folder, below.
</p>
<p v-else class="fc-muted text-body-2 mb-4">
Database dumps are written to <code>/images/_backups/</code> on this
server; tag one to keep it past Keep last. Images and attachments are
backed up only to an off-site destination: set one up above to back up
the whole library, database included, off this server.
</p>
<v-alert
@@ -18,6 +24,21 @@
{{ store.lastError }}
</v-alert>
<!-- Whether the backups are keeping up: the same verdict as the Backups
row on the System tab and the dot beside the brand. -->
<v-alert
v-if="backupPart && backupPart.state !== 'ok'"
type="warning" variant="tonal" density="compact" class="mb-3"
>{{ backupPart.detail }}.</v-alert>
<div
v-else-if="backupPart" class="d-flex align-center text-body-2 mb-3"
style="font-variant-numeric: tabular-nums;"
>
<v-icon size="small" color="success" class="mr-2">mdi-check-circle</v-icon>
{{ backupPart.detail }}<template v-if="newestDump?.size_bytes">,
{{ humanBytes(newestDump.size_bytes) }}</template>.
</div>
<!-- Database section -->
<h3 class="fc-section-title">Database</h3>
<div class="fc-settings-row mb-3">
@@ -40,6 +61,10 @@
@update:model-value="onSettingChange('backup_db_keep_last_n', Number($event))"
/>
</div>
<p v-if="offsite" class="fc-muted text-caption mb-3">
Nightly sends the database, then any new images and attachments. Keep
last applies to this list and to any dumps still on this server.
</p>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-database-arrow-up" class="mb-3"
@@ -52,81 +77,261 @@
@restore="onRestore"
@delete="onDelete"
@tag="onTag"
@test="(r) => testSection?.start({ runId: r.id })"
/>
<v-divider class="my-4" />
<!-- Images section -->
<h3 class="fc-section-title">Images</h3>
<div class="fc-settings-row mb-3">
<v-text-field
:model-value="settings.backup_images_keep_last_n"
label="Keep last" type="number" min="1" max="100"
density="compact" hide-details style="max-width: 110px;"
@update:model-value="onSettingChange('backup_images_keep_last_n', Number($event))"
<BackupTestSection ref="testSection" :bucket="offsite?.bucket || ''" @started="pollOnce" />
<v-divider class="my-4" />
<!-- Off-site: images and attachments as one file per fingerprint -->
<template v-if="offsite">
<h3 class="fc-section-title">Images and attachments</h3>
<p class="fc-muted text-body-2 mb-2">
Each file goes to the bucket's <code>media/</code> folder once, named by
its fingerprint, and the bucket checks it against the fingerprint taken
at import. Only files not sent yet go in each run. With Nightly on, they
follow the database dump every night.
</p>
<div v-if="coverage" class="mb-3">
<div class="text-body-2 mb-1" style="font-variant-numeric: tabular-nums;">
{{ coverage.files_sent.toLocaleString() }} of
{{ coverage.files_total.toLocaleString() }} files in the bucket
({{ humanBytes(coverage.bytes_sent) }} of {{ humanBytes(coverage.bytes_total) }})
</div>
<v-progress-linear
:model-value="coveragePct" color="accent" height="6" rounded
/>
</div>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-cloud-upload-outline" class="mb-3"
:loading="mediaTriggering" :disabled="mediaRunning"
@click="onTrigger('media')"
>Send new files now</v-btn>
<span v-if="mediaRunning" class="text-caption fc-muted ml-2">
A run is going: {{ runningSent.toLocaleString() }} files
({{ humanBytes(runningMedia.size_bytes || 0) }}) sent so far.
</span>
<p class="fc-muted text-caption mb-2" style="font-variant-numeric: tabular-nums;">
{{ lastCheckLine }}
</p>
<BackupRunsTable :runs="store.mediaRuns" />
<v-divider class="my-4" />
<!-- Off-site: restoring this install from the bucket -->
<h3 class="fc-section-title">Restore from the bucket</h3>
<p class="fc-muted text-body-2 mb-2">
Replace this install's database with one of the bucket's dumps, then
fetch every image and attachment back to where it belongs, each
checked against its fingerprint as it arrives. The database being
replaced is saved to the bucket first, so a restore can be undone.
Account logins aren't in the backup: sign in to each again afterwards.
</p>
<p class="fc-muted text-body-2 mb-2">
To repair a library that lost files, fetch only what's missing from
disk; the database stays as it is.
</p>
<div class="fc-settings-row mb-3">
<v-btn
color="warning" variant="tonal" rounded="pill"
prepend-icon="mdi-database-arrow-down"
:disabled="anyGoing" :loading="dumpsLoading"
@click="openDumps"
>Choose a dump…</v-btn>
<v-btn
variant="tonal" rounded="pill"
prepend-icon="mdi-cloud-download-outline"
:disabled="anyGoing" :loading="fetchTriggering"
@click="onFetchMissing"
>Fetch missing files</v-btn>
</div>
<v-alert
v-if="restoreDispatched || runningRestore"
type="info" variant="tonal" density="compact" class="mb-3"
>
<template v-if="runningRestore?.manifest?.phase === 'files'">
Fetching files: {{ restoreLine(runningRestore.manifest) }}.
</template>
<template v-else>
Restoring the database. While it loads, this page may stop answering
for a few minutes; it comes back on its own.
</template>
</v-alert>
<BackupRunsTable :runs="store.restoreRuns" empty-text="No restores yet." />
<DumpPickerDialog
v-model="dumpsOpen" action="restore"
:bucket="offsite.bucket" :dumps="dumps" :error="dumpsError"
@pick="onPickDump"
/>
</div>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-folder-zip" class="mb-3"
:loading="imagesTriggering"
@click="onTrigger('images')"
>Run images backup now</v-btn>
<span class="text-caption fc-muted ml-2">
ⓘ Hours for a 200GB+ library.
</span>
</template>
<BackupRunsTable
:runs="store.imagesRuns"
@restore="onRestore"
@delete="onDelete"
@tag="onTag"
/>
<BackupConfirmModal
v-model="confirmOpen"
:action="confirmAction"
:kind="confirmKind"
:run-id="confirmRunId"
:description="confirmDescription"
:expected-token-override="confirmToken"
@confirm="onConfirmSubmit"
/>
</MaintenanceTile>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useBackupStore } from '../../stores/backup.js'
import { useSystemHealthStore } from '../../stores/systemHealth.js'
import BackupConfirmModal from '../modal/DestructiveConfirmModal.vue'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import { humanBytes } from '../../utils/bytes.js'
import { formatDumpTime, formatRelative } from '../../utils/date.js'
import BackupRunsTable from './BackupRunsTable.vue'
import BackupTestSection from './BackupTestSection.vue'
import DumpPickerDialog from './DumpPickerDialog.vue'
const store = useBackupStore()
const healthStore = useSystemHealthStore()
// The server's verdict on the backups; absent when none are set up.
const backupPart = computed(() => healthStore.parts.find((p) => p.key === 'backup'))
const newestDump = computed(() => store.dbRuns.find((r) => r.status === 'ok'))
// The weekly check of the bucket against what was sent.
const lastCheckLine = computed(() => {
const r = store.reconcileRuns.find((x) => x.status !== 'running')
if (!r) return 'The bucket is checked against what was sent once a week; no check has run yet.'
const when = formatRelative(r.finished_at || r.started_at)
if (r.status === 'error') return `The last bucket check (${when}) failed: ${r.error}`
const n = (k) => (r.manifest?.[k] || 0).toLocaleString()
const missing = r.manifest?.dropped
? `${n('dropped')} missing, sent again by the next run`
: 'none missing'
return `Bucket checked ${when}: ${n('listed')} files there, ${missing}.`
})
const settings = computed(() => store.settings || {
backup_db_nightly_enabled: false,
backup_db_nightly_hour_utc: 3,
backup_db_keep_last_n: 14,
backup_images_keep_last_n: 3,
})
// The off-site destination, once it is fully set up: database dumps go there.
const offsite = computed(() => (
store.destination?.configured ? store.destination : null
))
const coverage = computed(() => store.mediaCoverage)
const coveragePct = computed(() => {
const c = coverage.value
return c && c.files_total ? (100 * c.files_sent) / c.files_total : 0
})
const runningMedia = computed(() => store.mediaRuns.find((r) => r.status === 'running'))
const mediaRunning = computed(() => Boolean(runningMedia.value))
const runningSent = computed(() => runningMedia.value?.manifest?.sent || 0)
const mediaTriggering = ref(false)
const dbTriggering = ref(false)
const imagesTriggering = ref(false)
const confirmOpen = ref(false)
const confirmAction = ref('restore')
const confirmKind = ref('db')
const confirmRunId = ref(0)
const confirmDescription = ref('')
const confirmToken = ref('')
// Restoring from the bucket.
const runningRestore = computed(() => store.restoreRuns.find((r) => r.status === 'restoring'))
const anyGoing = computed(() => Boolean(
runningRestore.value || mediaRunning.value
|| store.dbRuns.some((r) => r.status === 'running'),
))
const restoreDispatched = ref(false)
const dumpsOpen = ref(false)
const dumpsLoading = ref(false)
const dumpsError = ref('')
const dumps = ref([])
const pickedDump = ref(null)
const fetchTriggering = ref(false)
const testSection = ref(null)
// The notice that a restore was asked for stays up until its run is over.
watch(runningRestore, (now, before) => {
if (before && !now) restoreDispatched.value = false
})
function restoreLine(m) {
const n = (k) => (m?.[k] || 0).toLocaleString()
return `${n('restored')} restored, ${n('copied')} copied from a duplicate, `
+ `${n('present')} already there, ${n('missing')} not in the bucket, ${n('failed')} failed`
}
async function openDumps() {
dumpsLoading.value = true
dumpsError.value = ''
try {
dumps.value = await store.listOffsiteDumps()
} catch (e) {
dumps.value = []
dumpsError.value = e.message
} finally {
dumpsLoading.value = false
dumpsOpen.value = true
}
}
function onPickDump(d) {
pickedDump.value = d
dumpsOpen.value = false
confirmAction.value = 'restore'
confirmKind.value = 'offsite'
confirmRunId.value = ''
confirmToken.value = d.confirm
confirmDescription.value = (
`Replaces this install's database with the dump taken ${formatDumpTime(d.ts)}, `
+ 'then fetches the library\'s files from the bucket.'
)
confirmOpen.value = true
}
async function onFetchMissing() {
fetchTriggering.value = true
try {
await store.fetchMissingFiles()
pollOnce()
} catch {
// store.lastError is shown above.
} finally {
fetchTriggering.value = false
}
}
let pollId = null
let polls = 0
// Coverage counts every library file, so it refreshes every sixth poll
// (30 s) rather than with the run lists.
const COVERAGE_EVERY = 6
function pollOnce() {
if (document.hidden) return
store.loadRuns('db')
store.loadRuns('images')
store.loadRuns('test')
if (offsite.value) {
store.loadRuns('media')
store.loadRuns('restore')
if (polls++ % COVERAGE_EVERY === 0) {
store.loadMediaCoverage()
store.loadRuns('reconcile')
}
}
}
onMounted(() => {
store.loadSettings()
healthStore.refresh()
if (!store.destination) store.loadDestination().catch(() => {})
pollOnce()
pollId = setInterval(pollOnce, 5000)
})
@@ -135,11 +340,12 @@ onUnmounted(() => {
})
async function onTrigger(kind) {
const triggeringRef = kind === 'db' ? dbTriggering : imagesTriggering
const triggeringRef = { db: dbTriggering, media: mediaTriggering }[kind]
triggeringRef.value = true
try {
await store.triggerBackup(kind)
pollOnce()
healthStore.refresh()
} finally {
triggeringRef.value = false
}
@@ -153,9 +359,10 @@ function _openConfirm(action, run) {
confirmAction.value = action
confirmKind.value = run.kind
confirmRunId.value = run.id
confirmToken.value = ''
confirmDescription.value = (
action === 'restore'
? `Source artifact: ${run.kind === 'db' ? run.sql_path : run.tar_path}`
? `Source artifact: ${run.sql_path}`
: 'Removes the BackupRun row and unlinks the artifact files.'
)
confirmOpen.value = true
@@ -176,7 +383,10 @@ async function onTag(run) {
async function onConfirmSubmit(token) {
try {
if (confirmAction.value === 'restore') {
if (confirmKind.value === 'offsite') {
await store.restoreFromOffsite(pickedDump.value.key, token)
restoreDispatched.value = true
} else if (confirmAction.value === 'restore') {
await store.restore(confirmRunId.value, token)
} else {
await store.deleteRun(confirmRunId.value, token)
@@ -0,0 +1,217 @@
<!--
Off-site backup destination: an S3 bucket (Fabled Archives or any S3
service) that backups are sent to. The five fields only work together, so
they save as one with an explicit button rather than field by field. The
secret key is write-only: once saved it is never shown again, and leaving the
field blank keeps it. Test destination lists the bucket with what is typed
in the form (saved or not) and writes nothing.
-->
<template>
<MaintenanceTile
icon="mdi-cloud-upload-outline"
title="Off-site backup destination"
blurb="The S3 bucket (Fabled Archives or any S3 service) that backups are sent to."
>
<p class="fc-muted text-body-2 mb-3">
Backups sent here survive losing this server. FabledCurator only adds to the
bucket and never deletes from it, so a key that may add and read objects but
not delete them is enough, and is the safer key to use. Account logins are
never sent.
</p>
<v-alert
v-if="loadError" type="warning" variant="tonal" density="compact" class="mb-3"
>{{ loadError }}</v-alert>
<v-text-field
v-model="form.endpoint" label="Address"
placeholder="http://archives.example:8081" density="compact"
hint="Include the port the S3 service listens on." persistent-hint
class="mb-3" :disabled="busy"
/>
<div class="fc-dest-row mb-3">
<v-text-field
v-model="form.bucket" label="Bucket" density="compact" hide-details
:disabled="busy"
/>
<v-text-field
v-model="form.region" label="Region" density="compact" hide-details
class="fc-dest-region" :disabled="busy"
/>
</div>
<v-text-field
v-model="form.access_key" label="Access key" density="compact" hide-details
autocomplete="off" class="mb-3" :disabled="busy"
/>
<v-text-field
v-model="form.secret_key" label="Secret key" type="password"
autocomplete="new-password" density="compact"
:placeholder="secretSet ? 'Saved. Leave blank to keep it.' : ''"
:persistent-placeholder="secretSet"
:hint="secretSet ? 'A saved secret is never shown again.' : ''" persistent-hint
class="mb-3" :disabled="busy"
/>
<v-text-field
v-model.number="form.upload_limit_mbps" label="Upload limit (Mbit/s)"
type="number" min="0" density="compact" class="fc-dest-limit mb-3"
hint="0 for no limit. A lower limit leaves more of the link free, and makes each upload take longer."
persistent-hint :disabled="busy"
/>
<div class="d-flex align-center flex-wrap mb-2" style="gap: 8px;">
<v-btn
size="small" variant="flat" color="accent" rounded="pill"
prepend-icon="mdi-content-save" :loading="saving"
:disabled="busy || !dirty" @click="onSave"
>Save</v-btn>
<v-btn
size="small" variant="tonal" rounded="pill"
prepend-icon="mdi-lan-connect" :loading="testing"
:disabled="busy || !form.endpoint" @click="onTest"
>Test destination</v-btn>
<v-btn
v-if="configured" size="small" variant="text" rounded="pill" color="error"
:disabled="busy" @click="confirmRemove = true"
>Remove</v-btn>
<span v-if="savedNote" class="text-caption text-success">{{ savedNote }}</span>
</div>
<v-alert
v-if="testResult" :type="testResult.ok ? 'success' : 'error'"
variant="tonal" density="compact" class="mb-1"
>{{ testResult.message }}</v-alert>
<p v-if="!configured && !testResult" class="fc-muted text-caption mb-0">
No destination is set up, so nothing is sent off this server yet.
</p>
<p v-else-if="configured && !testResult" class="fc-muted text-caption mb-0">
Database backups and the library's images and attachments go to this bucket.
Set when they run under Backups below.
</p>
<v-dialog v-model="confirmRemove" max-width="440">
<v-card>
<v-card-title class="text-h6">Remove the destination?</v-card-title>
<v-card-text class="text-body-2">
FabledCurator forgets the address and both keys and stops sending
backups there. What was already sent stays in the bucket.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="confirmRemove = false">Cancel</v-btn>
<v-btn color="error" variant="flat" :loading="saving" @click="onRemove">
Remove
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</MaintenanceTile>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useBackupStore } from '../../stores/backup.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
const store = useBackupStore()
const FIELDS = ['endpoint', 'bucket', 'access_key', 'region']
const DEFAULT_LIMIT = 200
const form = reactive({
endpoint: '', bucket: '', access_key: '', region: 'us-east-1', secret_key: '',
upload_limit_mbps: DEFAULT_LIMIT,
})
const saved = ref(null)
const loadError = ref('')
const saving = ref(false)
const testing = ref(false)
const testResult = ref(null)
const savedNote = ref('')
const confirmRemove = ref(false)
const busy = computed(() => saving.value || testing.value)
const secretSet = computed(() => Boolean(saved.value?.secret_set))
const configured = computed(() => Boolean(saved.value?.configured))
const dirty = computed(() => (
!saved.value
|| form.secret_key.trim() !== ''
|| FIELDS.some((f) => (form[f] || '').trim() !== (saved.value[f] || ''))
|| form.upload_limit_mbps !== saved.value.upload_limit_mbps
))
function apply(dest) {
saved.value = dest
for (const f of FIELDS) form[f] = dest[f] || (f === 'region' ? 'us-east-1' : '')
form.upload_limit_mbps = dest.upload_limit_mbps ?? DEFAULT_LIMIT
form.secret_key = ''
}
function fields() {
const out = {}
for (const f of FIELDS) out[f] = form[f]
if (form.secret_key.trim()) out.secret_key = form.secret_key
const limit = Number(form.upload_limit_mbps)
if (Number.isInteger(limit) && limit >= 0) out.upload_limit_mbps = limit
return out
}
function messageOf(e) {
return e?.body?.detail || e?.message || 'The request failed.'
}
onMounted(async () => {
try {
apply(await store.loadDestination())
} catch (e) {
loadError.value = `Couldn't load the destination: ${messageOf(e)}`
}
})
async function onSave() {
saving.value = true
savedNote.value = ''
testResult.value = null
try {
apply(await store.saveDestination(fields()))
savedNote.value = 'Saved.'
} catch (e) {
testResult.value = { ok: false, message: `Not saved: ${messageOf(e)}` }
} finally {
saving.value = false
}
}
async function onTest() {
testing.value = true
savedNote.value = ''
testResult.value = null
try {
testResult.value = await store.testDestination(fields())
} catch (e) {
testResult.value = { ok: false, message: messageOf(e) }
} finally {
testing.value = false
}
}
async function onRemove() {
saving.value = true
try {
apply(await store.removeDestination())
testResult.value = null
savedNote.value = 'Removed.'
confirmRemove.value = false
} catch (e) {
testResult.value = { ok: false, message: `Not removed: ${messageOf(e)}` }
} finally {
saving.value = false
}
}
</script>
<style scoped>
.fc-dest-row { display: flex; gap: 12px; flex-wrap: wrap; }
.fc-dest-row > :first-child { flex: 1 1 200px; }
.fc-dest-region { flex: 0 1 160px; }
.fc-dest-limit { max-width: 260px; }
</style>
@@ -4,6 +4,7 @@
<tr>
<th>When</th>
<th>Status</th>
<th>Saved to</th>
<th class="text-right">Duration</th>
<th class="text-right">Size</th>
<th>Tag</th>
@@ -11,7 +12,8 @@
</tr>
</thead>
<tbody>
<tr v-for="r in runs" :key="r.id">
<template v-for="r in runs" :key="r.id">
<tr>
<td class="fc-tabular" :title="r.started_at">
{{ formatRelative(r.started_at) }}
</td>
@@ -20,6 +22,21 @@
{{ statusIcon(r.status) }}
</v-icon>
{{ r.status }}
<div
v-if="r.error" class="fc-run-error text-caption text-error" :title="r.error"
>{{ r.error }}</div>
<v-btn
v-if="failures(r).length"
size="x-small" variant="text" color="error" class="px-1"
:append-icon="open[r.id] ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click="open[r.id] = !open[r.id]"
>{{ failedLabel(r) }}</v-btn>
</td>
<td :title="r.offsite ? `${r.offsite.endpoint} · ${r.offsite.key}` : ''">
<template v-if="r.offsite">
<v-icon size="small" class="mr-1">mdi-cloud-check-outline</v-icon>{{ r.offsite.bucket }}
</template>
<span v-else class="fc-muted">This server</span>
</td>
<td class="text-right fc-tabular">{{ formatDuration(r) }}</td>
<td class="text-right fc-tabular">{{ formatBytes(r.size_bytes) }}</td>
@@ -30,7 +47,8 @@
<span v-else class="fc-muted">—</span>
</td>
<td class="text-right">
<KebabMenu :label="`Actions for backup ${r.id}`">
<span v-if="r.offsite || !actions" class="fc-muted">—</span>
<KebabMenu v-else :label="`Actions for backup ${r.id}`">
<v-list-item
:title="r.tag ? 'Untag' : 'Tag…'"
@click="$emit('tag', r)"
@@ -39,6 +57,11 @@
title="Restore…" :disabled="r.status !== 'ok'"
@click="$emit('restore', r)"
/>
<v-list-item
v-if="r.kind === 'db'"
title="Test restore" :disabled="r.status !== 'ok'"
@click="$emit('test', r)"
/>
<v-list-item
title="Delete…"
@click="$emit('delete', r)"
@@ -46,19 +69,60 @@
</KebabMenu>
</td>
</tr>
<!-- The files a run couldn't send or fetch, each with what went wrong.
The run keeps the first few; the count covers them all. -->
<tr v-if="open[r.id]" class="fc-failures">
<td colspan="7">
<ul class="fc-failures__list text-caption">
<li v-for="(f, i) in failures(r)" :key="i">
<code :title="f.path">{{ f.path }}</code>
<span class="text-error"> — {{ f.error }}</span>
</li>
</ul>
<div v-if="moreFailed(r)" class="text-caption fc-muted">
and {{ moreFailed(r).toLocaleString() }} more.
</div>
</td>
</tr>
</template>
<tr v-if="!runs.length">
<td colspan="6" class="text-center fc-muted py-4">No backups yet.</td>
<td colspan="7" class="text-center fc-muted py-4">{{ emptyText }}</td>
</tr>
</tbody>
</v-table>
</template>
<script setup>
import { reactive } from 'vue'
import { formatRelative as fmtRelative } from '../../utils/date.js'
import KebabMenu from '../common/KebabMenu.vue'
defineProps({ runs: { type: Array, default: () => [] } })
defineEmits(['restore', 'delete', 'tag'])
defineProps({
runs: { type: Array, default: () => [] },
emptyText: { type: String, default: 'No backups yet.' },
// False for runs that are records, not backups (tests): no Tag, Restore
// or Delete menu.
actions: { type: Boolean, default: true },
})
defineEmits(['restore', 'delete', 'tag', 'test'])
// Which runs have their failed-file list open.
const open = reactive({})
function failures(r) {
return r.manifest?.failures || []
}
function failedCount(r) {
return r.manifest?.failed || failures(r).length
}
function moreFailed(r) {
return Math.max(0, failedCount(r) - failures(r).length)
}
function failedLabel(r) {
const n = failedCount(r)
return `${n.toLocaleString()} file${n === 1 ? '' : 's'} failed`
}
function statusIcon(s) {
return {
@@ -97,4 +161,13 @@ function formatRelative(iso) {
<style scoped>
.fc-backup-table { background: transparent; }
.fc-tabular { font-variant-numeric: tabular-nums; }
.fc-failures td { border-top: 0 !important; }
.fc-failures__list { margin: 0 0 6px; padding-left: 18px; }
.fc-failures__list code {
display: inline-block; max-width: 420px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom;
}
.fc-run-error {
max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
</style>
@@ -0,0 +1,197 @@
<template>
<div>
<h3 class="fc-section-title">Test a backup</h3>
<p class="fc-muted text-body-2 mb-2">
Load a database dump into a separate, temporary database, check that every
table came back with the rows it held when the dump was taken, and bring it
up to this version. The temporary database is then deleted. This install's
database and library aren't touched.
<template v-if="store.databaseBytes">
It needs about {{ humanBytes(store.databaseBytes) }} free on the
database's disk while it runs.
</template>
</p>
<div class="fc-settings-row mb-3">
<v-btn
color="accent" variant="tonal" rounded="pill"
prepend-icon="mdi-database-check-outline"
:disabled="blocked || (!bucket && !newestLocal)" :loading="starting"
@click="startNewest"
>Test the newest dump</v-btn>
<v-btn
v-if="bucket"
variant="text" rounded="pill"
:disabled="blocked" :loading="dumpsLoading"
@click="openDumps"
>Choose a dump…</v-btn>
</div>
<v-alert
v-if="running || dispatched"
type="info" variant="tonal" density="compact" class="mb-3"
>
Testing the dump<template v-if="running?.manifest?.dump?.ts">
taken {{ formatDumpTime(running.manifest.dump.ts) }}</template>:
{{ phaseText }}.
</v-alert>
<!-- The newest finished test: its verdict, and every table on request. -->
<div v-if="last" class="mb-3">
<div class="d-flex align-start text-body-2">
<v-icon
size="small" class="mr-2 mt-1"
:color="last.status === 'ok' ? 'success' : 'error'"
>{{ last.status === 'ok' ? 'mdi-check-circle' : 'mdi-close-circle' }}</v-icon>
<div>
<strong>Last test</strong>
({{ formatRelative(last.finished_at) }}<template v-if="last.manifest?.dump?.ts">,
dump taken {{ formatDumpTime(last.manifest.dump.ts) }}</template>):
{{ last.manifest?.summary || last.error }}
<div v-if="upgradeLine" class="text-caption fc-muted">{{ upgradeLine }}</div>
</div>
</div>
<v-btn
v-if="tables.length"
size="x-small" variant="text" class="mt-1"
:append-icon="showTables ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click="showTables = !showTables"
>{{ showTables ? 'Hide' : 'Show' }} each table</v-btn>
<v-table v-if="showTables" density="compact" class="fc-test-tables mt-1">
<thead>
<tr>
<th>Table</th>
<th class="text-right">In the dump</th>
<th class="text-right">Restored</th>
<th class="text-right">Live now</th>
</tr>
</thead>
<tbody>
<tr
v-for="t in tables" :key="t.table"
:class="{ 'text-error': mismatched.has(t.table) }"
>
<td><code>{{ t.table }}</code></td>
<td class="text-right">{{ num(t.dump) }}</td>
<td class="text-right">{{ num(t.restored) }}</td>
<td class="text-right fc-muted">{{ num(t.live) }}</td>
</tr>
</tbody>
</v-table>
</div>
<BackupRunsTable :runs="store.testRuns" :actions="false" empty-text="No tests yet." />
<DumpPickerDialog
v-if="bucket"
v-model="dumpsOpen" action="test"
:bucket="bucket" :dumps="dumps" :error="dumpsError"
@pick="onPick"
/>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useBackupStore } from '../../stores/backup.js'
import { humanBytes } from '../../utils/bytes.js'
import { formatDumpTime, formatRelative } from '../../utils/date.js'
import BackupRunsTable from './BackupRunsTable.vue'
import DumpPickerDialog from './DumpPickerDialog.vue'
const props = defineProps({
// The off-site bucket's name when one is set up; otherwise the dumps on
// this server are tested.
bucket: { type: String, default: '' },
})
const emit = defineEmits(['started'])
const store = useBackupStore()
const running = computed(() => store.testRuns.find((r) => r.status === 'running'))
const last = computed(() => store.testRuns.find((r) => r.status !== 'running'))
const restoring = computed(() => store.restoreRuns.some((r) => r.status === 'restoring'))
const blocked = computed(() => Boolean(running.value || restoring.value))
const newestLocal = computed(() => store.dbRuns.find((r) => r.status === 'ok' && !r.offsite))
const PHASES = {
fetching: 'downloading it from the bucket',
loading: 'loading it into a temporary database',
comparing: 'counting every table',
upgrading: 'bringing it up to this version',
}
const phaseText = computed(() => PHASES[running.value?.manifest?.phase] || 'starting')
const tables = computed(() => last.value?.manifest?.tables || [])
const mismatched = computed(() => new Set(last.value?.manifest?.mismatched || []))
const showTables = ref(false)
const upgradeLine = computed(() => {
const m = last.value?.manifest
if (!m?.alembic_now) return ''
return m.alembic_dump === m.alembic_now
? 'Its schema is already this version\'s.'
: `Its schema was brought up from ${m.alembic_dump || 'none'} to ${m.alembic_now}.`
})
function num(n) {
return n == null ? '—' : n.toLocaleString()
}
// "Testing…" shows from the click until the run appears.
const dispatched = ref(false)
watch(running, (now) => { if (now) dispatched.value = false })
const starting = ref(false)
async function start(what) {
starting.value = true
try {
await store.testDump(what)
dispatched.value = true
emit('started')
} catch {
// store.lastError is shown on the card.
} finally {
starting.value = false
}
}
function startNewest() {
start(props.bucket ? {} : { runId: newestLocal.value.id })
}
const dumpsOpen = ref(false)
const dumpsLoading = ref(false)
const dumpsError = ref('')
const dumps = ref([])
async function openDumps() {
dumpsLoading.value = true
dumpsError.value = ''
try {
dumps.value = await store.listOffsiteDumps()
} catch (e) {
dumps.value = []
dumpsError.value = e.message
} finally {
dumpsLoading.value = false
dumpsOpen.value = true
}
}
function onPick(d) {
dumpsOpen.value = false
start({ key: d.key })
}
defineExpose({ start })
</script>
<style scoped>
.fc-section-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 16px; font-weight: 500;
color: rgb(var(--v-theme-on-surface));
margin-bottom: 8px;
}
.fc-settings-row {
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
}
.fc-test-tables { background: transparent; font-variant-numeric: tabular-nums; }
</style>
@@ -0,0 +1,72 @@
<template>
<!-- The database dumps in the off-site bucket, newest first, each with one
action: restore it, or test it. -->
<v-dialog :model-value="modelValue" max-width="560" @update:model-value="$emit('update:modelValue', $event)">
<v-card>
<v-card-title>Dumps in {{ bucket }}</v-card-title>
<v-card-text>
<v-alert
v-if="error" type="warning" variant="tonal" density="compact" class="mb-3"
>{{ error }}</v-alert>
<template v-else>
<v-table density="compact">
<thead>
<tr><th>Taken</th><th class="text-right">Size</th><th /></tr>
</thead>
<tbody>
<tr v-for="d in dumps" :key="d.key">
<td :title="d.key">
{{ formatDumpTime(d.ts) }}
<v-icon
v-if="d.counted" size="x-small" class="ml-1" color="success"
title="Its row counts were recorded, so a test checks every table exactly"
>mdi-check-decagram-outline</v-icon>
</td>
<td class="text-right fc-tabular">{{ humanBytes(d.size_bytes) }}</td>
<td class="text-right">
<v-btn
size="small" variant="text" :color="action === 'restore' ? 'warning' : 'accent'"
@click="$emit('pick', d)"
>{{ action === 'restore' ? 'Restore…' : 'Test' }}</v-btn>
</td>
</tr>
<tr v-if="!dumps.length">
<td colspan="3" class="text-center fc-muted py-4">
The bucket holds no database dumps yet.
</td>
</tr>
</tbody>
</v-table>
<p v-if="action === 'test' && dumps.some((d) => d.counted)" class="fc-muted text-caption mt-2">
<v-icon size="x-small" color="success">mdi-check-decagram-outline</v-icon>
marks a dump whose row counts were recorded as it was taken. A dump
without them is compared with the live database instead.
</p>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { humanBytes } from '../../utils/bytes.js'
import { formatDumpTime } from '../../utils/date.js'
defineProps({
modelValue: { type: Boolean, default: false },
bucket: { type: String, default: '' },
dumps: { type: Array, default: () => [] },
error: { type: String, default: '' },
// 'restore' or 'test': what choosing a dump does.
action: { type: String, default: 'restore' },
})
defineEmits(['update:modelValue', 'pick'])
</script>
<style scoped>
.fc-tabular { font-variant-numeric: tabular-nums; }
</style>
@@ -0,0 +1,150 @@
<template>
<!-- Files a download wrote into an artist's folder but never imported: a run
that stopped between writing and importing leaves them there, and the
walk that marked them seen never returns. Preview lists them per artist;
Import queues them as an import batch that attaches each file where it
lies. -->
<MaintenanceTile
icon="mdi-folder-download"
title="Import files the library is missing"
blurb="Find art in the artists' folders that was downloaded but never imported, and import it where it is."
:open="applying || previewing"
>
<p class="text-body-2 mb-3">
A download writes its files into the artist's folder, then imports them.
A run that stops in between leaves complete files that no image points
at, and later downloads skip them as already seen. This walks every
artist's folder for images, videos and archives the library has
<strong>no record of</strong>. <strong>Preview</strong> first;
<strong>Import</strong> runs them through the normal import where they
lie. A file that turns out to be a copy of something already in the
library, or that the import filters reject, is removed from disk. Files
changed in the last day are left alone, so a download still running 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="primary" rounded="pill"
:loading="applying"
:disabled="previewing || !canApply"
@click="confirmOpen = true"
>
<v-icon start>mdi-folder-download</v-icon> Import
</v-btn>
</div>
<v-alert
v-if="summary" :type="summaryType" variant="tonal" class="mt-4"
density="comfortable"
>
<span v-if="applied && summary.batch_id">
Queued {{ summary.files }} file(s) ({{ humanBytes(summary.bytes) }}) for
import. Progress shows in the import queue below and on Activity.
</span>
<span v-else-if="hasWork">
{{ summary.files }} file(s), {{ humanBytes(summary.bytes) }}, are on
disk but not in the library<span v-if="summary.archives">, including
{{ summary.archives }} archive(s)</span>.
Click <strong>Import</strong> to bring them in.
</span>
<span v-else>Nothing to import: every file in the artists' folders is in the library.</span>
<div v-if="passedOver" class="mt-1 text-caption">
Left alone: {{ summary.recent }} changed in the last day,
{{ summary.not_media }} that aren't images, videos or archives, and
{{ summary.no_artist }} in folders that match no artist.
</div>
</v-alert>
<v-table v-if="hasWork && !applied" density="compact" class="mt-3">
<thead>
<tr>
<th>Artist</th>
<th class="text-right">Files</th>
<th class="text-right">Size</th>
<th>For example</th>
</tr>
</thead>
<tbody>
<tr v-for="a in summary.artists" :key="a.slug">
<td>{{ a.slug }}</td>
<td class="text-right">{{ a.files }}</td>
<td class="text-right">{{ humanBytes(a.bytes) }}</td>
<td class="text-caption text-medium-emphasis fc-sample">{{ a.samples[0] }}</td>
</tr>
</tbody>
</v-table>
<QueueStatusBar queue="import" queue-label="Import" />
<v-dialog v-model="confirmOpen" max-width="440">
<v-card>
<v-card-title>Import {{ summary?.files ?? 0 }} file(s)?</v-card-title>
<v-card-text class="text-body-2">
This imports <strong>{{ summary?.files ?? 0 }}</strong> file(s)
({{ humanBytes(summary?.bytes) }}) where they lie. Any that turn out
to be copies of art already in the library, or that the import
filters reject, are removed from disk.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="confirmOpen = false">Cancel</v-btn>
<v-btn color="primary" @click="apply">Import</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)
// The walk stats every file in the artists' folders, so on a large library on
// network storage it can take many minutes. 900 polls × 2s = 30 min, the
// task's own soft limit.
const { previewing, applying, summary, applied, preview, apply: applyTask } = useMaintenanceTask({
endpoint: '/api/admin/maintenance/import-unimported',
storageKey: 'fc.maint.importUnimported',
appliedToast: 'Files queued for import',
maxPolls: 900,
})
const hasWork = computed(() => !!summary.value && summary.value.files > 0)
const canApply = computed(() => hasWork.value && !applied.value)
const passedOver = computed(() => {
const s = summary.value
return !!s && (s.recent > 0 || s.not_media > 0 || s.no_artist > 0)
})
const summaryType = computed(() => {
if (applied.value) return 'success'
return hasWork.value ? 'info' : 'success'
})
function apply () {
confirmOpen.value = false
applyTask()
}
</script>
<style scoped>
.fc-sample {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -63,8 +63,11 @@
<section class="fc-section">
<h3 class="fc-section__title">Storage</h3>
<p class="fc-section__hint">Database + image backups and restore.</p>
<p class="fc-section__hint">
Database + image backups and restore, and where backups are sent off this server.
</p>
<div class="fc-tile-stack">
<BackupDestinationCard />
<BackupCard />
</div>
</section>
@@ -93,6 +96,7 @@ import PostAssociationsCard from './PostAssociationsCard.vue'
import GpuAgentCard from './GpuAgentCard.vue'
import AliasTable from './AliasTable.vue'
import BackupCard from './BackupCard.vue'
import BackupDestinationCard from './BackupDestinationCard.vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js'
// Poll queue depths so each task tile's QueueStatusBar shows live pending
+144 -36
View File
@@ -1,72 +1,174 @@
// Click-to-toggle zoom + drag-to-pan when zoomed + wheel-to-zoom.
// Returns reactive state + handlers a component can spread onto its
// root element.
// Zoom and pan for one picture in a canvas.
//
// - Click toggles between the fitted view and a zoomed one. A long strip (or a
// wide panorama) zooms to fill the canvas across its short side, so a comic
// strip opens at reading width; anything else zooms to 2x. The clicked point
// stays under the cursor.
// - A drag pans, and the click that ends a drag does not toggle the zoom.
// - The wheel scrolls the zoomed picture (shift+wheel goes sideways); with
// ctrl held, which is also what a trackpad pinch sends, it zooms at the
// cursor.
// - zoomBy/setScale drive the toolbar's buttons and slider.
//
// `measure()` returns the canvas size, the picture's fitted (unzoomed) size
// and its natural width: { cw, ch, iw, ih, nw }, or null before it has laid
// out. The picture is centred in the canvas and scaled about its centre, so a
// translate of (x, y) at scale s puts picture point p at centre + (x, y) + s*p.
import { reactive } from 'vue'
const MIN_SCALE = 1
const MAX_SCALE = 4
const WHEEL_SENSITIVITY = 0.0015
export const MIN_SCALE = 1
const CLICK_SCALE = 2
// A picture that would have to grow more than this to fill the canvas is a
// strip or a panorama: its click zoom fills the canvas instead of doubling.
const LONG_FILL = 2
const MAX_FLOOR = 4 // the most you can zoom a small picture
const MAX_CEIL = 32
const STEP = 1.25 // one press of + or -
const DRAG_PX = 4 // movement before a press counts as a drag
const PINCH_SENSITIVITY = 0.01
const LINE_PX = 40 // a wheel "line" (deltaMode 1)
export function usePanZoom() {
export function usePanZoom(measure = () => null) {
const state = reactive({
scale: 1,
x: 0,
y: 0,
panning: false
panning: false,
// Off while dragging or scrolling, so the picture follows the hand
// instead of easing behind it.
smooth: true,
})
let dragOriginX = 0
let dragOriginY = 0
let panOriginX = 0
let panOriginY = 0
let press = null // { x, y, panX, panY, id } while a button is down
let dragged = false // the press that is ending moved: swallow its click
function geometry() {
const g = measure()
return g && g.iw > 0 && g.ih > 0 && g.cw > 0 && g.ch > 0 ? g : null
}
// The scale at which the picture covers the canvas both ways.
function fillScale(g = geometry()) {
return g ? Math.max(g.cw / g.iw, g.ch / g.ih) : CLICK_SCALE
}
function maxScale(g = geometry()) {
if (!g) return MAX_FLOOR
const actualPixels = g.nw ? g.nw / g.iw : 0
return Math.min(MAX_CEIL, Math.max(MAX_FLOOR, fillScale(g), actualPixels * 2))
}
function clickScale(g = geometry()) {
const fill = fillScale(g)
return Math.min(maxScale(g), fill > LONG_FILL ? fill : CLICK_SCALE)
}
// Keep the picture over the canvas: no panning past its edges, and a side
// that is smaller than the canvas stays centred.
function clamp() {
const g = geometry()
if (!g) return
const mx = Math.max(0, (g.iw * state.scale - g.cw) / 2)
const my = Math.max(0, (g.ih * state.scale - g.ch) / 2)
state.x = Math.min(mx, Math.max(-mx, state.x))
state.y = Math.min(my, Math.max(-my, state.y))
}
function reset() {
state.smooth = true
state.scale = 1
state.x = 0
state.y = 0
}
// Zoom to `scale` keeping the point (px, py), measured from the canvas
// centre, where it is on screen.
function zoomAt(scale, px = 0, py = 0) {
const next = Math.min(maxScale(), Math.max(MIN_SCALE, scale))
if (next === MIN_SCALE) { state.scale = next; state.x = 0; state.y = 0; return }
const k = next / state.scale
state.x = px - (px - state.x) * k
state.y = py - (py - state.y) * k
state.scale = next
clamp()
}
function setScale(scale) {
state.smooth = true
zoomAt(scale)
}
function zoomBy(steps) {
setScale(state.scale * STEP ** steps)
}
function fromCentre(ev) {
const r = ev.currentTarget?.getBoundingClientRect?.()
if (!r) return [0, 0]
return [ev.clientX - r.left - r.width / 2, ev.clientY - r.top - r.height / 2]
}
function toggleZoom(ev) {
if (state.scale > 1) { reset(); return }
state.scale = 2
// Zoom centered on click point
if (ev && ev.currentTarget) {
const rect = ev.currentTarget.getBoundingClientRect()
const cx = ev.clientX - rect.left - rect.width / 2
const cy = ev.clientY - rect.top - rect.height / 2
state.x = -cx
state.y = -cy
}
if (state.scale > MIN_SCALE) { reset(); return }
state.smooth = true
zoomAt(clickScale(), ...(ev ? fromCentre(ev) : [0, 0]))
}
function onWheel(ev) {
ev.preventDefault()
const delta = -ev.deltaY * WHEEL_SENSITIVITY
const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale + delta * state.scale))
state.scale = next
if (next === MIN_SCALE) { state.x = 0; state.y = 0 }
state.smooth = false
if (ev.ctrlKey) {
const [px, py] = fromCentre(ev)
zoomAt(state.scale * Math.exp(-ev.deltaY * PINCH_SENSITIVITY), px, py)
return
}
if (state.scale === MIN_SCALE) return
const unit = ev.deltaMode === 1 ? LINE_PX
: ev.deltaMode === 2 ? (geometry()?.ch || 800) : 1
let dx = ev.deltaX * unit
let dy = ev.deltaY * unit
if (ev.shiftKey && !dx) { dx = dy; dy = 0 }
state.x -= dx
state.y -= dy
clamp()
}
function onPointerDown(ev) {
if (state.scale === 1) return
if (ev.button !== 0) return
dragged = false
press = { x: ev.clientX, y: ev.clientY, panX: state.x, panY: state.y, id: ev.pointerId }
if (state.scale === MIN_SCALE) return
state.panning = true
dragOriginX = ev.clientX
dragOriginY = ev.clientY
panOriginX = state.x
panOriginY = state.y
ev.currentTarget.setPointerCapture(ev.pointerId)
state.smooth = false
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch {}
}
function onPointerMove(ev) {
if (!press) return
const dx = ev.clientX - press.x
const dy = ev.clientY - press.y
if (!dragged && Math.hypot(dx, dy) > DRAG_PX) dragged = true
if (!state.panning) return
state.x = panOriginX + (ev.clientX - dragOriginX)
state.y = panOriginY + (ev.clientY - dragOriginY)
state.x = press.panX + dx
state.y = press.panY + dy
clamp()
}
function onPointerUp(ev) {
press = null
if (!state.panning) return
state.panning = false
try { ev.currentTarget.releasePointerCapture(ev.pointerId) } catch {}
}
// True once for the click that ends a drag, so the canvas can ignore it.
function endedDrag() {
const was = dragged
dragged = false
return was
}
return {
state,
handlers: {
@@ -74,8 +176,14 @@ export function usePanZoom() {
onPointerDown,
onPointerMove,
onPointerUp,
onClick: toggleZoom
onPointerCancel: onPointerUp,
onClick: toggleZoom,
},
reset
endedDrag,
zoomBy,
setScale,
maxScale,
clamp,
reset,
}
}
+106 -5
View File
@@ -7,14 +7,26 @@ export const useBackupStore = defineStore('backup', () => {
const api = useApi()
const dbRuns = ref([])
const imagesRuns = ref([])
const mediaRuns = ref([])
const restoreRuns = ref([])
// The weekly check of the bucket against what was sent.
const reconcileRuns = ref([])
// Test restores: a dump loaded into a temporary database and checked.
const testRuns = ref([])
const settings = ref(null)
const loading = ref({ dbRuns: false, imagesRuns: false, settings: false })
const loading = ref({
dbRuns: false, mediaRuns: false, restoreRuns: false,
reconcileRuns: false, testRuns: false, settings: false,
})
const lastError = ref(null)
const RUNS = {
db: dbRuns, media: mediaRuns, restore: restoreRuns,
reconcile: reconcileRuns, test: testRuns,
}
async function loadRuns(kind) {
const targetRef = kind === 'db' ? dbRuns : imagesRuns
const loadingKey = kind === 'db' ? 'dbRuns' : 'imagesRuns'
const targetRef = RUNS[kind]
const loadingKey = `${kind}Runs`
loading.value[loadingKey] = true
lastError.value = null
try {
@@ -101,9 +113,98 @@ export const useBackupStore = defineStore('backup', () => {
}
}
// Off-site destination (an S3 bucket). The secret key goes up but never
// comes back: the server answers `secret_set` instead.
const destination = ref(null)
async function loadDestination() {
destination.value = await api.get('/api/system/backup/destination')
return destination.value
}
async function saveDestination(fields) {
destination.value = await api.patch('/api/system/backup/destination', { body: fields })
return destination.value
}
async function removeDestination() {
destination.value = await api.delete('/api/system/backup/destination')
return destination.value
}
// Resolves to {ok, message}; an unreachable bucket is a result, not a throw.
async function testDestination(fields) {
return api.post('/api/system/backup/destination/test', { body: fields })
}
// How much of the library is in the off-site bucket:
// {configured, files_total, files_sent, bytes_total, bytes_sent}.
const mediaCoverage = ref(null)
async function loadMediaCoverage() {
try {
mediaCoverage.value = await api.get('/api/system/backup/media')
} catch (e) {
lastError.value = e.message
}
}
// Restoring from the bucket. Dumps arrive newest first, each with the
// `confirm` text a restore of it asks for, and `counted` when its row
// counts were recorded beside it.
// How big the live database is: a test restore needs about that much free
// space on the database's disk while it runs.
const databaseBytes = ref(null)
async function listOffsiteDumps() {
const body = await api.get('/api/system/backup/offsite/dumps')
databaseBytes.value = body.database_bytes ?? null
return body.dumps || []
}
// Test a dump without touching this install: from the bucket by key (the
// newest when null), or one kept on this server by its run id.
async function testDump({ key = null, runId = null } = {}) {
lastError.value = null
try {
if (runId != null) {
await api.post(`/api/system/backup/runs/${runId}/test`, { body: {} })
} else {
await api.post('/api/system/backup/offsite/test', { body: key ? { key } : {} })
}
} catch (e) {
lastError.value = e.message
throw e
}
}
async function restoreFromOffsite(key, confirm) {
lastError.value = null
try {
await api.post('/api/system/backup/offsite/restore', { body: { key, confirm } })
} catch (e) {
lastError.value = e.message
throw e
}
}
async function fetchMissingFiles() {
lastError.value = null
try {
await api.post('/api/system/backup/offsite/fetch-files', { body: {} })
} catch (e) {
lastError.value = e.message
throw e
}
}
return {
dbRuns, imagesRuns, settings, loading, lastError,
dbRuns, mediaRuns, restoreRuns, reconcileRuns, testRuns,
mediaCoverage, loadMediaCoverage,
listOffsiteDumps, databaseBytes, restoreFromOffsite, fetchMissingFiles, testDump,
settings, loading, lastError,
loadRuns, triggerBackup, restore, deleteRun, setTag,
loadSettings, patchSettings,
destination, loadDestination, saveDestination, removeDestination, testDestination,
}
})
+10
View File
@@ -61,3 +61,13 @@ export function formatLocalDate (iso) {
year: 'numeric', month: 'short', day: 'numeric',
})
}
// A dump's compact UTC stamp (20261107T134258Z, as in its file name) as the
// reader's local date and time.
export function formatDumpTime (ts) {
if (typeof ts !== 'string') return ''
const iso = ts.replace(
/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, '$1-$2-$3T$4:$5:$6Z',
)
return formatDateTime(iso)
}
+4 -1
View File
@@ -22,13 +22,15 @@
<h3 class="fc-section__title">Duplicates &amp; leftovers</h3>
<p class="fc-section__hint">
Tidy post records, duplicates, locked-preview leftovers and attachments
that outlived what they belonged to.
that outlived what they belonged to, and import downloads that never
made it into the library.
</p>
<div class="fc-tile-grid">
<PostMaintenanceCard />
<VideoDedupCard />
<GatedPurgeCard />
<AttachmentReclaimCard />
<LibraryImportCard />
</div>
</section>
@@ -63,6 +65,7 @@ import PostMaintenanceCard from '../components/settings/PostMaintenanceCard.vue'
import VideoDedupCard from '../components/settings/VideoDedupCard.vue'
import GatedPurgeCard from '../components/settings/GatedPurgeCard.vue'
import AttachmentReclaimCard from '../components/settings/AttachmentReclaimCard.vue'
import LibraryImportCard from '../components/settings/LibraryImportCard.vue'
import TagMaintenanceCard from '../components/settings/TagMaintenanceCard.vue'
import DangerZoneCard from '../components/settings/DangerZoneCard.vue'
</script>
@@ -0,0 +1,43 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import BackupRunsTable from '../../src/components/settings/BackupRunsTable.vue'
import { mountComponent } from '../support/mountComponent.js'
const MEDIA_RUN = {
id: 7, kind: 'media', status: 'ok', started_at: new Date().toISOString(),
duration_seconds: 90, size_bytes: 2048,
error: "3 file(s) weren't sent; the next run tries them again.",
offsite: { bucket: 'curator', endpoint: 'http://archives.example', key: 'media/' },
manifest: {
failed: 3,
failures: [
{ path: '/images/a/one.jpg', error: 'HTTP 503: busy.' },
{ path: '/images/b/two.zip', error: 'OSError: gone' },
],
},
}
describe('BackupRunsTable', () => {
it('offers a run\'s failed files, counted in full', () => {
const w = mountComponent(BackupRunsTable, { props: { runs: [MEDIA_RUN] } })
expect(w.text()).toContain('3 files failed')
expect(w.text()).not.toContain('/images/a/one.jpg')
})
it('lists each failed file with why once opened, and says how many more', async () => {
const w = mountComponent(BackupRunsTable, { props: { runs: [MEDIA_RUN] } })
await w.find('v-btn').trigger('click')
const t = w.text()
expect(t).toContain('/images/a/one.jpg')
expect(t).toContain('HTTP 503: busy.')
expect(t).toContain('/images/b/two.zip')
expect(t).toContain('and 1 more.')
})
it('shows no failure button on a clean run', () => {
const clean = { ...MEDIA_RUN, id: 8, error: null, manifest: { failed: 0, failures: [] } }
const w = mountComponent(BackupRunsTable, { props: { runs: [clean] } })
expect(w.text()).not.toContain('failed')
})
})
@@ -0,0 +1,59 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import BackupTestSection from '../../src/components/settings/BackupTestSection.vue'
import { useBackupStore } from '../../src/stores/backup.js'
import { mountWithStore } from '../support/mountComponent.js'
const FINISHED = {
id: 9, kind: 'test', status: 'error', started_at: new Date().toISOString(),
finished_at: new Date().toISOString(), duration_seconds: 300, size_bytes: 2048,
error: '1 table(s) came back different: post (7 in the dump, 6 restored).',
offsite: null,
manifest: {
phase: 'done',
dump: { ts: '20261107T030000Z' },
summary: '1 table(s) came back different: post (7 in the dump, 6 restored).',
counted: true,
mismatched: ['post'],
alembic_dump: '0120', alembic_now: '0121',
tables: [
{ table: 'artist', dump: 10, restored: 10, live: 12 },
{ table: 'post', dump: 7, restored: 6, live: 7 },
],
},
}
function mount(runs) {
return mountWithStore(BackupTestSection, () => {
useBackupStore().testRuns = runs
}, { props: { bucket: 'curator' } })
}
describe('BackupTestSection', () => {
it('leads with the last test\'s verdict and how its schema was brought up', () => {
const t = mount([FINISHED]).text()
expect(t).toContain('Last test')
expect(t).toContain('post (7 in the dump, 6 restored)')
expect(t).toContain('brought up from 0120 to 0121')
expect(t).not.toContain('artist')
})
it('lists every table on request, with the dump, restored and live counts', async () => {
const w = mount([FINISHED])
const btn = w.findAll('v-btn').find((b) => b.text().includes('each table'))
await btn.trigger('click')
const rows = w.findAll('tr').filter((r) => r.text().includes('post'))
expect(rows.some((r) => r.classes().includes('text-error'))).toBe(true)
expect(w.text()).toContain('artist')
})
it('says what a running test is doing', () => {
const running = {
...FINISHED, id: 10, status: 'running', finished_at: null, error: null,
manifest: { phase: 'loading', dump: { ts: '20261107T030000Z' } },
}
const t = mount([running, FINISHED]).text()
expect(t).toContain('loading it into a temporary database')
})
})
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import { usePanZoom } from '../src/composables/usePanZoom.js'
// A 1400x1300 canvas. The strip is 3600x24000, fitted to 195x1300.
const STRIP = { cw: 1400, ch: 1300, iw: 195, ih: 1300, nw: 3600 }
const PHOTO = { cw: 1400, ch: 1300, iw: 1200, ih: 900, nw: 4000 }
const RECT = { left: 0, top: 0, width: 1400, height: 1300 }
const target = { getBoundingClientRect: () => RECT, setPointerCapture() {}, releasePointerCapture() {} }
const at = (x, y, extra = {}) => ({ clientX: x, clientY: y, currentTarget: target,
button: 0, pointerId: 1, preventDefault() {}, ...extra })
describe('usePanZoom', () => {
it('opens a long strip at the canvas width, keeping the clicked point under the cursor', () => {
const pz = usePanZoom(() => STRIP)
pz.handlers.onClick(at(700, 650 - 300))
expect(pz.state.scale).toBeCloseTo(1400 / 195)
expect(pz.state.x).toBe(0)
// The picture point that was 300px above centre (picture y = -300 at
// scale 1) is still 300px above centre: y + scale * -300 === -300.
expect(pz.state.y + pz.state.scale * -300).toBeCloseTo(-300)
})
it('doubles an ordinary picture', () => {
const pz = usePanZoom(() => PHOTO)
pz.handlers.onClick(at(700, 650))
expect(pz.state.scale).toBe(2)
})
it('does not unzoom on the click that ends a drag', () => {
const pz = usePanZoom(() => STRIP)
pz.handlers.onClick(at(700, 650))
pz.handlers.onPointerDown(at(700, 650))
pz.handlers.onPointerMove(at(700, 450))
pz.handlers.onPointerUp(at(700, 450))
expect(pz.endedDrag()).toBe(true)
expect(pz.state.y).toBe(-200)
// A plain click afterwards is a click again.
pz.handlers.onPointerDown(at(700, 450))
pz.handlers.onPointerUp(at(700, 450))
expect(pz.endedDrag()).toBe(false)
})
it('scrolls a zoomed picture with the wheel and stops at its ends', () => {
const pz = usePanZoom(() => STRIP)
pz.handlers.onClick(at(700, 650))
const scale = pz.state.scale
pz.handlers.onWheel(at(700, 650, { deltaY: 100, deltaX: 0, deltaMode: 0 }))
expect(pz.state.scale).toBe(scale)
expect(pz.state.y).toBe(-100)
pz.handlers.onWheel(at(700, 650, { deltaY: 1e6, deltaX: 0, deltaMode: 0 }))
expect(pz.state.y).toBeCloseTo(-(1300 * scale - 1300) / 2)
})
it('leaves the fitted view alone on a plain wheel and zooms with ctrl held', () => {
const pz = usePanZoom(() => PHOTO)
pz.handlers.onWheel(at(700, 650, { deltaY: 100, deltaX: 0, deltaMode: 0 }))
expect(pz.state.scale).toBe(1)
pz.handlers.onWheel(at(700, 650, { deltaY: -100, deltaX: 0, deltaMode: 0, ctrlKey: true }))
expect(pz.state.scale).toBeGreaterThan(1)
})
it('steps with the buttons and never goes past full size twice over', () => {
const pz = usePanZoom(() => PHOTO)
pz.zoomBy(1)
expect(pz.state.scale).toBeCloseTo(1.25)
pz.zoomBy(100)
expect(pz.state.scale).toBeCloseTo(pz.maxScale())
expect(pz.maxScale()).toBeCloseTo((4000 / 1200) * 2)
pz.zoomBy(-100)
expect(pz.state.scale).toBe(1)
expect([pz.state.x, pz.state.y]).toEqual([0, 0])
})
})
+6 -1
View File
@@ -136,9 +136,14 @@ newest() {
# Unquoted on purpose: the pathspec must word-split into separate args.
# Globbing is off script-wide (`set -f`).
#
# Newest committer time wins. Two commits pushed together can share a
# second, and then the one git log lists first (the descendant, which holds
# both changes) wins: a stable sort on the timestamp alone keeps git's order
# among equals. Sorting on the whole line would break the tie by hash.
# shellcheck disable=SC2086
set -- "$(cd "$ROOT" && git log --format='%ct %H' HEAD -- $paths \
| sort -n | tail -1)"
| sort -s -k1,1nr | head -1)"
if [ -z "$1" ]; then
echo "artifacts.sh: no commit touches this artifact's shipped files" >&2
exit 1
+110
View File
@@ -0,0 +1,110 @@
"""An in-process S3 bucket for round-trip tests: put, multipart upload,
get, and ListObjectsV2, kept in memory. It doesn't check signatures; the
signing has its own tests against AWS's published vectors. Like Fabled
Archives, it refuses to replace an object that is already there.
with FakeS3() as s3:
target = s3.target()
...
s3.objects # {key: bytes}
"""
from __future__ import annotations
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, unquote, urlsplit
from xml.sax.saxutils import escape
from backend.app.services.backup_s3 import S3Target
BUCKET = "curator"
class FakeS3:
def __init__(self):
self.objects: dict[str, bytes] = {}
self.uploads: dict[str, dict[int, bytes]] = {}
outer = self
class Handler(BaseHTTPRequestHandler):
def _parts(self):
u = urlsplit(self.path)
path = unquote(u.path).lstrip("/")
bucket, _, key = path.partition("/")
query = parse_qs(u.query, keep_blank_values=True) # `?uploads` has no value
return bucket, key, {k: v[0] for k, v in query.items()}
def _reply(self, status, body=b"", headers=None):
self.send_response(status)
for name, value in (headers or {}).items():
self.send_header(name, value)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _body(self):
length = int(self.headers.get("Content-Length") or 0)
return self.rfile.read(length) if length else b""
def do_PUT(self):
_, key, q = self._parts()
body = self._body()
if "uploadId" in q:
outer.uploads[q["uploadId"]][int(q["partNumber"])] = body
return self._reply(200, headers={"ETag": f'"p{q["partNumber"]}"'})
if key in outer.objects:
return self._reply(403, b"<Error><Code>AccessDenied</Code></Error>")
outer.objects[key] = body
return self._reply(200)
def do_POST(self):
_, key, q = self._parts()
self._body()
if "uploads" in q:
upload_id = f"u{len(outer.uploads) + 1}"
outer.uploads[upload_id] = {}
return self._reply(200, (
"<InitiateMultipartUploadResult>"
f"<UploadId>{upload_id}</UploadId>"
"</InitiateMultipartUploadResult>").encode())
parts = outer.uploads.pop(q["uploadId"])
outer.objects[key] = b"".join(parts[n] for n in sorted(parts))
return self._reply(200, b"<CompleteMultipartUploadResult/>")
def do_DELETE(self):
_, _, q = self._parts()
outer.uploads.pop(q.get("uploadId", ""), None)
return self._reply(204)
def do_GET(self):
_, key, q = self._parts()
if q.get("list-type") == "2":
prefix = q.get("prefix", "")
items = "".join(
f"<Contents><Key>{escape(k)}</Key><Size>{len(v)}</Size>"
"<LastModified>2026-01-01T00:00:00Z</LastModified></Contents>"
for k, v in sorted(outer.objects.items()) if k.startswith(prefix)
)
return self._reply(200, f"<ListBucketResult>{items}</ListBucketResult>".encode())
if key not in outer.objects:
return self._reply(404, b"<Error><Code>NoSuchKey</Code></Error>")
return self._reply(200, outer.objects[key])
def log_message(self, *args):
pass
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
def __enter__(self):
self.thread.start()
return self
def __exit__(self, *exc):
self.httpd.shutdown()
self.httpd.server_close()
def target(self) -> S3Target:
host, port = self.httpd.server_address
return S3Target(f"http://{host}:{port}", BUCKET, "AK", "SK")
+19
View File
@@ -708,3 +708,22 @@ async def test_trigger_reclaim_attachments_threads_apply(client, monkeypatch):
)
assert resp.status_code == 202
assert calls[0][1] == {"dry_run": False}
@pytest.mark.asyncio
async def test_trigger_import_unimported_defaults_to_preview(client, monkeypatch):
"""An empty body previews: the apply removes files the library rejects."""
from backend.app.tasks import admin as admin_tasks
calls = []
monkeypatch.setattr(
admin_tasks.import_unimported_files_task, "delay", _fake_delay(calls)
)
resp = await client.post("/api/admin/maintenance/import-unimported", json={})
assert resp.status_code == 202
assert calls[0][1] == {"dry_run": True}
resp = await client.post(
"/api/admin/maintenance/import-unimported", json={"dry_run": False},
)
assert calls[1][1] == {"dry_run": False}
+275 -15
View File
@@ -16,21 +16,20 @@ async def _seed_runs(db):
now = datetime.now(UTC)
for i in range(4):
db.add(BackupRun(
kind="db" if i % 2 == 0 else "images",
kind="db" if i % 2 == 0 else "media",
status="ok",
tag=None,
triggered_by="manual",
started_at=now - timedelta(seconds=10 - i),
finished_at=now - timedelta(seconds=9 - i),
sql_path="/tmp/fake.sql" if i % 2 == 0 else None,
tar_path=None if i % 2 == 0 else "/tmp/fake.tar.zst",
size_bytes=100,
manifest={},
))
await db.commit()
# --- POST /db, /images ----------------------------------------------
# --- POST /db ---------------------------------------------------------
@pytest.mark.asyncio
@@ -72,14 +71,10 @@ async def test_post_db_rejects_tag_too_long(client):
@pytest.mark.asyncio
async def test_post_images_dispatches(client, monkeypatch):
dispatched = []
monkeypatch.setattr(
"backend.app.tasks.backup.backup_images_task.delay",
lambda **kw: dispatched.append(kw),
)
resp = await client.post("/api/system/backup/images", json={})
assert resp.status_code == 202
async def test_the_images_tarball_is_gone(client):
assert (await client.post("/api/system/backup/images", json={})).status_code in (404, 405)
resp = await client.get("/api/system/backup/runs?kind=images")
assert resp.status_code == 400
# --- GET /runs -------------------------------------------------------
@@ -103,10 +98,10 @@ async def test_runs_filter_by_kind_db(client, _seed_runs):
@pytest.mark.asyncio
async def test_runs_filter_by_kind_images(client, _seed_runs):
resp = await client.get("/api/system/backup/runs?kind=images")
async def test_runs_filter_by_kind_media(client, _seed_runs):
resp = await client.get("/api/system/backup/runs?kind=media")
body = await resp.get_json()
assert all(r["kind"] == "images" for r in body["runs"])
assert body["runs"] and all(r["kind"] == "media" for r in body["runs"])
@pytest.mark.asyncio
@@ -232,6 +227,27 @@ async def test_restore_rejects_non_ok_status(client, db):
assert body["error"] == "not_restorable"
@pytest.mark.asyncio
async def test_an_off_site_dump_is_not_restored_from_this_screen(client, db):
db.add(BackupRun(
kind="db", status="ok", tag=None, triggered_by="nightly",
started_at=datetime.now(UTC), finished_at=datetime.now(UTC),
manifest={"offsite": {"endpoint": "http://archives.example:8081",
"bucket": "curator", "key": "database/x.dump"}},
))
await db.commit()
run = (await (await client.get("/api/system/backup/runs?kind=db&limit=1")).get_json())[
"runs"][0]
assert run["offsite"]["bucket"] == "curator"
resp = await client.post(
f"/api/system/backup/runs/{run['id']}/restore",
json={"confirm": f"restore-db-{run['id']}"},
)
assert resp.status_code == 400
assert (await resp.get_json())["error"] == "not_restorable"
# --- DELETE /runs/<id> ----------------------------------------------
@@ -277,7 +293,7 @@ async def test_get_settings_returns_defaults(client):
assert body["backup_db_nightly_enabled"] is False
assert body["backup_db_nightly_hour_utc"] == 3
assert body["backup_db_keep_last_n"] == 14
assert body["backup_images_keep_last_n"] == 3
assert "backup_images_keep_last_n" not in body
@pytest.mark.asyncio
@@ -322,3 +338,247 @@ async def test_patch_settings_rejects_keep_n_below_min(client):
json={"backup_db_keep_last_n": 0},
)
assert resp.status_code == 400
# --- /destination ---------------------------------------------------
@pytest.fixture
def _sealing_key(tmp_path, monkeypatch):
from backend.app.services import backup_destination
monkeypatch.setattr(backup_destination, "KEY_PATH", tmp_path / "credential_key.b64")
_DEST = {
"endpoint": "http://archives.example:8081/",
"bucket": "curator",
"access_key": "AKEXAMPLE",
"region": "",
"secret_key": "s3cr3t-example",
}
@pytest.mark.asyncio
async def test_destination_saves_and_never_returns_the_secret(client, db_sync, _sealing_key):
from backend.app.models import ImportSettings
resp = await client.patch("/api/system/backup/destination", json=_DEST)
assert resp.status_code == 200
body = await resp.get_json()
assert body == {
"endpoint": "http://archives.example:8081", "bucket": "curator",
"access_key": "AKEXAMPLE", "region": "us-east-1",
"secret_set": True, "configured": True, "upload_limit_mbps": 200,
}
assert "s3cr3t-example" not in str(await (await client.get(
"/api/system/backup/destination")).get_json())
row = db_sync.execute(select(ImportSettings).where(ImportSettings.id == 1)).scalar_one()
db_sync.refresh(row)
assert row.backup_s3_secret_sealed and "s3cr3t-example" not in row.backup_s3_secret_sealed
from backend.app.services import backup_destination
assert backup_destination.unseal(row.backup_s3_secret_sealed) == "s3cr3t-example"
@pytest.mark.asyncio
async def test_destination_blank_secret_keeps_the_saved_one(client, db_sync, _sealing_key):
from backend.app.models import ImportSettings
from backend.app.services import backup_destination
await client.patch("/api/system/backup/destination", json=_DEST)
resp = await client.patch(
"/api/system/backup/destination", json={**_DEST, "bucket": "other", "secret_key": ""},
)
assert (await resp.get_json())["bucket"] == "other"
row = db_sync.execute(select(ImportSettings).where(ImportSettings.id == 1)).scalar_one()
db_sync.refresh(row)
assert backup_destination.unseal(row.backup_s3_secret_sealed) == "s3cr3t-example"
@pytest.mark.asyncio
@pytest.mark.parametrize("patch, word", [
({"endpoint": "archives.example:8081"}, "http://"),
({"endpoint": "ftp://archives.example"}, "http://"),
({"bucket": "curator/sub"}, "bucket"),
({"access_key": 5}, "access_key"),
])
async def test_destination_rejects_what_cant_be_an_s3_target(client, _sealing_key, patch, word):
resp = await client.patch("/api/system/backup/destination", json={**_DEST, **patch})
assert resp.status_code == 400
assert word in (await resp.get_json())["detail"]
@pytest.mark.asyncio
async def test_destination_remove_forgets_everything(client, _sealing_key):
await client.patch("/api/system/backup/destination", json=_DEST)
resp = await client.delete("/api/system/backup/destination")
assert await resp.get_json() == {
"endpoint": "", "bucket": "", "access_key": "", "region": "us-east-1",
"secret_set": False, "configured": False, "upload_limit_mbps": 200,
}
@pytest.mark.asyncio
async def test_destination_test_uses_the_form_and_the_saved_secret(
client, _sealing_key, monkeypatch,
):
from backend.app.services import backup_destination
seen = []
def fake_check(target):
seen.append(target)
return {"ok": True, "message": "Reached it."}
monkeypatch.setattr(backup_destination, "check_target", fake_check)
await client.patch("/api/system/backup/destination", json=_DEST)
resp = await client.post(
"/api/system/backup/destination/test",
json={**_DEST, "bucket": "typed-not-saved", "secret_key": ""},
)
assert await resp.get_json() == {"ok": True, "message": "Reached it."}
assert seen[0].bucket == "typed-not-saved"
assert seen[0].secret_key == "s3cr3t-example"
assert seen[0].endpoint == "http://archives.example:8081"
@pytest.mark.asyncio
async def test_destination_test_names_what_is_missing(client, _sealing_key):
await client.delete("/api/system/backup/destination")
resp = await client.post(
"/api/system/backup/destination/test",
json={"endpoint": "http://archives.example:8081", "bucket": "curator"},
)
body = await resp.get_json()
assert body["ok"] is False
assert body["message"] == "Not tested: fill in the access key, secret key."
@pytest.mark.asyncio
async def test_destination_test_reports_an_unreachable_bucket_as_a_result(
client, _sealing_key, monkeypatch,
):
from backend.app.services import backup_destination
from backend.app.services.backup_s3 import S3Error
def refuse(target, **kw):
raise S3Error("Couldn't reach http://archives.example:8081: nothing is listening there.")
monkeypatch.setattr(backup_destination, "list_objects", refuse)
resp = await client.post("/api/system/backup/destination/test", json=_DEST)
assert resp.status_code == 200
assert await resp.get_json() == {
"ok": False,
"message": "Couldn't reach http://archives.example:8081: nothing is listening there.",
}
@pytest.mark.asyncio
async def test_the_upload_limit_saves_and_a_missing_one_keeps_it(client, _sealing_key):
resp = await client.patch(
"/api/system/backup/destination", json={**_DEST, "upload_limit_mbps": 50},
)
assert (await resp.get_json())["upload_limit_mbps"] == 50
resp = await client.patch("/api/system/backup/destination", json=_DEST)
assert (await resp.get_json())["upload_limit_mbps"] == 50
@pytest.mark.asyncio
@pytest.mark.parametrize("limit", [-1, 2.5, "fast", True])
async def test_an_upload_limit_that_isnt_a_whole_number_is_refused(client, _sealing_key, limit):
resp = await client.patch(
"/api/system/backup/destination", json={**_DEST, "upload_limit_mbps": limit},
)
assert resp.status_code == 400
assert "upload_limit_mbps" in (await resp.get_json())["detail"]
# ── restoring from the off-site bucket ──────────────────────────────────────
@pytest.fixture
def _saved_bucket(monkeypatch):
from backend.app.services import backup_destination
from backend.app.services.backup_s3 import S3Target
target = S3Target("http://archives.example:8081", "curator", "AK", "SK")
monkeypatch.setattr(backup_destination, "saved_target", lambda settings: target)
dispatched = []
monkeypatch.setattr("backend.app.tasks.backup.restore_offsite_task.delay",
lambda **kw: dispatched.append(kw))
return dispatched
_DUMP = "database/fabledcurator-20261107T134258Z.dump"
@pytest.mark.asyncio
async def test_the_bucket_dumps_are_listed_with_their_confirmation(
client, _saved_bucket, monkeypatch,
):
from backend.app.services import backup_restore
monkeypatch.setattr(backup_restore, "list_dumps", lambda target: [
backup_restore.Dump(_DUMP, "20261107T134258Z", 3222868924),
])
resp = await client.get("/api/system/backup/offsite/dumps")
assert resp.status_code == 200
body = await resp.get_json()
assert body["dumps"] == [{
"key": _DUMP, "ts": "20261107T134258Z", "size_bytes": 3222868924,
"counted": False, "confirm": "restore-offsite-20261107T134258Z",
}]
# What a test restore needs free on the database's disk.
assert body["database_bytes"] > 0
@pytest.mark.asyncio
async def test_a_restore_needs_the_dump_typed_back(client, _saved_bucket):
resp = await client.post("/api/system/backup/offsite/restore",
json={"key": _DUMP, "confirm": "yes"})
assert resp.status_code == 400
assert (await resp.get_json())["expected"] == "restore-offsite-20261107T134258Z"
assert _saved_bucket == []
resp = await client.post("/api/system/backup/offsite/restore", json={
"key": _DUMP, "confirm": "restore-offsite-20261107T134258Z"})
assert resp.status_code == 202
assert _saved_bucket == [{"dump_key": _DUMP}]
@pytest.mark.asyncio
async def test_a_restore_waits_while_a_backup_runs(client, _saved_bucket, db_sync):
db_sync.add(BackupRun(kind="db", status="running", triggered_by="nightly",
started_at=datetime.now(UTC), manifest={}))
db_sync.commit()
resp = await client.post("/api/system/backup/offsite/restore", json={
"key": _DUMP, "confirm": "restore-offsite-20261107T134258Z"})
assert resp.status_code == 409
assert "db backup is running" in (await resp.get_json())["detail"]
resp = await client.post("/api/system/backup/offsite/fetch-files", json={})
assert resp.status_code == 409
assert _saved_bucket == []
@pytest.mark.asyncio
async def test_fetching_missing_files_leaves_the_database(client, _saved_bucket):
resp = await client.post("/api/system/backup/offsite/fetch-files", json={})
assert resp.status_code == 202
assert _saved_bucket == [{"files_only": True}]
@pytest.mark.asyncio
async def test_restoring_needs_a_destination(client):
resp = await client.get("/api/system/backup/offsite/dumps")
assert resp.status_code == 400
assert (await resp.get_json())["error"] == "no_destination"
@pytest.mark.asyncio
async def test_restore_runs_are_listed_as_their_own_kind(client, db_sync):
db_sync.add(BackupRun(kind="restore", status="restoring", triggered_by="restore",
started_at=datetime.now(UTC), manifest={"phase": "files"}))
db_sync.commit()
resp = await client.get("/api/system/backup/runs?kind=restore")
runs = (await resp.get_json())["runs"]
assert [r["status"] for r in runs] == ["restoring"]
+120
View File
@@ -0,0 +1,120 @@
"""The Backups row on the System view (services/backup_health.py): quiet while
the backups keep up, a warning that says what is wrong when the newest dump is
more than a day old, the last one failed, or the bucket check found files
missing that no run has sent again since."""
from datetime import UTC, datetime, timedelta
import pytest
from backend.app.models import BackupRun
from backend.app.services import backup_health
from backend.app.services.backup_health import BackupFacts, verdict
NOW = datetime(2026, 3, 4, 12, 0, tzinfo=UTC)
def test_no_row_when_backups_are_not_set_up():
assert verdict(BackupFacts(expected=False, offsite=False), NOW) is None
def test_a_dump_from_last_night_is_ok_and_says_its_age():
part = verdict(BackupFacts(expected=True, offsite=True,
last_dump_at=NOW - timedelta(hours=9)), NOW)
assert part["state"] == "ok"
assert part["detail"] == "The newest database backup to the off-site bucket is 9 h old"
def test_a_dump_older_than_a_day_warns():
part = verdict(BackupFacts(expected=True, offsite=False,
last_dump_at=NOW - timedelta(days=3)), NOW)
assert part["state"] == "stale"
assert part["detail"] == "The newest database backup on this server is 3 days old"
def test_no_dump_yet_warns():
part = verdict(BackupFacts(expected=True, offsite=True), NOW)
assert part["state"] == "stale"
assert "No database backup has been taken to the off-site bucket yet" in part["detail"]
def test_a_failed_dump_warns_with_its_reason_beside_the_age():
part = verdict(BackupFacts(expected=True, offsite=True,
last_dump_at=NOW - timedelta(hours=30),
last_dump_error="S3Error: HTTP 403"), NOW)
assert part["state"] == "stale"
assert "The last database backup failed: S3Error: HTTP 403" in part["detail"]
assert "is 30 h old" in part["detail"]
def test_files_missing_from_the_bucket_warn():
part = verdict(BackupFacts(expected=True, offsite=True,
last_dump_at=NOW - timedelta(hours=2), missing=1234), NOW)
assert part["state"] == "stale"
assert "found 1,234 file(s) missing from the bucket" in part["detail"]
# ── reading the facts ───────────────────────────────────────────────────────
def _run(db_sync, kind, status, at, **kw):
row = BackupRun(kind=kind, status=status, triggered_by="nightly", started_at=at,
finished_at=at + timedelta(minutes=5), **kw)
db_sync.add(row)
db_sync.commit()
return row
@pytest.fixture
def offsite(monkeypatch):
monkeypatch.setattr("backend.app.services.backup_destination.describe",
lambda settings: {"configured": True})
@pytest.mark.integration
def test_the_facts_come_from_the_newest_runs(db_sync, offsite):
now = datetime.now(UTC)
_run(db_sync, "db", "ok", now - timedelta(days=2))
newest = _run(db_sync, "db", "ok", now - timedelta(hours=3))
_run(db_sync, "db", "running", now - timedelta(minutes=1))
facts = backup_health.gather(db_sync)
assert facts.expected and facts.offsite
assert facts.last_dump_at == newest.finished_at
assert facts.last_dump_error is None
assert facts.missing == 0
@pytest.mark.integration
def test_the_newest_finished_dump_failing_is_reported(db_sync, offsite):
now = datetime.now(UTC)
_run(db_sync, "db", "ok", now - timedelta(days=2))
_run(db_sync, "db", "error", now - timedelta(hours=3), error="DumpFailed: disk full")
facts = backup_health.gather(db_sync)
assert facts.last_dump_error == "DumpFailed: disk full"
@pytest.mark.integration
def test_missing_files_count_until_a_media_run_finishes_after_the_check(db_sync, offsite):
now = datetime.now(UTC)
check = _run(db_sync, "reconcile", "ok", now - timedelta(hours=5),
manifest={"listed": 10, "dropped": 3, "adopted": 0})
_run(db_sync, "media", "ok", now - timedelta(days=1))
assert backup_health.gather(db_sync).missing == 3
_run(db_sync, "media", "ok", check.finished_at + timedelta(minutes=1))
assert backup_health.gather(db_sync).missing == 0
@pytest.mark.integration
def test_nothing_is_expected_without_a_destination_or_the_nightly(db_sync, monkeypatch):
monkeypatch.setattr("backend.app.services.backup_destination.describe",
lambda settings: {"configured": False})
assert backup_health.gather(db_sync).expected is False
@pytest.mark.integration
async def test_the_system_view_lists_the_backups_row(client, offsite):
body = await (await client.get("/api/system/health")).get_json()
part = next(p for p in body["parts"] if p["key"] == "backup")
assert part["state"] == "stale"
assert part["detail"].startswith("No database backup has been taken")
+356
View File
@@ -0,0 +1,356 @@
"""The off-site copy of the library's files (services/backup_media.py and the
backup_media_task chunk loop): each image and attachment goes to the bucket
once, by sha256, the work list is what isn't there yet, and a file that can't
be sent is reported and tried again rather than stopping the run."""
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import select
from backend.app.models import (
Artist,
BackupBlob,
BackupRun,
ImageRecord,
Post,
PostAttachment,
)
from backend.app.services import backup_media
from backend.app.services.backup_s3 import ListPage, S3Error, S3Target, StoredObject
pytestmark = pytest.mark.integration
TARGET = S3Target("http://archives.example:8081", "curator", "AK", "SK")
TID = "http://archives.example:8081/curator"
def _sha(c: str) -> str:
return c * 64
def _image(db_sync, tmp_path, c, body=b"img", ext=".jpg"):
path = tmp_path / f"{c}{ext}"
path.write_bytes(body)
db_sync.add(ImageRecord(path=str(path), sha256=_sha(c), size_bytes=len(body),
mime="image/jpeg", origin="imported_filesystem"))
return path
def _attachment(db_sync, tmp_path, c, body=b"zip", name="set.zip"):
path = tmp_path / f"{c}-{name}"
path.write_bytes(body)
db_sync.add(PostAttachment(sha256=_sha(c), path=str(path), original_filename=name,
ext=".zip", size_bytes=len(body)))
return path
# ── keys and the work list ──────────────────────────────────────────────────
def test_keys_are_content_addressed_under_media():
sha = "abcd" + "0" * 60
assert backup_media.blob_key(sha, "/images/x/Pic.JPG") == f"media/ab/cd/{sha}.jpg"
assert backup_media.blob_key(sha, "/images/x/noext") == f"media/ab/cd/{sha}"
assert backup_media.blob_key(sha, "/images/x/a.we ird") == f"media/ab/cd/{sha}"
def test_the_work_list_is_every_file_once_that_the_bucket_lacks(db_sync, tmp_path):
_image(db_sync, tmp_path, "a")
_image(db_sync, tmp_path, "b")
_attachment(db_sync, tmp_path, "c")
# The same archive held by a post as well is one file to send.
artist = Artist(name="Bea", slug="bea")
db_sync.add(artist)
db_sync.flush()
post = Post(artist_id=artist.id, external_post_id="p1")
db_sync.add(post)
db_sync.flush()
db_sync.add(PostAttachment(post_id=post.id, sha256=_sha("c"),
path=str(tmp_path / "c-copy.zip"),
original_filename="set.zip", ext=".zip", size_bytes=3))
db_sync.add(BackupBlob(target=TID, sha256=_sha("b"), key="media/bb", size_bytes=3))
# A row for another bucket doesn't count for this one.
db_sync.add(BackupBlob(target="http://other/bucket", sha256=_sha("a"), key="k",
size_bytes=3))
db_sync.commit()
shas = [b.sha256 for b in backup_media.pending(db_sync, TID)]
assert shas == [_sha("a"), _sha("c")]
assert [b.sha256 for b in backup_media.pending(db_sync, TID, after=_sha("a"))] == [_sha("c")]
cov = backup_media.coverage(db_sync, TID)
assert cov == {"files_total": 3, "bytes_total": 9, "files_sent": 1, "bytes_sent": 3}
# ── sending one file ────────────────────────────────────────────────────────
@pytest.fixture
def puts(monkeypatch):
sent = []
monkeypatch.setattr(backup_media, "put_file",
lambda t, key, path, **kw: sent.append(("whole", key, kw)))
monkeypatch.setattr(backup_media, "put_file_in_parts",
lambda t, key, path, **kw: sent.append(("parts", key, kw)))
return sent
def test_a_file_is_signed_with_its_recorded_sha(db_sync, tmp_path, puts):
path = _image(db_sync, tmp_path, "a")
blob = backup_media.Blob(_sha("a"), str(path), 3, "image/jpeg")
key = backup_media.send(TARGET, blob, tmp_path, None)
assert key == f"media/aa/aa/{_sha('a')}.jpg"
(how, sent_key, kw) = puts[0]
assert (how, sent_key) == ("whole", key)
assert kw["payload_sha256"] == _sha("a") and kw["content_type"] == "image/jpeg"
def test_a_large_file_goes_in_parts(db_sync, tmp_path, puts, monkeypatch):
monkeypatch.setattr(backup_media, "MULTIPART_OVER", 2)
path = _attachment(db_sync, tmp_path, "c")
backup_media.send(TARGET, backup_media.Blob(_sha("c"), str(path), 3, None), tmp_path, None)
assert puts[0][0] == "parts"
assert puts[0][2]["sha256"] == _sha("c")
def test_a_missing_or_changed_file_is_refused_before_sending(db_sync, tmp_path, puts):
gone = backup_media.Blob(_sha("a"), str(tmp_path / "gone.jpg"), 3, None)
with pytest.raises(S3Error, match="not on disk"):
backup_media.send(TARGET, gone, tmp_path, None)
path = _image(db_sync, tmp_path, "b", body=b"longer now")
changed = backup_media.Blob(_sha("b"), str(path), 3, None)
with pytest.raises(S3Error, match="when imported"):
backup_media.send(TARGET, changed, tmp_path, None)
assert puts == []
def test_a_refusal_for_an_object_already_there_counts_as_sent(db_sync, tmp_path, monkeypatch):
def refused(*a, **kw):
raise S3Error("HTTP 403 AccessDenied: append-only.")
monkeypatch.setattr(backup_media, "put_file", refused)
monkeypatch.setattr(backup_media, "object_present", lambda t, key, size: True)
path = _image(db_sync, tmp_path, "a")
key = backup_media.send(TARGET, backup_media.Blob(_sha("a"), str(path), 3, None),
tmp_path, None)
assert key.startswith("media/aa/aa/")
# ── the run ─────────────────────────────────────────────────────────────────
@pytest.fixture
def run_env(monkeypatch, tmp_path):
monkeypatch.setattr("backend.app.tasks.backup.IMAGES_ROOT", tmp_path)
monkeypatch.setattr(
"backend.app.services.backup_destination.saved_target", lambda settings: TARGET,
)
queued = []
from backend.app.tasks import backup as tasks
monkeypatch.setattr(tasks.backup_media_task, "delay", lambda **kw: queued.append(kw))
return queued
def _send_all_but(bad_sha):
def send(target, blob, images_root, pacer):
if blob.sha256 == bad_sha:
raise S3Error("HTTP 503: busy.")
return backup_media.blob_key(blob.sha256, blob.path)
return send
def test_a_run_sends_what_is_missing_and_reports_what_failed(
db_sync, tmp_path, run_env, monkeypatch,
):
from backend.app.tasks.backup import backup_media_task
for c in "abc":
_image(db_sync, tmp_path, c)
db_sync.commit()
monkeypatch.setattr(backup_media, "send", _send_all_but(_sha("b")))
result = backup_media_task(triggered_by="manual")
assert result["continues"] is False
row = db_sync.get(BackupRun, result["backup_run_id"])
db_sync.refresh(row)
assert (row.kind, row.status, row.size_bytes) == ("media", "ok", 6)
assert row.manifest["sent"] == 2 and row.manifest["failed"] == 1
assert row.manifest["offsite"]["bucket"] == "curator"
assert "1 file(s) weren't sent" in row.error
assert row.manifest["failures"][0]["error"] == "HTTP 503: busy."
sent = set(db_sync.execute(select(BackupBlob.sha256)).scalars())
assert sent == {_sha("a"), _sha("c")}
# The next run tries only the file that failed.
monkeypatch.setattr(backup_media, "send", _send_all_but(None))
again = backup_media_task(triggered_by="manual")
row = db_sync.get(BackupRun, again["backup_run_id"])
db_sync.refresh(row)
assert row.manifest["sent"] == 1 and row.error is None
def test_a_run_out_of_time_queues_its_next_chunk(db_sync, tmp_path, run_env, monkeypatch):
from backend.app.tasks import backup as tasks
for c in "ab":
_image(db_sync, tmp_path, c)
db_sync.commit()
monkeypatch.setattr(backup_media, "send", _send_all_but(None))
monkeypatch.setattr(tasks, "MEDIA_CHUNK_SECONDS", -1)
result = tasks.backup_media_task(triggered_by="manual")
assert result["continues"] is True
row = db_sync.get(BackupRun, result["backup_run_id"])
db_sync.refresh(row)
assert row.status == "running" and row.last_progress_at is not None
# The next chunk carries the token of the chunk that queued it.
assert run_env == [{"run_id": row.id, "after": row.manifest["chunk"]}]
def _going(db_sync, chunk):
row = BackupRun(kind="media", status="running", triggered_by="manual",
started_at=datetime.now(UTC), size_bytes=0,
manifest={"sent": 0, "failed": 0, "failures": [], "cursor": "",
"chunk": chunk})
db_sync.add(row)
db_sync.commit()
return row
def test_only_the_chunk_queued_by_the_one_carrying_the_run_goes_on(
db_sync, tmp_path, run_env, monkeypatch,
):
from backend.app.tasks.backup import backup_media_task
_image(db_sync, tmp_path, "a")
row = _going(db_sync, "live")
monkeypatch.setattr(backup_media, "send", _send_all_but(None))
# A killed chunk's own message, delivered again, is dropped.
stale = backup_media_task(run_id=row.id, after="earlier")
assert "another chunk" in stale["skipped"]
assert backup_media_task(run_id=row.id, after="live")["continues"] is False
db_sync.refresh(row)
assert row.status == "ok" and row.manifest["sent"] == 1
def test_a_resume_takes_the_run_and_the_chunk_it_replaced_stops(
db_sync, tmp_path, run_env, monkeypatch,
):
from backend.app.tasks import backup as tasks
for c in "ab":
_image(db_sync, tmp_path, c)
row = _going(db_sync, "killed")
def send_then_lose_the_run(target, blob, images_root, pacer):
# While this chunk sends its first file, a newer one takes the run.
with tasks._sync_session_factory()() as s:
other = s.get(BackupRun, row.id)
other.manifest = {**other.manifest, "chunk": "newer"}
s.commit()
return backup_media.blob_key(blob.sha256, blob.path)
monkeypatch.setattr(backup_media, "send", send_then_lose_the_run)
result = tasks.backup_media_task(run_id=row.id, resume=True)
assert "took the run over" in result["skipped"]
assert run_env == []
db_sync.refresh(row)
assert row.status == "running" and row.manifest["chunk"] == "newer"
assert row.manifest["sent"] == 0 and row.manifest["cursor"] == ""
def test_a_restart_resumes_every_media_run_still_going(db_sync, run_env):
from backend.app.tasks.backup import resume_offsite_runs
going = _going(db_sync, "killed")
db_sync.add(BackupRun(kind="media", status="ok", triggered_by="manual",
started_at=datetime.now(UTC), manifest={}))
db_sync.commit()
assert resume_offsite_runs()["media"] == [going.id]
assert run_env == [{"run_id": going.id, "resume": True}]
def test_a_second_run_waits_for_the_one_going(db_sync, run_env):
from backend.app.tasks.backup import backup_media_task
now = datetime.now(UTC)
db_sync.add(BackupRun(kind="media", status="running", triggered_by="nightly",
started_at=now, manifest={}))
db_sync.commit()
assert "already running" in backup_media_task(triggered_by="manual")["skipped"]
def test_without_a_destination_the_run_fails_with_why(db_sync, monkeypatch):
from backend.app.tasks.backup import backup_media_task
monkeypatch.setattr(
"backend.app.services.backup_destination.saved_target", lambda settings: None,
)
with pytest.raises(S3Error):
backup_media_task(triggered_by="manual")
row = db_sync.execute(select(BackupRun).where(BackupRun.kind == "media")).scalar_one()
assert row.status == "error" and "No off-site destination" in row.error
def test_a_long_media_run_is_judged_stalled_by_its_heartbeat(db_sync):
from backend.app.tasks.maintenance import recover_stalled_backup_runs
long_ago = datetime.now(UTC) - timedelta(hours=20)
alive = BackupRun(kind="media", status="running", triggered_by="nightly",
started_at=long_ago, last_progress_at=datetime.now(UTC), manifest={})
silent = BackupRun(kind="media", status="running", triggered_by="nightly",
started_at=long_ago, last_progress_at=long_ago, manifest={})
db_sync.add_all([alive, silent])
db_sync.commit()
assert recover_stalled_backup_runs() == 1
db_sync.refresh(alive)
db_sync.refresh(silent)
assert (alive.status, silent.status) == ("running", "error")
# ── reconcile ───────────────────────────────────────────────────────────────
def test_reconcile_resends_what_the_bucket_lost_and_adopts_what_it_has(
db_sync, tmp_path, monkeypatch,
):
_image(db_sync, tmp_path, "a") # row, but the bucket lost it
_image(db_sync, tmp_path, "b") # in the bucket, no row
_image(db_sync, tmp_path, "c") # neither
key_a = backup_media.blob_key(_sha("a"), "x.jpg")
key_b = backup_media.blob_key(_sha("b"), "x.jpg")
db_sync.add(BackupBlob(target=TID, sha256=_sha("a"), key=key_a, size_bytes=3))
db_sync.commit()
pages = iter([
ListPage([StoredObject("media/zz/other", 1, "")], "next"),
ListPage([StoredObject(key_b, 3, "")], None),
])
monkeypatch.setattr(backup_media, "list_objects", lambda *a, **kw: next(pages))
monkeypatch.setattr(backup_media.time, "sleep", lambda s: None)
assert backup_media.reconcile(db_sync, TARGET) == {
"listed": 2, "dropped": 1, "adopted": 1,
}
assert set(db_sync.execute(select(BackupBlob.sha256)).scalars()) == {_sha("b")}
def test_the_weekly_check_is_kept_as_a_run_with_its_counts(db_sync, run_env, monkeypatch):
from backend.app.tasks import backup as tasks
monkeypatch.setattr(backup_media, "reconcile",
lambda session, target: {"listed": 9, "dropped": 2, "adopted": 1})
out = tasks.reconcile_media_backup()
row = db_sync.get(BackupRun, out["backup_run_id"])
db_sync.refresh(row)
assert (row.kind, row.status, row.triggered_by) == ("reconcile", "ok", "weekly")
assert row.finished_at is not None
assert {k: row.manifest[k] for k in ("listed", "dropped", "adopted")} == {
"listed": 9, "dropped": 2, "adopted": 1,
}
assert row.manifest["offsite"]["bucket"] == "curator"
def test_a_weekly_check_that_fails_is_kept_with_why(db_sync, run_env, monkeypatch):
from backend.app.tasks import backup as tasks
def unreachable(session, target):
raise S3Error("HTTP 503: busy.")
monkeypatch.setattr(backup_media, "reconcile", unreachable)
with pytest.raises(S3Error):
tasks.reconcile_media_backup()
row = db_sync.execute(select(BackupRun).where(BackupRun.kind == "reconcile")).scalar_one()
assert row.status == "error"
assert row.error == "S3Error: HTTP 503: busy."
+330
View File
@@ -0,0 +1,330 @@
"""Restoring from the off-site bucket (services/backup_restore.py and
restore_offsite_task): the database from a dated dump, then every library
file back at the path its row records, checked against its sha256 on arrival.
The round trip at the bottom backs a seeded library up to an in-process S3
bucket, wipes it, restores it, and compares every file."""
import hashlib
import shutil
import time
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import func, select
from backend.app.models import Artist, BackupRun, ImageRecord, Post, PostAttachment
from backend.app.services import backup_restore, backup_verify
from backend.app.services.backup_s3 import ListPage, ObjectMissing, S3Error, S3Target, StoredObject
from tests.fake_s3 import FakeS3
pytestmark = pytest.mark.integration
TARGET = S3Target("http://archives.example:8081", "curator", "AK", "SK")
def _sha(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _file(root, rel, data):
p = root / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(data)
return p
def _image(db_sync, path, data, mime="image/jpeg"):
db_sync.add(ImageRecord(path=str(path), sha256=_sha(data), size_bytes=len(data),
mime=mime, origin="imported_filesystem"))
# ── dumps ───────────────────────────────────────────────────────────────────
def test_dumps_are_listed_newest_first_and_other_keys_ignored(monkeypatch):
page = ListPage([
StoredObject("database/fabledcurator-20261101T034000Z.dump", 10, ""),
StoredObject("database/fabledcurator-20261107T134258Z.dump", 30, ""),
StoredObject("database/fabledcurator-20261107T134258Z.counts.json", 2, ""),
StoredObject("database/notes.txt", 1, ""),
], None)
monkeypatch.setattr(backup_restore, "list_objects", lambda *a, **kw: page)
dumps = backup_restore.list_dumps(TARGET)
assert [d.ts for d in dumps] == ["20261107T134258Z", "20261101T034000Z"]
assert [d.counted for d in dumps] == [True, False]
assert backup_restore.find_dump(TARGET, "latest").size_bytes == 30
assert backup_restore.find_dump(TARGET, dumps[1].key).ts == "20261101T034000Z"
with pytest.raises(S3Error, match="not among"):
backup_restore.find_dump(TARGET, "database/fabledcurator-1.dump")
def test_an_empty_bucket_has_no_dump_to_restore(monkeypatch):
monkeypatch.setattr(backup_restore, "list_objects", lambda *a, **kw: ListPage([], None))
with pytest.raises(S3Error, match="no database dumps"):
backup_restore.find_dump(TARGET, None)
# ── one file ────────────────────────────────────────────────────────────────
@pytest.fixture
def fetches(monkeypatch):
got = []
def get_file(target, key, path, *, sha256=None, size=None, pacer=None):
got.append(key)
path.write_bytes(b"x" * size)
monkeypatch.setattr(backup_restore, "get_file", get_file)
return got
def test_a_file_on_disk_at_its_size_is_left_alone(tmp_path, fetches):
p = _file(tmp_path, "a/one.jpg", b"xxx")
f = backup_restore.LibraryFile("a" * 64, (str(p),), 3)
assert backup_restore.restore_file(TARGET, f, None, tmp_path, None) == "present"
assert fetches == []
def test_a_missing_file_is_fetched_by_its_key_to_its_path(tmp_path, fetches):
sha = "abcd" + "0" * 60
f = backup_restore.LibraryFile(sha, (str(tmp_path / "bea/one.JPG"),), 4)
assert backup_restore.restore_file(TARGET, f, None, tmp_path, None) == "restored"
assert fetches == [f"media/ab/cd/{sha}.jpg"]
assert (tmp_path / "bea/one.JPG").read_bytes() == b"xxxx"
def test_a_recorded_key_is_used_over_the_computed_one(tmp_path, fetches):
f = backup_restore.LibraryFile("e" * 64, (str(tmp_path / "x.png"),), 2)
backup_restore.restore_file(TARGET, f, "media/ee/ee/recorded.png", tmp_path, None)
assert fetches == ["media/ee/ee/recorded.png"]
def test_one_fetch_fills_every_path_that_shares_the_file(tmp_path, fetches):
paths = (str(tmp_path / "p1/set.zip"), str(tmp_path / "p2/set.zip"))
f = backup_restore.LibraryFile("c" * 64, paths, 5)
assert backup_restore.restore_file(TARGET, f, None, tmp_path, None) == "restored"
assert len(fetches) == 1
assert (tmp_path / "p2/set.zip").read_bytes() == b"xxxxx"
# With one copy left on disk, the other is copied, not fetched.
(tmp_path / "p1/set.zip").unlink()
assert backup_restore.restore_file(TARGET, f, None, tmp_path, None) == "copied"
assert len(fetches) == 1
def test_a_path_outside_the_library_is_refused(tmp_path, fetches):
f = backup_restore.LibraryFile("d" * 64, ("/etc/passwd",), 3)
with pytest.raises(S3Error, match="outside"):
backup_restore.restore_file(TARGET, f, None, tmp_path, None)
f = backup_restore.LibraryFile("d" * 64, (str(tmp_path / "../escape.jpg"),), 3)
with pytest.raises(S3Error, match="outside"):
backup_restore.restore_file(TARGET, f, None, tmp_path, None)
assert fetches == []
def test_the_work_list_gives_each_file_every_path(db_sync, tmp_path):
_image(db_sync, tmp_path / "a.jpg", b"one")
artist = Artist(name="Bea", slug="bea")
db_sync.add(artist)
db_sync.flush()
post = Post(artist_id=artist.id, external_post_id="p1")
db_sync.add(post)
db_sync.flush()
for i, post_id in enumerate((None, post.id)):
db_sync.add(PostAttachment(post_id=post_id, sha256=_sha(b"zip"),
path=str(tmp_path / f"{i}/set.zip"),
original_filename="set.zip", ext=".zip", size_bytes=3))
db_sync.commit()
files = {f.sha256: f for f in backup_restore.library_files(db_sync)}
assert files[_sha(b"zip")].paths == (str(tmp_path / "0/set.zip"), str(tmp_path / "1/set.zip"))
assert files[_sha(b"one")].paths == (str(tmp_path / "a.jpg"),)
# ── the file phase as a run ─────────────────────────────────────────────────
@pytest.fixture
def run_env(monkeypatch, tmp_path):
from backend.app.tasks import backup as tasks
monkeypatch.setattr(tasks, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(
"backend.app.services.backup_destination.saved_target", lambda settings: TARGET,
)
queued, thumbs = [], []
monkeypatch.setattr(tasks.restore_offsite_task, "delay", lambda **kw: queued.append(kw))
monkeypatch.setattr("backend.app.tasks.thumbnail.backfill_thumbnails.delay",
lambda: thumbs.append(True))
return queued, thumbs
def test_fetching_missing_files_counts_each_outcome(db_sync, tmp_path, run_env, monkeypatch):
from backend.app.tasks.backup import restore_offsite_task
_file(tmp_path, "here.jpg", b"here")
_image(db_sync, tmp_path / "here.jpg", b"here")
_image(db_sync, tmp_path / "gone.jpg", b"gone")
_image(db_sync, tmp_path / "lost.jpg", b"lost")
db_sync.commit()
def get_file(target, key, path, *, sha256=None, size=None, pacer=None):
if sha256 == _sha(b"lost"):
raise ObjectMissing(f"{key} is not in the curator bucket.")
path.write_bytes(b"gone")
monkeypatch.setattr(backup_restore, "get_file", get_file)
result = restore_offsite_task(files_only=True)
assert result["continues"] is False
row = db_sync.get(BackupRun, result["backup_run_id"])
db_sync.refresh(row)
m = row.manifest
assert (row.kind, row.status) == ("restore", "restored")
assert (m["restored"], m["present"], m["missing"], m["failed"]) == (1, 1, 1, 0)
assert "1 file(s) aren't in the bucket" in row.error
assert (tmp_path / "gone.jpg").read_bytes() == b"gone"
queued, thumbs = run_env
assert queued == [] and thumbs == [True]
def test_a_restore_out_of_time_queues_its_next_chunk(db_sync, tmp_path, run_env, monkeypatch):
from backend.app.tasks import backup as tasks
_image(db_sync, tmp_path / "a.jpg", b"aa")
db_sync.commit()
monkeypatch.setattr(tasks, "RESTORE_CHUNK_SECONDS", -1)
result = tasks.restore_offsite_task(files_only=True)
queued, _ = run_env
assert result["continues"] is True
row = db_sync.get(BackupRun, result["backup_run_id"])
db_sync.refresh(row)
assert row.status == "restoring" and row.last_progress_at is not None
# The next chunk carries the token of the chunk that queued it.
assert queued == [{"run_id": row.id, "after": row.manifest["chunk"]}]
def test_a_restart_resumes_the_file_phase_and_drops_the_stale_chunk(
db_sync, tmp_path, run_env,
):
from backend.app.tasks.backup import restore_offsite_task, resume_offsite_runs
_file(tmp_path, "a.jpg", b"aa")
_image(db_sync, tmp_path / "a.jpg", b"aa")
now = datetime.now(UTC)
files = BackupRun(kind="restore", status="restoring", triggered_by="restore",
started_at=now, manifest={"phase": "files", "cursor": "",
"failures": [], "chunk": "killed"})
loading = BackupRun(kind="restore", status="restoring", triggered_by="restore",
started_at=now, manifest={"phase": "database"})
db_sync.add_all([files, loading])
db_sync.commit()
queued, _ = run_env
# A restore stopped while loading its database isn't resumed.
resume_offsite_runs()
assert queued == [{"run_id": files.id, "resume": True}]
# The killed chunk's own message, delivered again, is dropped.
assert "another chunk" in restore_offsite_task(run_id=files.id, after="earlier")["skipped"]
done = restore_offsite_task(run_id=files.id, resume=True)
assert done["continues"] is False
db_sync.refresh(files)
assert files.status == "restored" and files.manifest["present"] == 1
def test_a_restore_waits_for_a_backup_that_is_running(db_sync, run_env):
from backend.app.tasks.backup import restore_offsite_task
db_sync.add(BackupRun(kind="media", status="running", triggered_by="nightly",
started_at=datetime.now(UTC), manifest={}))
db_sync.commit()
assert restore_offsite_task(files_only=True)["skipped"] == "a media backup is running"
def test_backups_wait_while_a_restore_is_going(db_sync, monkeypatch):
from backend.app.tasks.backup import backup_db_task, backup_media_task
db_sync.add(BackupRun(kind="restore", status="restoring", triggered_by="restore",
started_at=datetime.now(UTC), manifest={}))
db_sync.commit()
assert backup_db_task(triggered_by="nightly")["skipped"] == "a restore is going"
assert backup_media_task(triggered_by="nightly")["skipped"] == "a restore is going"
def test_a_long_restore_is_judged_stalled_by_its_heartbeat(db_sync):
from backend.app.tasks.maintenance import recover_stalled_backup_runs
long_ago = datetime.now(UTC) - timedelta(hours=20)
alive = BackupRun(kind="restore", status="restoring", triggered_by="restore",
started_at=long_ago, last_progress_at=datetime.now(UTC), manifest={})
silent = BackupRun(kind="restore", status="restoring", triggered_by="restore",
started_at=long_ago, last_progress_at=long_ago, manifest={})
db_sync.add_all([alive, silent])
db_sync.commit()
assert recover_stalled_backup_runs() == 1
db_sync.refresh(alive)
db_sync.refresh(silent)
assert (alive.status, silent.status) == ("restoring", "error")
# ── the round trip ──────────────────────────────────────────────────────────
def test_a_library_comes_back_whole_from_the_bucket(db_sync, tmp_path, monkeypatch):
"""Back the library up to a bucket, lose the files and change the
database, restore from the bucket: the rows are the backup's and every
file is back at its path with its bytes."""
from backend.app.services import backup_destination
from backend.app.tasks import backup as tasks
images = tmp_path / "images"
library = {
"bea/one.jpg": b"first picture",
"bea/two.png": b"second picture, a png",
"attachments/ab/set.zip": b"an archive of pictures" * 50,
}
for rel, data in library.items():
_file(images, rel, data)
_image(db_sync, images / "bea/one.jpg", library["bea/one.jpg"])
_image(db_sync, images / "bea/two.png", library["bea/two.png"], mime="image/png")
db_sync.add(PostAttachment(sha256=_sha(library["attachments/ab/set.zip"]),
path=str(images / "attachments/ab/set.zip"),
original_filename="set.zip", ext=".zip",
size_bytes=len(library["attachments/ab/set.zip"])))
db_sync.commit()
monkeypatch.setattr(tasks, "IMAGES_ROOT", images)
monkeypatch.setattr(backup_destination, "seal", lambda secret: f"sealed:{secret}")
queued = []
monkeypatch.setattr(tasks.restore_offsite_task, "delay", lambda **kw: queued.append(kw))
monkeypatch.setattr("backend.app.tasks.thumbnail.backfill_thumbnails.delay", lambda: None)
with FakeS3() as s3:
monkeypatch.setattr(backup_destination, "saved_target", lambda settings: s3.target())
dumped = tasks.backup_db_task(triggered_by="manual")
tasks.backup_media_task(triggered_by="manual")
dump_key = [k for k in s3.objects if k.endswith(".dump")][0]
# The dump's row counts sit beside it, for a test restore.
assert backup_verify.counts_key(dump_key) in s3.objects
assert len([k for k in s3.objects if k.startswith("media/")]) == 3
# Lose the files, and change the database after the dump.
shutil.rmtree(images)
db_sync.execute(ImageRecord.__table__.delete().where(
ImageRecord.sha256 == _sha(library["bea/two.png"])))
db_sync.add(Artist(name="Added later", slug="added-later"))
db_sync.commit()
time.sleep(1.1) # the safety dump gets its own second-resolution name
first = tasks.restore_offsite_task(dump_key=dump_key)
assert queued == [{"run_id": first["backup_run_id"]}]
done = tasks.restore_offsite_task(run_id=first["backup_run_id"])
assert done["continues"] is False
dumps_now = [k for k in s3.objects if k.endswith(".dump")]
# The database is the dump's: a fresh session, since the restore ended
# every connection that was open.
with tasks._sync_session_factory()() as s:
assert s.execute(select(func.count()).select_from(ImageRecord)).scalar() == 2
assert s.execute(select(Artist).where(Artist.slug == "added-later")).first() is None
row = s.get(BackupRun, done["backup_run_id"])
assert row.status == "restored" and row.error is None
assert row.manifest["restored"] == 3
assert row.manifest["dump"]["key"] == dump_key
# The database it replaced was saved to the bucket first.
assert row.manifest["saved_before"] in dumps_now and len(dumps_now) == 2
assert s.get(BackupRun, dumped["backup_run_id"]).status == "ok"
settings = s.execute(select(tasks.ImportSettings)).scalars().first()
assert settings.backup_s3_secret_sealed == "sealed:SK"
for rel, data in library.items():
assert (images / rel).read_bytes() == data
+398
View File
@@ -0,0 +1,398 @@
"""The backup destination's S3 client (services/backup_s3.py): signatures match
AWS's published Signature Version 4 examples, and the calls send and read what
S3 expects, over a real HTTP connection to a local stand-in server."""
import hashlib
import socket
import threading
from datetime import UTC, datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from backend.app.services import backup_s3 as s3
# AWS's example credentials and moment ("Signature Calculations for the
# Authorization Header", Amazon S3 API reference).
KEY_ID = "AKIAIOSFODNN7EXAMPLE"
SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
AT = datetime(2013, 5, 24, tzinfo=UTC)
def _signature(headers):
return headers["Authorization"].rsplit("Signature=", 1)[1]
def test_aws_example_get_object():
h = s3.sign("GET", "https://examplebucket.s3.amazonaws.com/test.txt",
{"Range": "bytes=0-9"}, s3.EMPTY_SHA256, KEY_ID, SECRET, "us-east-1", AT)
assert "SignedHeaders=host;range;x-amz-content-sha256;x-amz-date," in h["Authorization"]
assert "Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request" in h[
"Authorization"]
assert _signature(h) == "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"
def test_aws_example_list_objects():
h = s3.sign("GET", "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J", {},
s3.EMPTY_SHA256, KEY_ID, SECRET, "us-east-1", AT)
assert _signature(h) == "34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7"
def test_a_changed_secret_changes_the_signature():
url = "https://examplebucket.s3.amazonaws.com/test.txt"
a = s3.sign("GET", url, {}, s3.EMPTY_SHA256, KEY_ID, SECRET, "us-east-1", AT)
b = s3.sign("GET", url, {}, s3.EMPTY_SHA256, KEY_ID, SECRET + "x", "us-east-1", AT)
assert _signature(a) != _signature(b)
def test_paths_are_path_style_and_keys_are_escaped():
t = s3.S3Target("http://archives.example:8081/s3", "curator", KEY_ID, SECRET)
assert t.url("media/ab/cd/abcd.jpg") == "http://archives.example:8081/s3/curator/media/ab/cd/abcd.jpg"
assert t.url() == "http://archives.example:8081/s3/curator/"
assert t.url("a b+c.jpg") == "http://archives.example:8081/s3/curator/a%20b%2Bc.jpg"
# ── Calls, against a local stand-in ─────────────────────────────────────────
class _Server:
"""A local HTTP server that records each request and answers with
`reply(handler) -> (status, body)` or `(status, body, headers)`."""
def __init__(self, reply):
self.seen = []
outer = self
class Handler(BaseHTTPRequestHandler):
def _serve(self):
length = int(self.headers.get("Content-Length") or 0)
outer.seen.append({
"method": self.command, "path": self.path,
"headers": {k.lower(): v for k, v in self.headers.items()},
"body": self.rfile.read(length) if length else b"",
})
status, body, *extra = reply(self)
self.send_response(status)
for name, value in (extra[0] if extra else {}).items():
self.send_header(name, value)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
do_GET = do_PUT = do_POST = do_DELETE = _serve
def log_message(self, *args):
pass
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
def __enter__(self):
self.thread.start()
return self
def __exit__(self, *exc):
self.httpd.shutdown()
self.httpd.server_close()
def target(self, bucket="curator"):
host, port = self.httpd.server_address
return s3.S3Target(f"http://{host}:{port}", bucket, KEY_ID, SECRET)
def test_put_streams_the_file_signed(tmp_path):
f = tmp_path / "abcd.jpg"
f.write_bytes(b"x" * 5000)
with _Server(lambda h: (200, b"")) as srv:
s3.put_file(srv.target(), "media/ab/cd/abcd.jpg", f, content_type="image/jpeg")
req = srv.seen[0]
assert req["method"] == "PUT"
assert req["path"] == "/curator/media/ab/cd/abcd.jpg"
assert req["body"] == b"x" * 5000
assert req["headers"]["x-amz-content-sha256"] == s3.file_sha256(f)
assert req["headers"]["content-type"] == "image/jpeg"
assert req["headers"]["authorization"].startswith(
"AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/")
def test_put_signs_with_a_known_sha_without_rereading(tmp_path, monkeypatch):
f = tmp_path / "b.bin"
f.write_bytes(b"y")
monkeypatch.setattr(s3, "file_sha256", lambda p: pytest.fail("read the file twice"))
with _Server(lambda h: (200, b"")) as srv:
s3.put_file(srv.target(), "b.bin", f, payload_sha256="a" * 64)
assert srv.seen[0]["headers"]["x-amz-content-sha256"] == "a" * 64
def test_a_refusal_says_why(tmp_path):
f = tmp_path / "b.bin"
f.write_bytes(b"x")
body = b"<Error><Code>SignatureDoesNotMatch</Code><Message>no</Message></Error>"
with _Server(lambda h: (403, body)) as srv, pytest.raises(
s3.S3Error, match="HTTP 403 SignatureDoesNotMatch: the secret key is wrong",
):
s3.put_file(srv.target(), "b.bin", f)
def test_a_web_server_at_the_address_is_named_as_not_s3():
"""An address without the S3 port lands on whatever serves the default
port, and its plain 404 says nothing about S3."""
page = b"<html><body><h1>404 Not Found</h1></body></html>"
with _Server(lambda h: (404, page)) as srv, pytest.raises(
s3.S3Error,
match=r"HTTP 404: the address answered, but not as S3 — check the scheme and the port",
):
s3.list_objects(srv.target())
def test_a_server_error_without_a_body_is_not_blamed_on_the_port():
with _Server(lambda h: (503, b"")) as srv, pytest.raises(
s3.S3Error, match="HTTP 503: Service Unavailable",
) as err:
s3.list_objects(srv.target())
assert "port" not in str(err.value)
def test_nothing_listening_says_check_the_port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
target = s3.S3Target(f"http://127.0.0.1:{port}", "curator", KEY_ID, SECRET)
with pytest.raises(s3.S3Error, match="nothing is listening there"):
s3.list_objects(target)
def test_list_reads_contents_and_the_next_page_token():
xml = b"""<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>curator</Name><KeyCount>2</KeyCount><IsTruncated>true</IsTruncated>
<NextContinuationToken>tok-2</NextContinuationToken>
<Contents><Key>database/fabledcurator-20261105T030000Z.dump</Key><Size>1200</Size>
<LastModified>2026-11-05T03:00:01.000Z</LastModified></Contents>
<Contents><Key>database/fabledcurator-20261106T030000Z.dump</Key><Size>1300</Size>
<LastModified>2026-11-06T03:00:01.000Z</LastModified></Contents>
</ListBucketResult>"""
with _Server(lambda h: (200, xml)) as srv:
page = s3.list_objects(srv.target(), "database/", continuation="tok-1")
path = srv.seen[0]["path"]
assert path.startswith("/curator/?")
assert "list-type=2" in path and "prefix=database%2F" in path
assert "continuation-token=tok-1" in path
assert page.next_token == "tok-2"
assert page.objects == [
s3.StoredObject("database/fabledcurator-20261105T030000Z.dump", 1200,
"2026-11-05T03:00:01.000Z"),
s3.StoredObject("database/fabledcurator-20261106T030000Z.dump", 1300,
"2026-11-06T03:00:01.000Z"),
]
def test_a_listing_that_isnt_xml_is_named():
with _Server(lambda h: (200, b"hello")) as srv, pytest.raises(
s3.S3Error, match="isn't S3's XML",
):
s3.list_objects(srv.target())
def test_get_streams_the_object_to_disk(tmp_path):
with _Server(lambda h: (200, b"y" * 3000)) as srv:
out = tmp_path / "b.dump"
s3.get_file(srv.target(), "database/b.dump", out)
assert srv.seen[0]["path"] == "/curator/database/b.dump"
assert srv.seen[0]["headers"]["x-amz-content-sha256"] == s3.EMPTY_SHA256
assert out.read_bytes() == b"y" * 3000
assert not (tmp_path / "b.dump.partial").exists()
def test_a_refused_get_says_so_and_writes_nothing(tmp_path):
body = b"<Error><Code>AccessDenied</Code></Error>"
out = tmp_path / "b.dump"
with _Server(lambda h: (403, body)) as srv, pytest.raises(s3.S3Error, match="b.dump"):
s3.get_file(srv.target(), "b.dump", out)
assert not out.exists()
assert not (tmp_path / "b.dump.partial").exists()
def test_a_missing_object_is_named_as_missing(tmp_path):
body = b"<Error><Code>NoSuchKey</Code></Error>"
with _Server(lambda h: (404, body)) as srv, pytest.raises(s3.ObjectMissing):
s3.get_file(srv.target(), "media/ab/cd/x.jpg", tmp_path / "x.jpg")
def test_a_fetched_file_is_kept_only_when_its_fingerprint_matches(tmp_path):
good = b"picture bytes"
sha = hashlib.sha256(good).hexdigest()
with _Server(lambda h: (200, good)) as srv:
s3.get_file(srv.target(), "k", tmp_path / "ok.jpg", sha256=sha, size=len(good))
assert (tmp_path / "ok.jpg").read_bytes() == good
with _Server(lambda h: (200, b"other bytes!!")) as srv, \
pytest.raises(s3.S3Error, match="different fingerprint"):
s3.get_file(srv.target(), "k", tmp_path / "bad.jpg", sha256=sha, size=len(good))
assert not (tmp_path / "bad.jpg").exists()
assert not (tmp_path / "bad.jpg.partial").exists()
def test_a_short_download_is_not_kept(tmp_path):
with _Server(lambda h: (200, b"abc")) as srv, pytest.raises(s3.S3Error, match="3 bytes"):
s3.get_file(srv.target(), "k", tmp_path / "x", size=10)
assert not (tmp_path / "x").exists()
def test_a_paced_download_counts_its_bytes(tmp_path):
pacer = s3.Pacer(None)
taken = []
pacer.take = taken.append
with _Server(lambda h: (200, b"z" * 2500)) as srv:
s3.get_file(srv.target(), "k", tmp_path / "z", pacer=pacer)
assert sum(taken) == 2500
# ── Multipart ───────────────────────────────────────────────────────────────
_STARTED = b"<InitiateMultipartUploadResult><UploadId>up1</UploadId></InitiateMultipartUploadResult>"
def _bucket(parts_reply=None, complete=(200, b"<CompleteMultipartUploadResult/>")):
"""A stand-in bucket for one multipart upload: starts it, takes parts (each
answered by `parts_reply(n)`, an ETag by default) and completes it."""
def reply(h):
if h.command == "POST" and h.path.endswith("?uploads"):
return 200, _STARTED
if h.command == "PUT":
n = int(h.path.split("partNumber=")[1].split("&")[0])
return parts_reply(n) if parts_reply else (200, b"", {"ETag": f'"e{n}"'})
if h.command == "POST":
return complete
return 204, b""
return reply
def test_an_upload_sends_its_parts_then_joins_them_in_order():
with _Server(_bucket()) as srv:
up = s3.Upload(srv.target(), "database/x.dump")
up.add(b"first,")
up.add(b"second")
up.complete()
start, one, two, done = srv.seen
assert (start["method"], start["path"]) == ("POST", "/curator/database/x.dump?uploads")
assert one["path"] == "/curator/database/x.dump?partNumber=1&uploadId=up1"
assert two["body"] == b"second"
assert two["headers"]["x-amz-content-sha256"] == hashlib.sha256(b"second").hexdigest()
assert done["path"] == "/curator/database/x.dump?uploadId=up1"
assert done["body"].index(b'"e1"') < done["body"].index(b'"e2"')
assert (up.parts, up.size) == (2, len(b"first,second"))
def test_a_refused_start_says_why():
with _Server(lambda h: (403, b"<Error><Code>AccessDenied</Code></Error>")) as srv:
with pytest.raises(s3.S3Error, match="the key isn't allowed"):
s3.Upload(srv.target(), "database/x.dump")
def test_a_completion_refused_under_a_200_is_a_failure():
error = (200, b"<Error><Code>InternalError</Code><Message>disk full</Message></Error>")
with _Server(_bucket(complete=error)) as srv:
up = s3.Upload(srv.target(), "database/x.dump")
up.add(b"data")
with pytest.raises(s3.S3Error, match="Finishing the upload"):
up.complete()
def test_nothing_sent_is_not_completed():
with _Server(_bucket()) as srv:
up = s3.Upload(srv.target(), "database/x.dump")
with pytest.raises(s3.S3Error, match="Nothing was sent"):
up.complete()
def test_a_busy_bucket_gets_the_part_again_after_a_rest(monkeypatch):
rests = []
monkeypatch.setattr(s3.time, "sleep", rests.append)
answers = iter([(503, b"", {"Retry-After": "7"}), (200, b"", {"ETag": '"e1"'})])
with _Server(_bucket(parts_reply=lambda n: next(answers))) as srv:
up = s3.Upload(srv.target(), "database/x.dump")
up.add(b"data")
assert rests == [7]
assert up.parts == 1
def test_a_part_refused_outright_is_not_sent_again(monkeypatch):
monkeypatch.setattr(s3.time, "sleep", lambda s: pytest.fail("rested on a refusal"))
refused = (403, b"<Error><Code>AccessDenied</Code></Error>")
with _Server(_bucket(parts_reply=lambda n: refused)) as srv:
up = s3.Upload(srv.target(), "database/x.dump")
with pytest.raises(s3.S3Error, match="Part 1"):
up.add(b"data")
def test_a_part_that_never_lands_gives_up_after_the_rests(monkeypatch):
rests = []
monkeypatch.setattr(s3.time, "sleep", rests.append)
with _Server(_bucket(parts_reply=lambda n: (503, b""))) as srv:
up = s3.Upload(srv.target(), "database/x.dump")
with pytest.raises(s3.S3Error, match="Part 1"):
up.add(b"data")
assert rests == list(s3.PART_RETRY_WAITS)
def test_abort_asks_the_bucket_to_drop_the_parts_and_never_raises():
with _Server(_bucket()) as srv:
up = s3.Upload(srv.target(), "database/x.dump")
up.abort()
assert srv.seen[-1]["method"] == "DELETE"
assert srv.seen[-1]["path"] == "/curator/database/x.dump?uploadId=up1"
up.abort() # the server is gone now: still no exception
def test_the_pacer_holds_bytes_to_the_limit(monkeypatch):
clock = [0.0]
monkeypatch.setattr(s3.time, "monotonic", lambda: clock[0])
monkeypatch.setattr(s3.time, "sleep", lambda secs: clock.__setitem__(0, clock[0] + secs))
pacer = s3.Pacer(8) # 8 Mbit/s = 1,000,000 bytes/s
data = b"x" * (3 * s3.CHUNK)
assert b"".join(pacer.chunks(data)) == data
assert clock[0] == pytest.approx(len(data) / 1_000_000)
def test_no_limit_never_waits(monkeypatch):
monkeypatch.setattr(s3.time, "sleep", lambda secs: pytest.fail("waited without a limit"))
assert b"".join(s3.Pacer(0).chunks(b"y" * (2 * s3.CHUNK + 1))) == b"y" * (2 * s3.CHUNK + 1)
def test_a_large_file_is_sent_in_parts_and_checked_against_its_sha(tmp_path, monkeypatch):
monkeypatch.setattr(s3, "PART_SIZE", 4)
f = tmp_path / "big.zip"
f.write_bytes(b"0123456789")
with _Server(_bucket()) as srv:
sent = s3.put_file_in_parts(srv.target(), "media/x.zip", f,
sha256=hashlib.sha256(b"0123456789").hexdigest())
assert sent == 10
assert [r["body"] for r in srv.seen if r["method"] == "PUT"] == [b"0123", b"4567", b"89"]
assert srv.seen[-1]["method"] == "POST" # completed
def test_a_file_that_changed_since_import_is_not_completed(tmp_path, monkeypatch):
monkeypatch.setattr(s3, "PART_SIZE", 4)
f = tmp_path / "big.zip"
f.write_bytes(b"0123456789")
with _Server(_bucket()) as srv:
with pytest.raises(s3.S3Error, match="recorded checksum"):
s3.put_file_in_parts(srv.target(), "media/x.zip", f, sha256="0" * 64)
assert srv.seen[-1]["method"] == "DELETE" # aborted, nothing stored
def test_a_paced_put_sends_the_same_bytes(tmp_path):
f = tmp_path / "a.bin"
f.write_bytes(b"z" * (s3.CHUNK + 5))
with _Server(lambda h: (200, b"")) as srv:
s3.put_file(srv.target(), "a.bin", f, pacer=s3.Pacer(0))
assert srv.seen[0]["body"] == b"z" * (s3.CHUNK + 5)
def test_presence_is_the_exact_key_at_the_exact_size():
listing = (b"<ListBucketResult><Contents><Key>media/a.jpg</Key><Size>3</Size>"
b"</Contents></ListBucketResult>")
with _Server(lambda h: (200, listing)) as srv:
assert s3.object_present(srv.target(), "media/a.jpg", 3)
assert not s3.object_present(srv.target(), "media/a.jpg", 4)
assert not s3.object_present(srv.target(), "media/a.jp", 3)
+227 -69
View File
@@ -1,10 +1,11 @@
"""FC-3h: backup_service unit tests.
Subprocess calls (pg_dump, tar, psql) are monkeypatched so tests run
Subprocess calls (pg_dump, pg_restore, psql) are monkeypatched so tests run
without external binaries. The real subprocess behavior is exercised
implicitly via the Celery task tests in test_tasks_backup.py.
"""
import json
import re
import subprocess
from pathlib import Path
@@ -28,12 +29,9 @@ def fake_subprocess(monkeypatch):
stderr = b""
def _write_sentinel(cmd):
if cmd[0] == "pg_dump":
if "pg_dump" in cmd:
i = cmd.index("-f")
Path(cmd[i + 1]).write_bytes(b"-- fake pg_dump\n")
elif cmd[0] == "tar" and "-cf" in cmd:
i = cmd.index("-cf")
Path(cmd[i + 1]).write_bytes(b"fake tar payload")
def _fake_run(cmd, **kwargs):
calls.append(list(cmd))
@@ -57,9 +55,36 @@ def fake_subprocess(monkeypatch):
return calls
class _Snapshot:
"""Stands in for the snapshot a dump is taken from."""
id = "00000003-0000001B-1"
COUNTS = {"alembic": "0121", "tables": {"credential": 0, "image_record": 2}}
def counts(self):
return self.COUNTS
@pytest.fixture(autouse=True)
def fake_snapshot(monkeypatch):
from contextlib import contextmanager
@contextmanager
def _snapshot(db_url):
yield _Snapshot()
monkeypatch.setattr(backup_service, "dump_snapshot", _snapshot)
# --- backup_db -------------------------------------------------------
def test_a_dump_is_taken_from_the_snapshot_its_row_counts_come_from(tmp_path, fake_subprocess):
result = backup_service.backup_db(db_url="postgresql://u@h/d", images_root=tmp_path)
assert f"--snapshot={_Snapshot.id}" in fake_subprocess[0]
assert result["counts"] == _Snapshot.COUNTS
manifest = json.loads(Path(result["manifest_path"]).read_text())
assert manifest["counts"] == _Snapshot.COUNTS
def test_backup_db_writes_sql_and_manifest(tmp_path, fake_subprocess):
result = backup_service.backup_db(
db_url="postgresql://test@x/db",
@@ -72,7 +97,6 @@ def test_backup_db_writes_sql_and_manifest(tmp_path, fake_subprocess):
assert sql.is_file() and sql.suffix == ".dump"
assert manifest.is_file() and manifest.suffix == ".json"
assert result["kind"] == "db"
assert result["tar_path"] is None
assert result["size_bytes"] == len(b"-- fake pg_dump\n")
parsed = json.loads(manifest.read_text())
@@ -87,7 +111,7 @@ def test_backup_db_strips_sqlalchemy_psycopg_driver(tmp_path, fake_subprocess):
db_url="postgresql+psycopg://u:p@h/d", images_root=tmp_path,
)
cmd = fake_subprocess[0]
assert cmd[0] == "pg_dump"
assert "pg_dump" in cmd
assert cmd[-1].startswith("postgresql://")
assert "+psycopg" not in cmd[-1]
@@ -113,50 +137,6 @@ def test_backup_db_default_tag_is_none(tmp_path, fake_subprocess):
assert parsed["tag"] is None
# --- backup_images ---------------------------------------------------
def test_backup_images_writes_tar_and_manifest(tmp_path, fake_subprocess):
result = backup_service.backup_images(
images_root=tmp_path, tag="monthly", triggered_by="manual",
)
tar = Path(result["tar_path"])
assert tar.is_file() and tar.name.endswith(".tar.zst")
assert result["sql_path"] is None
assert result["size_bytes"] == len(b"fake tar payload")
def test_backup_images_excludes_backups_and_quarantine(tmp_path, fake_subprocess):
backup_service.backup_images(images_root=tmp_path)
cmd = fake_subprocess[0]
excludes = [arg for arg in cmd if arg.startswith("--exclude=")]
assert any("_backups" in e for e in excludes)
assert any("_quarantine" in e for e in excludes)
def test_backup_images_excludes_credentials(tmp_path, fake_subprocess):
"""#4234: the images tarball carried `secrets/credential_key.b64` — the key
that decrypts the stored session cookies — and `cookies/` itself. A media
archive must not be a credential leak; encryption at rest is worth nothing
if the key travels with the data."""
backup_service.backup_images(images_root=tmp_path)
excludes = [a for a in fake_subprocess[0] if a.startswith("--exclude=")]
assert any(e.endswith("/secrets") for e in excludes)
assert any(e.endswith("/cookies") for e in excludes)
def test_backup_images_excludes_are_root_relative(tmp_path, fake_subprocess):
"""tar matches --exclude against the archived path, which is prefixed with
the root's own directory name (`-C <parent> <name>`). A bare `secrets`
would also match an ARTIST folder called secrets; the prefix is what keeps
the exclusion to the top level."""
backup_service.backup_images(images_root=tmp_path)
excludes = [a for a in fake_subprocess[0] if a.startswith("--exclude=")]
assert excludes and all(
e.startswith(f"--exclude={tmp_path.name}/") for e in excludes
)
# --- restore_db ------------------------------------------------------
@@ -171,22 +151,72 @@ def test_restore_db_drops_schema_then_loads(tmp_path, fake_subprocess):
assert fake_subprocess[0][0] == "psql"
assert "-c" in fake_subprocess[0]
assert "DROP SCHEMA IF EXISTS public CASCADE" in fake_subprocess[0][-1]
assert fake_subprocess[1][0] == "pg_restore"
assert "pg_terminate_backend" in fake_subprocess[0][-1]
assert "pg_restore" in fake_subprocess[1]
assert "-d" in fake_subprocess[1]
assert str(dump_path) in fake_subprocess[1]
# --- restore_images --------------------------------------------------
def _pg_restore_says(monkeypatch, stderr_text: bytes):
class _Done:
returncode = 1
class _Ok:
returncode = 0
def run(cmd, stderr=None, **kw):
if "pg_restore" in cmd:
stderr.write(stderr_text)
return _Done()
return _Ok()
monkeypatch.setattr("subprocess.run", run)
def test_restore_images_untar_to_parent(tmp_path, fake_subprocess):
tar_path = tmp_path / "fake.tar.zst"
tar_path.write_bytes(b"fake")
backup_service.restore_images(images_root=tmp_path, tar_path=tar_path)
cmd = fake_subprocess[0]
assert cmd[:3] == ["tar", "--zstd", "-xf"]
assert str(tar_path) in cmd
assert "-C" in cmd
_NEWER_CLIENT = (
b'pg_restore: error: could not execute query: ERROR: unrecognized configuration '
b'parameter "transaction_timeout"\nCommand was: SET transaction_timeout = 0;\n'
)
def test_a_newer_pg_restore_setting_what_the_server_lacks_is_not_a_failure(
tmp_path, monkeypatch,
):
_pg_restore_says(monkeypatch, _NEWER_CLIENT
+ b"pg_restore: warning: errors ignored on restore: 1\n")
backup_service.restore_db(db_url="postgresql://u@h/d", sql_path=tmp_path / "x.dump")
def test_a_real_error_beside_the_harmless_one_fails_the_restore_with_its_words(
tmp_path, monkeypatch,
):
_pg_restore_says(monkeypatch, _NEWER_CLIENT
+ b'pg_restore: error: could not execute query: ERROR: relation '
b'"tag" does not exist\nCommand was: COPY tag FROM stdin;\n'
+ b"pg_restore: warning: errors ignored on restore: 2\n")
with pytest.raises(backup_service.RestoreFailed, match='relation "tag" does not exist'):
backup_service.restore_db(db_url="postgresql://u@h/d", sql_path=tmp_path / "x.dump")
def test_a_failed_restore_says_what_pg_restore_said(tmp_path, monkeypatch):
class _Done:
def __init__(self, code):
self.returncode = code
def run(cmd, stderr=None, **kw):
if "pg_restore" in cmd:
stderr.write(b"pg_restore: processing data\npg_restore: error: disk full\n")
return _Done(1)
return _Done(0)
monkeypatch.setattr("subprocess.run", run)
with pytest.raises(backup_service.RestoreFailed, match="error: disk full"):
backup_service.restore_db(db_url="postgresql://u@h/d", sql_path=tmp_path / "x.dump")
def test_every_dump_leaves_out_account_logins(tmp_path, fake_subprocess):
backup_service.backup_db(db_url="postgresql://u@h/d", images_root=tmp_path)
assert "--exclude-table-data=credential" in fake_subprocess[0]
# --- unlink ----------------------------------------------------------
@@ -195,22 +225,19 @@ def test_restore_images_untar_to_parent(tmp_path, fake_subprocess):
def test_unlink_removes_present_files_and_reports(tmp_path):
sql = tmp_path / "x.sql"
sql.write_bytes(b"x")
tar = tmp_path / "x.tar.zst"
tar.write_bytes(b"x")
manifest = tmp_path / "x.json"
manifest.write_text("{}")
result = backup_service.unlink_artifact_files(
sql_path=str(sql), tar_path=str(tar), manifest_path=str(manifest),
sql_path=str(sql), manifest_path=str(manifest),
)
assert result == {"sql": True, "tar": True, "manifest": True}
assert not sql.exists() and not tar.exists() and not manifest.exists()
assert result == {"sql": True, "manifest": True}
assert not sql.exists() and not manifest.exists()
def test_unlink_missing_files_returns_true(tmp_path):
"""missing_ok semantics: a non-existent file isn't an error."""
result = backup_service.unlink_artifact_files(
sql_path=str(tmp_path / "nope.sql"),
tar_path=None, manifest_path=None,
sql_path=str(tmp_path / "nope.sql"), manifest_path=None,
)
assert result == {"sql": True}
@@ -218,7 +245,7 @@ def test_unlink_missing_files_returns_true(tmp_path):
def test_unlink_skips_none_paths():
"""A None path is skipped — not added to the result dict."""
result = backup_service.unlink_artifact_files(
sql_path=None, tar_path=None, manifest_path=None,
sql_path=None, manifest_path=None,
)
assert result == {}
@@ -284,3 +311,134 @@ def test_run_bounded_fails_fast_when_unkillable(monkeypatch):
with pytest.raises(subprocess.TimeoutExpired):
backup_service._run_bounded(["pg_dump"], 1)
assert killed["n"] == 1
# --- backup_db_offsite: pg_dump streamed to the bucket ---------------
class _Dump:
"""A pg_dump stand-in: `output` on stdout, `said` on stderr, exiting with
`code` once its output has been read."""
def __init__(self, output=b"", said=b"", code=0):
self.output, self.said, self.code = output, said, code
self.cmd = None
self.killed = False
def __call__(self, cmd, stdout=None, stderr=None, **kwargs):
import io
self.cmd = list(cmd)
self.args = cmd
self.stdout = io.BytesIO(self.output)
stderr.write(self.said)
self.returncode = None
return self
def poll(self):
return self.returncode
def wait(self, timeout=None):
if self.returncode is None:
self.returncode = -9 if self.killed else self.code
return self.returncode
def kill(self):
self.killed = True
class _Bucket:
"""Records what an Upload was asked to do instead of sending it."""
def __init__(self, fail_on_part=None):
self.fail_on_part = fail_on_part
self.uploads = []
def __call__(self, target, key, **kwargs):
from backend.app.services.backup_s3 import S3Error
bucket = self
class _Upload:
def __init__(self):
self.key, self.received = key, []
self.completed = self.aborted = False
bucket.uploads.append(self)
@property
def parts(self):
return len(self.received)
@property
def size(self):
return sum(len(p) for p in self.received)
def add(self, data):
if bucket.fail_on_part == len(self.received) + 1:
raise S3Error("HTTP 503: busy.")
self.received.append(data)
def complete(self):
self.completed = True
def abort(self):
self.aborted = True
return _Upload()
@pytest.fixture
def offsite(monkeypatch):
from backend.app.services import backup_s3
monkeypatch.setattr(backup_s3, "PART_SIZE", 4)
bucket = _Bucket()
monkeypatch.setattr(backup_s3, "Upload", bucket)
target = backup_s3.S3Target("http://archives.example:8081", "curator", "AK", "SK")
return bucket, target
def test_a_dump_streams_to_the_bucket_in_parts_with_nothing_kept_here(offsite, monkeypatch):
bucket, target = offsite
dump = _Dump(output=b"0123456789")
monkeypatch.setattr("subprocess.Popen", dump)
result = backup_service.backup_db_offsite(
db_url="postgresql+psycopg://u:p@h/d", target=target, limit_mbps=50,
)
(upload,) = bucket.uploads
assert upload.received == [b"0123", b"4567", b"89"]
assert upload.completed and not upload.aborted
assert re.fullmatch(r"database/fabledcurator-\d{8}T\d{6}Z\.dump", result["key"])
assert result["key"] == upload.key
assert (result["bucket"], result["size_bytes"], result["parts"]) == ("curator", 10, 3)
assert dump.cmd[0] == "nice" and "pg_dump" in dump.cmd and "-Fc" in dump.cmd
assert "-f" not in dump.cmd # stdout, not a file
assert dump.cmd[-1] == "postgresql://u:p@h/d"
assert f"--snapshot={_Snapshot.id}" in dump.cmd
assert result["counts"] == _Snapshot.COUNTS
def test_a_failed_dump_leaves_nothing_in_the_bucket_and_says_why(offsite, monkeypatch):
bucket, target = offsite
said = b"pg_dump: dumping contents of table x\npg_dump: error: connection lost\n"
monkeypatch.setattr("subprocess.Popen", _Dump(output=b"012345", said=said, code=1))
with pytest.raises(backup_service.DumpFailed, match="^pg_dump: error: connection lost$"):
backup_service.backup_db_offsite(db_url="postgresql://u@h/d", target=target)
(upload,) = bucket.uploads
assert upload.aborted and not upload.completed
def test_a_bucket_that_stops_taking_parts_stops_the_dump(offsite, monkeypatch):
from backend.app.services.backup_s3 import S3Error
bucket, target = offsite
bucket.fail_on_part = 2
dump = _Dump(output=b"0123456789")
monkeypatch.setattr("subprocess.Popen", dump)
with pytest.raises(S3Error):
backup_service.backup_db_offsite(db_url="postgresql://u@h/d", target=target)
assert dump.killed
(upload,) = bucket.uploads
assert upload.aborted and not upload.completed
def test_dump_names_sort_by_time():
assert backup_service.offsite_dump_key("20261107T030000Z") == (
"database/fabledcurator-20261107T030000Z.dump"
)
+268
View File
@@ -0,0 +1,268 @@
"""Testing a dump without touching this install (services/backup_verify.py and
restore_test_task): the dump is loaded into a temporary database, every table
is checked against the counts recorded with the dump, and the temporary
database is dropped. The round trips at the bottom dump the test database to
an in-process bucket with the real pg_dump and test it with the real
pg_restore."""
import json
from datetime import UTC, datetime
from pathlib import Path
import pytest
from sqlalchemy import func, select, text
from backend.app.models import Artist, BackupRun
from backend.app.services import backup_service, backup_verify
from backend.app.services.backup_verify import compare, summary
from tests.fake_s3 import FakeS3
LIVE = {"alembic": "0121", "tables": {"artist": 12, "image_record": 40, "post": 7}}
def _counts(**tables):
return {"alembic": "0121", "tables": tables}
def test_a_dump_that_comes_back_whole_matches_every_table():
recorded = _counts(artist=10, image_record=40, post=7)
result = compare(recorded, _counts(artist=10, image_record=40, post=7), LIVE)
assert result["counted"] and result["mismatched"] == []
assert result["rows_restored"] == 57
artist = next(t for t in result["tables"] if t["table"] == "artist")
assert artist == {"table": "artist", "dump": 10, "restored": 10, "live": 12}
assert summary(result) == "All 3 tables came back with the 57 rows the dump recorded."
def test_a_table_with_rows_missing_or_gone_is_named():
recorded = _counts(artist=10, image_record=40, post=7)
result = compare(recorded, _counts(artist=10, image_record=39), LIVE)
assert result["mismatched"] == ["image_record", "post"]
assert summary(result) == (
"2 table(s) came back different: image_record (40 in the dump, 39 restored), "
"post (7 in the dump, table missing).")
def test_a_dump_without_counts_is_shown_beside_the_live_database():
result = compare(None, _counts(artist=10, image_record=40), LIVE)
assert not result["counted"] and result["mismatched"] == []
assert next(t for t in result["tables"] if t["table"] == "post")["restored"] is None
assert summary(result).startswith("The dump loaded cleanly: 2 tables, 50 rows.")
def test_the_counts_sit_beside_the_dump_and_the_test_database_beside_the_live_one():
key = "database/fabledcurator-20261107T134258Z.dump"
assert backup_verify.counts_key(key) == "database/fabledcurator-20261107T134258Z.counts.json"
url = "postgresql+psycopg://fc:pw@postgres:5432/fabledcurator"
assert backup_verify.scratch_name(url) == "fabledcurator_restore_test"
assert backup_verify.scratch_url(url) == (
"postgresql+psycopg://fc:pw@postgres:5432/fabledcurator_restore_test")
# ── the task ────────────────────────────────────────────────────────────────
def _local_dump(db_sync, tmp_path, counts):
dump = tmp_path / "fc_db_20261107T030000Z.dump"
dump.write_bytes(b"a dump")
manifest = tmp_path / "fc_db_20261107T030000Z.json"
manifest.write_text(json.dumps({"kind": "db", "counts": counts}))
row = BackupRun(kind="db", status="ok", triggered_by="manual", sql_path=str(dump),
started_at=datetime.now(UTC),
size_bytes=6, manifest={"ts": "20261107T030000Z",
"manifest_path": str(manifest)})
db_sync.add(row)
db_sync.commit()
return row
@pytest.mark.integration
def test_a_dump_on_this_server_is_tested_against_its_manifest(db_sync, tmp_path, monkeypatch):
from backend.app.tasks import backup as tasks
recorded = _counts(artist=3)
src = _local_dump(db_sync, tmp_path, recorded)
seen = {}
def _trial(*, db_url, dump_path, recorded, note):
seen.update(dump_path=dump_path, recorded=recorded)
note("comparing")
return {**compare(recorded, _counts(artist=3), _counts(artist=5)),
"alembic_dump": "0121", "alembic_now": "0121"}
monkeypatch.setattr(backup_verify, "trial_restore", _trial)
out = tasks.restore_test_task(source_backup_run_id=src.id)
db_sync.expire_all()
row = db_sync.get(BackupRun, out["backup_run_id"])
assert seen == {"dump_path": Path(src.sql_path), "recorded": recorded}
assert (row.kind, row.status, row.error) == ("test", "ok", None)
assert row.manifest["summary"] == "All 1 tables came back with the 3 rows the dump recorded."
assert row.manifest["dump"]["run_id"] == src.id
assert Path(src.sql_path).exists() # the dump itself stays
@pytest.mark.integration
def test_a_test_that_finds_a_difference_ends_in_error_saying_which(db_sync, tmp_path, monkeypatch):
from backend.app.tasks import backup as tasks
src = _local_dump(db_sync, tmp_path, _counts(artist=3))
monkeypatch.setattr(backup_verify, "trial_restore", lambda *, recorded, **kw: {
**compare(recorded, _counts(artist=2), _counts(artist=2)),
"alembic_dump": "0121", "alembic_now": "0121"})
out = tasks.restore_test_task(source_backup_run_id=src.id)
db_sync.expire_all()
row = db_sync.get(BackupRun, out["backup_run_id"])
assert row.status == "error"
assert row.error == "1 table(s) came back different: artist (3 in the dump, 2 restored)."
@pytest.mark.integration
def test_a_dump_that_wont_load_is_recorded_with_why(db_sync, tmp_path, monkeypatch):
from backend.app.tasks import backup as tasks
src = _local_dump(db_sync, tmp_path, None)
def _fails(**kw):
raise backup_service.RestoreFailed("pg_restore: error: could not read input file")
monkeypatch.setattr(backup_verify, "trial_restore", _fails)
with pytest.raises(backup_service.RestoreFailed):
tasks.restore_test_task(source_backup_run_id=src.id)
row = db_sync.execute(select(BackupRun).where(BackupRun.kind == "test")).scalar_one()
assert row.status == "error"
assert row.error == "RestoreFailed: pg_restore: error: could not read input file"
@pytest.mark.integration
def test_no_test_starts_while_a_restore_or_another_test_is_going(db_sync, tmp_path):
from backend.app.tasks import backup as tasks
src = _local_dump(db_sync, tmp_path, None)
db_sync.add(BackupRun(kind="test", status="running", triggered_by="manual",
started_at=datetime.now(UTC)))
db_sync.commit()
assert tasks.restore_test_task(source_backup_run_id=src.id) == {
"skipped": "a test restore is already running"}
assert tasks.backup_busy(db_sync) == "a test restore is running"
@pytest.mark.integration
def test_a_test_cut_off_by_a_restart_is_ended_and_its_database_dropped(db_sync, monkeypatch):
from backend.app.tasks import backup as tasks
dropped = []
monkeypatch.setattr(backup_verify, "drop_scratch", lambda url: dropped.append(url))
row = BackupRun(kind="test", status="running", triggered_by="manual",
started_at=datetime.now(UTC))
db_sync.add(row)
db_sync.commit()
assert tasks.end_cut_off_tests() == 1
db_sync.expire_all()
assert db_sync.get(BackupRun, row.id).status == "error"
assert len(dropped) == 1
# ── the screen's endpoints ──────────────────────────────────────────────────
@pytest.mark.integration
async def test_a_dump_on_this_server_can_be_tested_from_the_screen(client, db_sync, tmp_path,
monkeypatch):
from backend.app.tasks import backup as tasks
queued = []
monkeypatch.setattr(tasks.restore_test_task, "delay", lambda **kw: queued.append(kw))
src = _local_dump(db_sync, tmp_path, None)
media = BackupRun(kind="media", status="ok", triggered_by="manual",
started_at=datetime.now(UTC))
db_sync.add(media)
db_sync.commit()
r = await client.post(f"/api/system/backup/runs/{media.id}/test", json={})
assert r.status_code == 400
r = await client.post(f"/api/system/backup/runs/{src.id}/test", json={})
assert r.status_code == 202
assert queued == [{"source_backup_run_id": src.id}]
r = await client.post("/api/system/backup/offsite/test", json={"key": "../etc"})
assert r.status_code == 400
# ── round trips with the real pg_dump and pg_restore ────────────────────────
def _scratch_exists(db_sync) -> bool:
from backend.app.config import get_config
name = backup_verify.scratch_name(get_config().database_url_sync)
return db_sync.execute(text("SELECT 1 FROM pg_database WHERE datname = :n"),
{"n": name}).first() is not None
@pytest.mark.integration
def test_the_counts_are_read_in_the_snapshot_the_dump_is_taken_from(db_sync):
from backend.app.config import get_config
db_sync.add(Artist(name="Bea", slug="bea"))
db_sync.commit()
with backup_service.dump_snapshot(get_config().database_url_sync) as snap:
before = snap.counts()
db_sync.add(Artist(name="Later", slug="later"))
db_sync.commit()
assert snap.counts() == before
assert before["tables"]["artist"] == 1
assert before["tables"]["credential"] == 0
assert before["alembic"]
@pytest.mark.integration
def test_a_dump_tested_from_the_bucket_comes_back_whole_and_leaves_no_trace(
db_sync, tmp_path, monkeypatch,
):
from backend.app.services import backup_destination
from backend.app.tasks import backup as tasks
db_sync.add(Artist(name="Bea", slug="bea"))
db_sync.commit()
monkeypatch.setattr(tasks, "IMAGES_ROOT", tmp_path)
with FakeS3() as s3:
monkeypatch.setattr(backup_destination, "saved_target", lambda settings: s3.target())
tasks.backup_db_task(triggered_by="manual")
dump_key = next(k for k in s3.objects if k.endswith(".dump"))
# The database moves on; the test compares with the dump's own counts.
db_sync.add(Artist(name="Later", slug="later"))
db_sync.commit()
out = tasks.restore_test_task(dump_key=dump_key)
with tasks._sync_session_factory()() as s:
row = s.get(BackupRun, out["backup_run_id"])
assert row.status == "ok", row.error
m = row.manifest
assert m["counted"] and m["mismatched"] == []
artist = next(t for t in m["tables"] if t["table"] == "artist")
assert (artist["dump"], artist["restored"], artist["live"]) == (1, 1, 2)
assert m["alembic_dump"] == m["alembic_now"]
assert m["offsite"]["key"] == dump_key
# This install is as it was, and the test left nothing behind.
assert s.execute(select(func.count()).select_from(Artist)).scalar() == 2
assert not _scratch_exists(s)
assert not list((tmp_path / "_backups").glob("*"))
@pytest.mark.integration
def test_a_dump_whose_counts_dont_match_fails_and_one_without_is_compared_with_live(
db_sync, tmp_path, monkeypatch,
):
from backend.app.services import backup_destination
from backend.app.tasks import backup as tasks
db_sync.add(Artist(name="Bea", slug="bea"))
db_sync.commit()
monkeypatch.setattr(tasks, "IMAGES_ROOT", tmp_path)
with FakeS3() as s3:
monkeypatch.setattr(backup_destination, "saved_target", lambda settings: s3.target())
tasks.backup_db_task(triggered_by="manual")
dump_key = next(k for k in s3.objects if k.endswith(".dump"))
counts = json.loads(s3.objects[backup_verify.counts_key(dump_key)])
counts["tables"]["artist"] = 5
s3.objects[backup_verify.counts_key(dump_key)] = json.dumps(counts).encode()
wrong = tasks.restore_test_task(dump_key=dump_key)
del s3.objects[backup_verify.counts_key(dump_key)]
uncounted = tasks.restore_test_task(dump_key=dump_key)
with tasks._sync_session_factory()() as s:
row = s.get(BackupRun, wrong["backup_run_id"])
assert row.status == "error"
assert row.error == "1 table(s) came back different: artist (5 in the dump, 1 restored)."
row = s.get(BackupRun, uncounted["backup_run_id"])
assert row.status == "ok" and row.manifest["counted"] is False
assert not _scratch_exists(s)
+2 -2
View File
@@ -414,7 +414,7 @@ def test_the_request_profile_is_gallery_dls():
# -- the adapter ---------------------------------------------------------------
def test_the_adapter_authenticates_with_the_token_and_keys_by_identity(tmp_path):
from backend.app.services.discord_ingester import DiscordIngester, _ledger_key
from backend.app.services.discord_ingester import DiscordIngester
ing = DiscordIngester(tmp_path, None, session_factory=None, auth_token="tok")
assert ing.client._session.headers["Authorization"] == "tok"
@@ -423,7 +423,7 @@ def test_the_adapter_authenticates_with_the_token_and_keys_by_identity(tmp_path)
[media] = DiscordClient.extract_media(
_msg(9, attachments=[{"id": "300", "url": "https://cdn/a/1.png"}])
)
assert _ledger_key(media) == "9:300"
assert ing._ledger_key(media) == "9:300"
# -- the poster picker (#4491) -------------------------------------------------
+1 -1
View File
@@ -13,7 +13,7 @@ from backend.app.services.download_backends import (
uses_native_ingester,
verify_source_credential,
)
from backend.app.services.gallery_dl import ErrorType
from backend.app.services.download_result import ErrorType
def test_native_platforms():
+23 -4
View File
@@ -1,7 +1,9 @@
"""#4433: only the process consuming `download` clears a restart's leftovers.
"""Each lane picks up only its own restart's leftovers: the process consuming
`download` clears orphaned walks and platform locks, and the one consuming
`maintenance_long` resumes off-site backups and restores.
The consolidated container boots four lanes side by side. If the scheduler or
ml lane ran this too, a lane restarting on its own (supervisord restarts a
ml lane ran these too, a lane restarting on its own (supervisord restarts a
crashed program) would close the events of walks that are still running.
"""
from __future__ import annotations
@@ -42,6 +44,10 @@ def calls(monkeypatch):
"backend.app.services.platform_lock.release_all_platform_locks",
lambda: seen.append("release") or 0,
)
monkeypatch.setattr(
"backend.app.tasks.backup.resume_offsite_runs",
lambda: seen.append("resume") or {},
)
return seen
@@ -50,12 +56,25 @@ def test_the_download_lane_clears_orphans_and_locks(calls):
assert calls == ["interrupt", "commit", "release"]
@pytest.mark.parametrize("queues", [("maintenance", "scan"), ("ml",), ("maintenance_long",)])
def test_other_lanes_leave_downloads_alone(calls, queues):
@pytest.mark.parametrize("queues", [("maintenance", "scan"), ("ml",)])
def test_other_lanes_leave_downloads_and_backups_alone(calls, queues):
celery_signals._on_worker_ready(sender=_consumer(*queues))
assert calls == []
def test_the_long_maintenance_lane_resumes_offsite_runs_and_nothing_else(calls):
celery_signals._on_worker_ready(sender=_consumer("maintenance_long"))
assert calls == ["resume"]
def test_a_failed_resume_does_not_stop_the_worker_starting(monkeypatch, calls):
def boom():
raise RuntimeError("db down")
monkeypatch.setattr("backend.app.tasks.backup.resume_offsite_runs", boom)
celery_signals._on_worker_ready(sender=_consumer("maintenance_long")) # must not raise
def test_unreadable_queues_do_nothing_rather_than_guess(calls):
celery_signals._on_worker_ready(sender=SimpleNamespace())
assert calls == []
+119 -6
View File
@@ -101,8 +101,6 @@ def _fake_gdl_with_result(result):
"skipped_count": 0, "per_item_failures": 0,
"warning_count": 0, "tier_gated_count": 0,
}
fake._extract_errors_warnings = lambda *a, **k: ""
fake._truncate_log = lambda x, **k: x
return fake
@@ -193,6 +191,69 @@ async def test_download_source_attaches_written_files(
assert imported_when_marked == [2]
@pytest.mark.asyncio
async def test_import_step_keeps_only_the_files_the_library_needs_on_disk(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""After the import step, a downloaded image the library took stays where it
is, while an archive whose images were extracted (the archive is in the
attachments store) and an image the import filters rejected are removed, so
nothing is left on disk without a record for a later walk to import again."""
import zipfile
from PIL import Image
from backend.app.models import PostAttachment
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
_artist, source = seed_artist_and_source
images_root = tmp_path / "images"
post_dir = images_root / "alice" / "patreon" / "post"
kept = post_dir / "a.jpg"
_make_jpg(kept, split="h")
member = tmp_path / "member.jpg"
_make_jpg(member, split="v")
archive = post_dir / "pack.zip"
with zipfile.ZipFile(archive, "w") as z:
z.writestr("art.jpg", member.read_bytes())
tiny = post_dir / "tiny.png"
Image.new("RGB", (16, 16), (200, 30, 30)).save(tiny) # below min_width
result = _make_fake_dl_result(
success=True, written_paths=[str(kept), str(archive), str(tiny)],
files_downloaded=3,
)
sync_settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
sync_settings.phash_threshold = 0
sync_settings.min_width = 32
importer = Importer(
session=db_sync, images_root=images_root, import_root=images_root,
thumbnailer=Thumbnailer(images_root=images_root), settings=sync_settings,
)
crypto = CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True)
svc = DownloadService(
async_session=db, sync_session=db_sync, gdl=_fake_gdl_with_result(result),
importer=importer, cred_service=CredentialService(db, crypto),
)
_stub_patreon_ingester(svc, result)
await svc.download_source(source.id)
assert kept.exists()
assert not archive.exists()
assert not tiny.exists()
# The archive and its image are both still in the library.
assert db_sync.execute(
select(func.count(PostAttachment.id)).where(
PostAttachment.original_filename == "pack.zip"
)
).scalar_one() == 1
assert db_sync.execute(select(func.count(ImageRecord.id))).scalar_one() == 2
@pytest.mark.asyncio
async def test_download_source_skipped_when_disabled(
db, db_sync, tmp_path, seed_artist_and_source,
@@ -353,7 +414,8 @@ async def test_run_download_native_unresolvable_fails_loud(
"""A campaign id we can't resolve is a loud NOT_FOUND failure, never a
silent empty success."""
from backend.app.services import download_backends as db_mod
from backend.app.services.gallery_dl import ErrorType, SourceConfig
from backend.app.services.download_result import ErrorType
from backend.app.services.gallery_dl import SourceConfig
_artist, source = seed_artist_and_source
@@ -670,7 +732,7 @@ async def test_backfill_completes_when_every_post_was_tier_gated(
it 'stalled', and the creator we can see least becomes the one we re-walk
most. `walk_completed` admits informational classes for exactly this.
"""
from backend.app.services.gallery_dl import ErrorType
from backend.app.services.download_result import ErrorType
_artist, source = seed_artist_and_source
source.config_overrides = {
@@ -892,7 +954,7 @@ async def test_backfill_timeout_chunk_reclassified_to_ok(
"""A backfill chunk that hit its time-box (TIMEOUT) but advanced is
reclassified to PARTIAL → event status 'ok' (progress, not error), and
consecutive_failures is not bumped."""
from backend.app.services.gallery_dl import ErrorType
from backend.app.services.download_result import ErrorType
_artist, source = seed_artist_and_source
source.config_overrides = {"_backfill_state": "running"}
@@ -995,8 +1057,8 @@ async def test_partial_error_type_maps_to_ok_status(
"""A non-zero gallery-dl exit accompanied by PARTIAL error_type
(real files were downloaded before the run was cut short) is treated
as status=ok by the orchestrator — next tick continues."""
from backend.app.services.download_result import ErrorType
from backend.app.services.download_service import DownloadService
from backend.app.services.gallery_dl import ErrorType
from backend.app.services.importer import Importer
_artist, source = seed_artist_and_source
@@ -1181,3 +1243,54 @@ async def test_async_task_engine_uses_nullpool():
assert isinstance(engine.sync_engine.pool, NullPool)
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_a_clean_walk_marks_the_credential_verified_and_a_tier_limited_one_does_not(
db, db_sync, tmp_path, seed_artist_and_source,
):
"""The credentials page shows when the account last worked. A tier-limited
walk is not evidence: an expired session hides paid posts the same way."""
from backend.app.models import Credential
from backend.app.services.download_result import ErrorType
from backend.app.services.download_service import DownloadService
from backend.app.services.importer import Importer
_artist, source = seed_artist_and_source
images_root = tmp_path / "images"
crypto = CredentialCrypto(tmp_path / "key.b64", bootstrap_ok=True)
cred_service = CredentialService(db, crypto, cookies_dir=tmp_path / "cookies")
await cred_service.upsert(
platform="patreon", credential_type="cookies",
data=".patreon.com\tTRUE\t/\tTRUE\t0\tsession_id\tabc",
)
settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
importer = Importer(
session=db_sync, images_root=images_root, import_root=images_root,
thumbnailer=Thumbnailer(images_root=images_root), settings=settings,
)
async def last_verified():
row = (await db.execute(
select(Credential).where(Credential.platform == "patreon")
)).scalar_one()
await db.refresh(row)
return row.last_verified
gated = _make_fake_dl_result(
success=True, error_type=ErrorType.TIER_LIMITED, error_message="gated",
)
svc = DownloadService(
async_session=db, sync_session=db_sync,
gdl=_fake_gdl_with_result(gated), importer=importer, cred_service=cred_service,
)
_stub_patreon_ingester(svc, gated)
await svc.download_source(source.id)
assert await last_verified() is None
clean = _make_fake_dl_result(success=True)
_stub_patreon_ingester(svc, clean)
await svc.download_source(source.id)
assert await last_verified() is not None
+21
View File
@@ -108,6 +108,27 @@ def test_mega_collects_new_files(tmp_path, monkeypatch):
assert res.files[0].name == "film.mp4"
def test_mega_folder_link_collects_files_in_subfolders(tmp_path, monkeypatch):
"""A folder link keeps the folder's tree; every file in it is collected,
files already there before the fetch and staging files are not."""
(tmp_path / "old.png").write_bytes(b"earlier")
def fake_mega(url, out_dir, *, timeout):
nested = Path(out_dir) / "images" / "2020"
nested.mkdir(parents=True)
(nested / "kara.png").write_bytes(b"png")
(Path(out_dir) / "videos").mkdir()
(Path(out_dir) / "videos" / "loop.webm").write_bytes(b"webm")
(nested / ".megatmp.AbCd").write_bytes(b"half")
monkeypatch.setattr(ef, "_run_mega_get", fake_mega)
res = fetch_external("mega", "https://mega.nz/folder/x#k", tmp_path)
assert res.ok
assert sorted(p.relative_to(tmp_path).as_posix() for p in res.files) == [
"images/2020/kara.png", "videos/loop.webm",
]
def test_unsupported_host(tmp_path):
res = fetch_external("nope", "https://x", tmp_path)
assert not res.ok
+290
View File
@@ -251,3 +251,293 @@ def test_downloaded_archive_gets_provenance_and_tagging(db_sync, tmp_path, monke
# Tagging + thumbnails queued for exactly those member images.
assert set(tagged) == member_ids
assert set(thumbed) == member_ids
def _link_dir(tmp_path, host, link_id):
return tmp_path / f"ext-{host}" / "patreon" / "external" / f"EXT-{host}" / str(link_id)
def test_extracted_archive_is_not_left_in_the_link_dir(db_sync, tmp_path, monkeypatch):
"""An archive whose images were extracted is in the attachments store, so
the downloaded copy is removed instead of sitting beside it."""
_, link = _seed(db_sync, host="mega")
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
written = []
def fake_fetch(host, url, dest_dir, **kwargs):
dest_dir.mkdir(parents=True, exist_ok=True)
zpath = dest_dir / "pack.zip"
with zipfile.ZipFile(zpath, "w") as z:
z.writestr("art.jpg", _structured_jpeg_bytes())
written.append(zpath)
return FetchResult(files=[zpath], bytes=zpath.stat().st_size)
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
import backend.app.tasks.ml as ml_mod
import backend.app.tasks.thumbnail as thumb_mod
monkeypatch.setattr(ml_mod.embed_image, "delay", lambda i: None)
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda i: None)
out = ext.fetch_external_link(link_id)
assert out["images"] >= 1
assert not written[0].exists()
db_sync.expire_all()
assert db_sync.execute(
select(func.count(PostAttachment.id))
.where(PostAttachment.original_filename == "pack.zip")
).scalar_one() == 1
def test_a_failed_fetch_leaves_no_partial_files(db_sync, tmp_path, monkeypatch):
"""A fetch that fails midway (megatools leaves `.megatmp.*` behind) clears
the link's staging dir, so a dead link leaves nothing on disk."""
_, link = _seed(db_sync, host="mega")
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
def fake_fetch(host, url, dest_dir, **kwargs):
dest_dir.mkdir(parents=True, exist_ok=True)
(dest_dir / ".megatmp.AbCd").write_bytes(b"half a game build")
return FetchResult(error="megatools dl exit 1: timed out")
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
ext.fetch_external_link(link_id)
link_dir = _link_dir(tmp_path, "mega", link_id)
assert not (link_dir / ".megatmp.AbCd").exists()
db_sync.expire_all()
assert db_sync.get(ExternalLink, link_id).status == "failed"
def test_a_retry_clears_what_a_killed_attempt_left(db_sync, tmp_path, monkeypatch):
"""Leftovers from an attempt whose worker was killed are removed before the
next fetch starts; an image imported in place by an earlier attempt stays."""
_, link = _seed(db_sync, host="pixeldrain")
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
link_dir = _link_dir(tmp_path, "pixeldrain", link_id)
link_dir.mkdir(parents=True)
leftover = link_dir / "build.zip.part"
leftover.write_bytes(b"partial")
imported = link_dir / "kept.jpg"
imported.write_bytes(_structured_jpeg_bytes())
db_sync.add(ImageRecord(
path=str(imported), sha256="ext-kept".ljust(64, "x"),
size_bytes=imported.stat().st_size, mime="image/jpeg",
width=256, height=256, origin="downloaded", integrity_status="ok",
))
db_sync.commit()
seen_at_fetch = []
def fake_fetch(host, url, dest_dir, **kwargs):
seen_at_fetch.extend(sorted(p.name for p in dest_dir.iterdir()))
return FetchResult(error="HTTP 503")
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
ext.fetch_external_link(link_id)
assert seen_at_fetch == ["kept.jpg"]
assert imported.exists()
assert not leftover.exists()
def test_a_rejected_download_is_removed(db_sync, tmp_path, monkeypatch):
"""A downloaded image the import filters reject has no record, so it is
removed rather than left in the link's dir."""
from backend.app.models import ImportSettings
_, link = _seed(db_sync, host="dropbox")
link_id = link.id
settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
settings.min_width = 512
db_sync.commit()
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
written = []
def fake_fetch(host, url, dest_dir, **kwargs):
dest_dir.mkdir(parents=True, exist_ok=True)
f = dest_dir / "small.jpg" # 256px wide, below min_width
f.write_bytes(_structured_jpeg_bytes())
written.append(f)
return FetchResult(files=[f], bytes=f.stat().st_size)
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
out = ext.fetch_external_link(link_id)
assert out["images"] == 0
assert not written[0].exists()
db_sync.expire_all()
assert db_sync.get(ExternalLink, link_id).status == "downloaded"
def _other_jpeg_bytes():
# A second structured image, unlike _structured_jpeg_bytes, so the two
# don't near-duplicate each other at import.
im = Image.new("RGB", (256, 256))
px = im.load()
for y in range(256):
for x in range(256):
px[x, y] = ((x * x) % 256, (y * 3) % 256, (x ^ y) % 256)
buf = io.BytesIO()
im.save(buf, "JPEG")
return buf.getvalue()
def test_a_failed_fetch_keeps_complete_files(db_sync, tmp_path, monkeypatch):
"""A failed fetch removes staging files only. A complete file may be the
only copy left once the host has dropped the upload."""
_, link = _seed(db_sync, host="mega")
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
def fake_fetch(host, url, dest_dir, **kwargs):
(dest_dir / "images").mkdir(parents=True, exist_ok=True)
(dest_dir / "images" / "done.jpg").write_bytes(_structured_jpeg_bytes())
(dest_dir / "images" / ".megatmp.AbCd").write_bytes(b"half")
return FetchResult(error="API call 'f' failed: ENOENT")
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
ext.fetch_external_link(link_id)
link_dir = _link_dir(tmp_path, "mega", link_id)
assert (link_dir / "images" / "done.jpg").exists()
assert not (link_dir / "images" / ".megatmp.AbCd").exists()
def test_a_successful_fetch_imports_what_an_earlier_attempt_finished(
db_sync, tmp_path, monkeypatch,
):
"""Complete files an earlier attempt wrote, in subfolders too, are imported
with the new fetch's files instead of being deleted or left behind."""
_, link = _seed(db_sync, host="mega")
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
link_dir = _link_dir(tmp_path, "mega", link_id)
(link_dir / "pack").mkdir(parents=True)
earlier = link_dir / "pack" / "earlier.jpg"
earlier.write_bytes(_structured_jpeg_bytes())
def fake_fetch(host, url, dest_dir, **kwargs):
f = dest_dir / "new.jpg"
f.write_bytes(_other_jpeg_bytes())
return FetchResult(files=[f], bytes=f.stat().st_size)
monkeypatch.setattr(ext, "fetch_external", fake_fetch)
import backend.app.tasks.ml as ml_mod
import backend.app.tasks.thumbnail as thumb_mod
monkeypatch.setattr(ml_mod.embed_image, "delay", lambda i: None)
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda i: None)
out = ext.fetch_external_link(link_id)
assert out["images"] == 2
db_sync.expire_all()
paths = set(db_sync.execute(select(ImageRecord.path)).scalars())
assert {str(earlier), str(link_dir / "new.jpg")} <= paths
def test_sweep_recovers_a_link_whose_worker_died(db_sync, monkeypatch):
"""A link 'downloading' past the task's hard limit (or with no claim time)
counts as a failed attempt and is retried; a fresh claim is left alone."""
from datetime import UTC, datetime, timedelta
_, stale = _seed(db_sync, host="mega", status="downloading")
_, unclaimed = _seed(db_sync, host="gdrive", status="downloading")
_, fresh = _seed(db_sync, host="dropbox", status="downloading")
now = datetime.now(UTC)
stale.started_at = now - timedelta(minutes=ext._STALL_MINUTES + 5)
fresh.started_at = now - timedelta(minutes=5)
db_sync.commit()
stale_id, unclaimed_id, fresh_id = stale.id, unclaimed.id, fresh.id
enqueued = []
monkeypatch.setattr(ext.fetch_external_link, "delay", lambda lid: enqueued.append(lid))
out = ext.sweep_external_links()
assert out["recovered"] == 2
assert set(enqueued) == {stale_id, unclaimed_id}
db_sync.expire_all()
for lid in (stale_id, unclaimed_id):
row = db_sync.get(ExternalLink, lid)
assert (row.status, row.attempts) == ("failed", 1)
assert db_sync.get(ExternalLink, fresh_id).status == "downloading"
def test_recovery_gives_a_dead_link_one_more_attempt_at_growing_intervals(db_sync, monkeypatch):
"""A dead link is retried once after 2**recoveries days, not given a fresh
three-attempt budget, so a link that always fails isn't downloaded daily."""
from datetime import UTC, datetime, timedelta
now = datetime.now(UTC)
_, due = _seed(db_sync, host="mega", status="dead")
_, waiting = _seed(db_sync, host="gdrive", status="dead")
_, spent = _seed(db_sync, host="dropbox", status="dead")
due.attempts = waiting.attempts = spent.attempts = ext.DEAD_LETTER_THRESHOLD
due.recoveries, due.completed_at = 2, now - timedelta(days=4, hours=1)
waiting.recoveries, waiting.completed_at = 2, now - timedelta(days=3)
spent.recoveries, spent.completed_at = ext._MAX_RECOVERIES, now - timedelta(days=90)
db_sync.commit()
due_id, waiting_id, spent_id = due.id, waiting.id, spent.id
enqueued = []
monkeypatch.setattr(ext.fetch_external_link, "delay", lambda lid: enqueued.append(lid))
out = ext.recover_external_links()
assert out["revived"] == 1
assert enqueued == [due_id]
db_sync.expire_all()
row = db_sync.get(ExternalLink, due_id)
assert (row.status, row.attempts, row.recoveries) == (
"failed", ext.DEAD_LETTER_THRESHOLD - 1, 3,
)
assert db_sync.get(ExternalLink, waiting_id).status == "dead"
assert db_sync.get(ExternalLink, spent_id).status == "dead"
def test_a_recovered_link_that_fails_again_goes_straight_back_to_dead(
db_sync, tmp_path, monkeypatch,
):
_, link = _seed(db_sync, host="mega", status="dead")
link.attempts = ext.DEAD_LETTER_THRESHOLD
db_sync.commit()
link_id = link.id
monkeypatch.setattr(ext, "IMAGES_ROOT", tmp_path)
monkeypatch.setattr(ext, "_redis", lambda: _FakeRedis())
monkeypatch.setattr(ext, "fetch_external", lambda *a, **k: FetchResult(error="too big"))
monkeypatch.setattr(ext.fetch_external_link, "delay", ext.fetch_external_link)
ext.recover_external_links()
db_sync.expire_all()
row = db_sync.get(ExternalLink, link_id)
assert (row.status, row.recoveries) == ("dead", 1)
def test_prune_removes_only_links_recovery_has_given_up_on(db_sync):
from datetime import UTC, datetime, timedelta
old = datetime.now(UTC) - timedelta(days=ext._RETENTION_DAYS + 1)
_, spent = _seed(db_sync, host="mega", status="dead")
_, recovering = _seed(db_sync, host="gdrive", status="dead")
_, recent = _seed(db_sync, host="dropbox", status="dead")
spent.recoveries, spent.completed_at = ext._MAX_RECOVERIES, old
recovering.recoveries, recovering.completed_at = 1, old
recent.recoveries = ext._MAX_RECOVERIES
recent.completed_at = datetime.now(UTC) - timedelta(days=1)
db_sync.commit()
spent_id, recovering_id, recent_id = spent.id, recovering.id, recent.id
assert ext.prune_external_links() == {"pruned": 1}
db_sync.expire_all()
assert db_sync.get(ExternalLink, spent_id) is None
assert db_sync.get(ExternalLink, recovering_id) is not None
assert db_sync.get(ExternalLink, recent_id) is not None
+10 -9
View File
@@ -4,11 +4,12 @@ from types import SimpleNamespace
import pytest
from backend.app.services.gallery_dl import (
from backend.app.services.download_result import (
ErrorType,
GalleryDLService,
SourceConfig,
extract_errors_warnings,
truncate_log,
)
from backend.app.services.gallery_dl import GalleryDLService, SourceConfig
@pytest.fixture
@@ -228,27 +229,27 @@ def test_compute_run_stats(gdl):
assert stats["per_item_failures"] == 1
def test_extract_errors_warnings(gdl):
def test_extract_errors_warnings():
stderr = (
"[patreon][debug] foo\n[patreon][error] bar\n"
"[urllib3][debug] baz\n[patreon][warning] qux\n"
)
result = gdl._extract_errors_warnings(stderr)
result = extract_errors_warnings(stderr)
assert "[error]" in result
assert "[warning]" in result
assert "[debug]" not in result
def test_truncate_log_caps_large_text(gdl):
def test_truncate_log_caps_large_text():
huge = "line\n" * 200_000
result = gdl._truncate_log(huge, max_bytes=10_000)
result = truncate_log(huge, max_bytes=10_000)
assert len(result.encode()) < 11_000
assert "lines elided" in result
def test_truncate_log_passes_short_text(gdl):
def test_truncate_log_passes_short_text():
text = "small\n"
assert gdl._truncate_log(text) == text
assert truncate_log(text) == text
# --- Plan #544: tick/backfill skip emission + PARTIAL classifier ------------
+1 -1
View File
@@ -24,7 +24,7 @@ from pathlib import Path
import pytest
from backend.app.models import Artist, Source
from backend.app.services.gallery_dl import ErrorType
from backend.app.services.download_result import ErrorType
from backend.app.services.membership_roster import (
GATED_FREE,
GATED_LAPSED,
+22
View File
@@ -216,6 +216,28 @@ def test_pil_load_oserror_in_transparency_check_skips_not_raises(
assert "transparency check" in (result.error or "")
def test_downloaded_file_with_broken_pixels_skips_not_raises(
importer, import_layout, monkeypatch,
):
"""A downloaded file goes through the same filters as an imported one, so
a decode error there is a skip; raised, it would end the download run
before the rest of its files were attached."""
_, images_root = import_layout
path = images_root / "Bob" / "corrupt.png"
_make_png_rgba(path, (100, 100), alpha=128)
importer.settings.skip_transparent = True
importer.settings.transparency_threshold = 0.5
def _boom(_self, _src):
raise OSError("broken data stream when reading image file")
monkeypatch.setattr(type(importer), "_transparency_pct", _boom)
result = importer.attach_in_place(path)
assert result.status == "skipped"
assert result.skip_reason == SkipReason.invalid_image
assert "transparency check" in (result.error or "")
def test_pil_load_oserror_in_phash_compute_skips_not_raises(
importer, import_layout, monkeypatch,
):
+57
View File
@@ -269,3 +269,60 @@ def test_reimport_archive_is_idempotent(importer, import_layout):
assert importer.session.execute(
select(func.count()).select_from(PostAttachment)
).scalar_one() == 1
def test_a_game_build_is_kept_whole_and_its_images_left_alone(importer, import_layout):
import_root, _ = import_layout
importer.settings.phash_threshold = 0
arc = import_root / "Bob" / "MyGame-1.0-pc.zip"
arc.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(arc, "w") as zf:
zf.writestr("MyGame/lib/py3-windows-x86_64/python.exe", b"MZ")
zf.writestr("MyGame/game/images/cg01.jpg", _split_bytes("v"))
zf.writestr("MyGame/game/script.rpyc", b"\x00")
r = importer.import_one(arc)
assert r.status == "attached"
assert importer.session.execute(select(func.count()).select_from(ImageRecord)).scalar_one() == 0
att = importer.session.execute(select(PostAttachment)).scalar_one()
assert att.original_filename == "MyGame-1.0-pc.zip"
def test_a_game_build_inside_an_art_pack_is_skipped_and_the_art_imported(importer, import_layout):
import_root, _ = import_layout
importer.settings.phash_threshold = 0
game = io.BytesIO()
with zipfile.ZipFile(game, "w") as zf:
zf.writestr("Game_Data/sharedassets0.assets", b"\x00")
zf.writestr("Game_Data/sprite.jpg", _split_bytes("h"))
arc = import_root / "Bob" / "pack.zip"
arc.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(arc, "w") as zf:
zf.writestr("art.jpg", _split_bytes("v"))
zf.writestr("bonus/game.zip", game.getvalue())
r = importer.import_one(arc)
assert r.status == "imported"
assert len(r.member_image_ids) == 1
assert importer.session.execute(select(func.count()).select_from(ImageRecord)).scalar_one() == 1
def test_a_game_build_is_recognised_before_the_integrity_test(
importer, import_layout, monkeypatch,
):
"""A multi-gigabyte build would spend minutes in the probe's read-everything
test, only to be kept whole anyway, so its directory decides first."""
from backend.app.utils import safe_probe
def _no_probe(_path, **_kw):
raise AssertionError("a software build went through the archive probe")
monkeypatch.setattr(safe_probe, "probe_archive", _no_probe)
import_root, _ = import_layout
arc = import_root / "Bob" / "MyGame-2.0-pc.zip"
arc.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(arc, "w") as zf:
zf.writestr("MyGame/MyGame.exe", b"MZ")
zf.writestr("MyGame/game/images/cg01.jpg", _split_bytes("v"))
assert importer.import_one(arc).status == "attached"
assert importer.session.execute(select(func.count()).select_from(ImageRecord)).scalar_one() == 0
+25
View File
@@ -410,6 +410,31 @@ def test_attach_in_place_invalid_image_returns_skipped(importer):
assert result.skip_reason.value == "invalid_image"
@pytest.mark.parametrize("name", ["film.mp4abc123_.part", "img.jpg.partial", ".megatmp.Xy12"])
def test_an_unfinished_download_is_refused_and_left_on_disk(importer, db_sync, name):
"""A staging file is a download still being written (or abandoned): it is
never stored as an attachment, and the importer never deletes it, so a
download in progress is not pulled out from under its writer."""
from backend.app.models import PostAttachment
artist = Artist(name="Hal", slug="hal")
db_sync.add(artist)
db_sync.flush()
staging = importer.images_root / "hal" / "patreon" / "post" / name
staging.parent.mkdir(parents=True, exist_ok=True)
staging.write_bytes(b"half of a video")
for result in (importer.attach_in_place(staging, artist=artist),
importer.import_one(staging)):
assert result.status == "skipped"
assert "unfinished download" in result.error
assert importer.discard_after_attach(staging, result) is False
assert staging.exists()
assert db_sync.execute(
select(func.count()).where(PostAttachment.original_filename == name)
).scalar() == 0
def test_attach_in_place_non_media_routes_to_attachment(importer, db_sync):
"""FC-2d-iii dispatch parity for the download path. A non-media file
(.pdf, .txt etc.) downloaded by gallery-dl must become a PostAttachment,
+202
View File
@@ -0,0 +1,202 @@
"""Two workers importing the same picture at once (services/importer.py).
The importer looks a file's sha256 up before inserting its row. When two copies
of one picture are imported side by side, both lookups can find nothing; the
unique index on sha256 then refuses the second insert. The loser files its copy
as a duplicate of the winner, links its post, and leaves the session usable,
instead of failing the import with a dead transaction.
The race is staged the way tests/test_importer_provenance_race.py stages its
own: the loser's sha256 lookup is forced to miss once, as it would if it ran
just before the winner's commit. The loser's near-duplicate cache is empty for
the same reason (it was loaded before the winner's row existed).
"""
import io
import zipfile
from pathlib import Path
import pytest
from PIL import Image
from sqlalchemy import func, select
from backend.app.models import Artist, ImageRecord, ImportSettings, PostAttachment
from backend.app.services.importer import Importer, SkipReason
from backend.app.services.thumbnailer import Thumbnailer
pytestmark = pytest.mark.integration
@pytest.fixture
def layout(tmp_path):
import_root = tmp_path / "import"
images_root = tmp_path / "images"
import_root.mkdir()
images_root.mkdir()
return import_root, images_root
def _worker(db_sync, layout) -> Importer:
import_root, images_root = layout
settings = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
return Importer(
session=db_sync, images_root=images_root, import_root=import_root,
thumbnailer=Thumbnailer(images_root=images_root), settings=settings,
)
def _jpeg(path: Path) -> bytes:
path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (320, 240), (40, 90, 160)).save(path, "JPEG")
return path.read_bytes()
def _copy(src: Path, dst: Path) -> Path:
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(src.read_bytes())
return dst
def _lose_the_race(importer: Importer, monkeypatch) -> None:
"""Make `importer` miss its first sha256 lookup and see no near-duplicates."""
real_execute = importer.session.execute
missed = [False]
class _Miss:
def scalar_one_or_none(self):
return None
def execute(stmt, *args, **kwargs):
sql = str(stmt)
if not missed[0] and "FROM image_record" in sql and "image_record.sha256 =" in sql:
missed[0] = True
return _Miss()
return real_execute(stmt, *args, **kwargs)
monkeypatch.setattr(importer.session, "execute", execute)
monkeypatch.setattr(importer, "_phash_candidates_cache", lambda: [])
def _records(db_sync) -> int:
return db_sync.execute(select(func.count()).select_from(ImageRecord)).scalar_one()
def test_a_copy_imported_alongside_the_original_is_filed_as_its_duplicate(
db_sync, layout, monkeypatch,
):
import_root, images_root = layout
first = import_root / "Alice" / "set1" / "pic.jpg"
_jpeg(first)
second = _copy(first, import_root / "Alice" / "set2" / "pic.jpg")
winner = _worker(db_sync, layout).import_one(first)
assert winner.status == "imported"
kept = Path(db_sync.get(ImageRecord, winner.image_id).path)
loser = _worker(db_sync, layout)
_lose_the_race(loser, monkeypatch)
result = loser.import_one(second)
assert result.status == "skipped"
assert result.skip_reason == SkipReason.duplicate_hash
assert result.image_id == winner.image_id
monkeypatch.undo()
assert _records(db_sync) == 1
assert kept.is_file()
# The loser's copy in the library was a stray and is gone; only the
# winner's file remains.
library_files = [p for p in images_root.rglob("*.jpg") if p.is_file()]
assert library_files == [kept]
def test_the_same_file_imported_twice_at_once_keeps_its_library_copy(
db_sync, layout, monkeypatch,
):
"""Both runs write the same library path, so the loser's copy IS the
winner's file and must not be deleted."""
import_root, _ = layout
src = import_root / "Alice" / "pic.jpg"
_jpeg(src)
winner = _worker(db_sync, layout).import_one(src)
kept = Path(db_sync.get(ImageRecord, winner.image_id).path)
loser = _worker(db_sync, layout)
_lose_the_race(loser, monkeypatch)
result = loser.import_one(src)
assert result.skip_reason == SkipReason.duplicate_hash
assert kept.is_file()
def test_a_copy_attached_in_place_alongside_the_original_is_its_duplicate(
db_sync, layout, monkeypatch,
):
_, images_root = layout
artist = Artist(name="Bea", slug="bea")
db_sync.add(artist)
db_sync.commit()
first = images_root / "bea" / "patreon" / "post1" / "pic.jpg"
_jpeg(first)
second = _copy(first, images_root / "bea" / "patreon" / "post2" / "pic.jpg")
winner = _worker(db_sync, layout).attach_in_place(first, artist=artist)
assert winner.status == "imported"
loser = _worker(db_sync, layout)
_lose_the_race(loser, monkeypatch)
result = loser.attach_in_place(second, artist=artist)
assert result.status == "skipped"
assert result.skip_reason == SkipReason.duplicate_hash
assert result.image_id == winner.image_id
monkeypatch.undo()
assert _records(db_sync) == 1
# Removing the loser's file is the caller's step (discard_after_attach).
assert first.is_file() and second.is_file()
def test_an_archive_whose_member_breaks_the_transaction_is_still_stored(
db_sync, layout, monkeypatch,
):
"""A member whose import fails mid-flush leaves the transaction dead. The
member loop rolls it back, so the archive itself is still stored and the
import ends with a result instead of an exception."""
import_root, _ = layout
seed = _worker(db_sync, layout).import_one(_jpeg_at(import_root / "Cy" / "seed.jpg"))
seed_path = db_sync.get(ImageRecord, seed.image_id).path
arc = import_root / "Cy" / "set.zip"
with zipfile.ZipFile(arc, "w") as zf:
zf.writestr("a.jpg", _jpeg_bytes())
importer = _worker(db_sync, layout)
def breaks_the_transaction(*args, **kwargs):
importer.session.add(ImageRecord(
path=seed_path, sha256="f" * 64, size_bytes=1, mime="image/jpeg",
origin="imported_filesystem",
))
importer.session.flush() # refused: the path is taken
monkeypatch.setattr(importer, "_import_media", breaks_the_transaction)
result = importer.import_one(arc)
assert result.status == "attached"
stored = db_sync.execute(
select(func.count()).select_from(PostAttachment)
).scalar_one()
assert stored == 1
def _jpeg_at(path: Path) -> Path:
_jpeg(path)
return path
def _jpeg_bytes() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (64, 48), (200, 30, 30)).save(buf, "JPEG")
return buf.getvalue()
+123
View File
@@ -0,0 +1,123 @@
"""Finding files in the artists' folders that the library has no record of, and
importing them where they lie (services.library_scan + the `library` batch)."""
import io
import os
import time
import pytest
from PIL import Image
from sqlalchemy import select
import backend.app.tasks.import_file as import_file
from backend.app.models import Artist, ImageRecord, ImportSettings, ImportTask
from backend.app.services import library_scan
from backend.app.services.importer import Importer
from backend.app.services.thumbnailer import Thumbnailer
pytestmark = pytest.mark.integration
def _jpeg(seed: int) -> bytes:
im = Image.new("RGB", (256, 256))
px = im.load()
for y in range(256):
for x in range(256):
px[x, y] = ((x * seed) % 256, (y * (seed + 1)) % 256, (x ^ y) % 256)
buf = io.BytesIO()
im.save(buf, "JPEG")
return buf.getvalue()
def _write(path, data: bytes, *, age_days: float = 2):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
t = time.time() - age_days * 86400
os.utime(path, (t, t))
return path
@pytest.fixture
def layout(db_sync, tmp_path):
db_sync.add(Artist(name="Libscan Maewix", slug="libscan-maewix"))
db_sync.commit()
post = tmp_path / "Libscan-Maewix" / "patreon" / "2026-05-01_123_Pack"
files = {
"missing": _write(post / "01_art.jpg", _jpeg(1)),
"owned": _write(post / "02_owned.jpg", _jpeg(2)),
"sidecar": _write(post / "01_art.json", b'{"category": "patreon", "id": "123"}'),
"staging": _write(post / "03_big.zip.part", b"partial"),
"recent": _write(post / "04_new.jpg", _jpeg(4), age_days=0),
"not_media": _write(post / "05_game.exe", b"MZ"),
"no_artist": _write(tmp_path / "libscan-nobody" / "x" / "a.jpg", _jpeg(5)),
"store": _write(tmp_path / "thumbs" / "abc" / "f.jpg", _jpeg(6)),
}
db_sync.add(ImageRecord(
path=str(files["owned"]), sha256="libscan-owned".ljust(64, "x"),
size_bytes=files["owned"].stat().st_size, mime="image/jpeg",
width=256, height=256, origin="downloaded", integrity_status="ok",
))
db_sync.commit()
return tmp_path, files
def test_finds_only_unrecorded_media_in_an_artists_folder(db_sync, layout):
"""A differently-cased folder still matches its artist. Recorded files,
sidecars, staging files, the library's own stores, folders that match no
artist, files changed in the last day and non-media are not listed."""
root, files = layout
found, summary = library_scan.find_unimported(db_sync, root)
assert [u.path for u in found] == [files["missing"]]
assert found[0].artist_slug == "libscan-maewix"
assert found[0].task_type == "media"
assert summary["files"] == 1
assert summary["recent"] == 1
assert summary["not_media"] == 1
assert summary["no_artist"] == 1
assert summary["artists"][0]["slug"] == "libscan-maewix"
assert summary["artists"][0]["samples"] == [
"Libscan-Maewix/patreon/2026-05-01_123_Pack/01_art.jpg",
]
def test_queue_makes_a_library_batch_of_what_find_lists(db_sync, layout, monkeypatch):
"""The import queued is exactly the previewed list."""
root, files = layout
queued = []
monkeypatch.setattr(import_file, "enqueue_import", lambda tid, tt: queued.append((tid, tt)))
summary = library_scan.queue_unimported(db_sync, root)
tasks = db_sync.execute(
select(ImportTask).where(ImportTask.batch_id == summary["batch_id"])
).scalars().all()
assert [t.source_path for t in tasks] == [str(files["missing"])]
assert tasks[0].batch.scan_mode == "library"
assert queued == [(tasks[0].id, "media")]
def test_a_library_import_attaches_in_place_and_removes_a_copy(db_sync, layout, monkeypatch):
"""The file stays where it is and becomes the record's file. A byte-identical
copy found later is removed from disk, since the library already holds it."""
root, files = layout
monkeypatch.setattr(import_file, "IMAGES_ROOT", root)
importer = Importer(
session=db_sync, images_root=root, import_root=root,
thumbnailer=Thumbnailer(images_root=root),
settings=ImportSettings.load_sync(db_sync),
)
result = import_file._attach_where_it_lies(db_sync, importer, files["missing"])
assert result.status == "imported"
record = db_sync.get(ImageRecord, result.image_id)
assert record.path == str(files["missing"])
assert files["missing"].exists()
assert db_sync.get(Artist, record.artist_id).slug == "libscan-maewix"
copy = _write(files["missing"].parent / "09_copy.jpg", files["missing"].read_bytes())
again = import_file._attach_where_it_lies(db_sync, importer, copy)
assert again.status == "skipped"
assert not copy.exists()
assert files["missing"].exists()

Some files were not shown because too many files have changed in this diff Show More