fix(downloads): second-round review fixes (fork on re-download, title fallbacks)

- redownload now ALWAYS rebuilds into a fresh private asset instead of ever reusing a shared
  cached file: the cache sig is salted with job_id + current asset_id, so the fork is distinct
  from both the dedup pool and the job's own current asset. This resolves the co-held silent
  no-op (finding #1) — a shared asset is left intact for its other holders while THIS job
  re-fetches its own copy. Resolving the fork BEFORE releasing the old hold (and the unique
  salted key) also removes the IntegrityError-rollback-after-file-delete window (finding #2).
- format_sig gains an optional `salt` for that fork identity.
- titles: don't consume the space after `|`, so a "stats | #hashtags" remainder keeps the
  leading whitespace _TRAILING_HASHTAGS needs — and fall back to the prefix+hashtag-stripped
  text, so neither the stats nor the SEO hashtags are resurrected when a title empties (#3).
- worker: early-fill asset.title falls back to the video id so a title that cleaned to empty
  doesn't show a blank card while downloading (#5).
- Tests for the salt fork and the hashtag-remainder case.

(Finding #4 — quota on re-download — intentionally not changed: a re-download reuses the
existing job and re-fetches ~the same bytes, so calling check_enqueue would wrongly reject on
the job-count cap while footprint is unchanged.)
This commit is contained in:
2026-07-22 02:48:16 +02:00
parent cae5702fbb
commit ebfddf330a
6 changed files with 53 additions and 17 deletions
+14 -9
View File
@@ -457,25 +457,30 @@ def redownload_download(
The SAME job row is kept, so any public share link (DownloadLink → job_id) keeps working and
resolves to the newly-downloaded file — a re-download never breaks a link you already sent out
(only a *failed* re-download leaves the job in `error`, which is the intended broken-link case).
Useful after a format/naming fix: the old file is deleted and re-fetched under the current rules
(the format-sig bump alone re-points this to a fresh asset)."""
The rebuild always re-fetches into a FRESH, private asset (the cache sig is salted with the job
id + current asset id) rather than the dedup pool. That means it (a) always genuinely rebuilds
under the current format/naming rules — never silently reuses a shared cached file — and (b)
never deletes a file another job/user still shares: the old asset is only removed if THIS job
was its last holder."""
job = _own_job(db, user, job_id)
if job.job_kind == "edit":
raise HTTPException(status_code=409, detail="Editor clips can't be re-downloaded.")
if job.status in ("queued", "running", "paused"):
raise HTTPException(status_code=409, detail="This download is still in progress.")
# Drop this job's hold on its current asset. `_release_asset` deletes the file + row when THIS
# job was the last holder (ref→0, ready) — so a solo re-download genuinely re-fetches. When the
# asset is still shared by another job/user we must NOT delete it out from under them: leaving it
# `ready` means the re-queued job just re-points to the shared file (the worker short-circuits a
# ready asset via `_finish_from_cache`), which is why there's no force-reset-to-pending here.
_release_asset(db, job)
# Resolve the fresh private asset FIRST (before touching any file). Salting with the current
# asset id makes it distinct from both the dedup pool and this job's current asset, so the
# insert never collides — no IntegrityError/rollback can strand us after a file was deleted.
spec = formats.normalize(job.profile_snapshot)
sig = formats.format_sig(spec)
sig = formats.format_sig(spec, salt=f"{job.id}:{job.asset_id}")
asset = service.get_or_create_asset(db, job.source_kind, job.source_ref, sig)
service.populate_from_catalog(db, asset)
# Now drop the old hold — `_release_asset` deletes the old file + row only if this job was its
# last holder (a co-held asset is left intact for its other holders).
_release_asset(db, job)
asset.ref_count = (asset.ref_count or 0) + 1
job.asset_id = asset.id
job.status = "queued"