fix(files): a file is added when it is done, not when it was asked for

A download that failed for days and then went through sat days down the library, under its
request date, and read as missing. A job now records when it became done (`ready_at`), stamped
where the expiry already is (`folders.stamp_done`, `finalize_if_running`) and on an insert that is
already done; a check constraint holds every path to done to it. The library sorts and dates its
"Added" by it; the queue keeps the request order.

The migration dates a file from before by its "ready to watch" notice where the inbox still holds
one, else by its request date, where it already sat.
This commit is contained in:
2026-10-12 22:19:37 +02:00
parent 132fb56428
commit 96509fe356
14 changed files with 204 additions and 21 deletions
+5 -4
View File
@@ -16,7 +16,7 @@ file in the TikTok folder would be a lie the folder's name tells.
Retention is the folder's: a source folder keeps a file `download_retention_days` (the value when the
folder was made; the user can change it), Uploads and own folders keep it until it is deleted. A
done file carries its own `expires_at`, stamped by `done_expiry` when it finishes — unless the user
kept it.
kept it — and its `ready_at`, the moment it finished (`stamp_done`).
"""
from datetime import datetime, timedelta, timezone
@@ -136,8 +136,9 @@ def done_expiry(db: Session, folder_id: int | None, kept: bool) -> datetime | No
return None if days is None else datetime.now(timezone.utc) + timedelta(days=days)
def stamp_expiry(db: Session, job: DownloadJob) -> None:
"""Stamp a job that just became done (in this session) with its expiry."""
def stamp_done(db: Session, job: DownloadJob) -> None:
"""Stamp a job that just became done (in this session): when it did, and its expiry."""
job.ready_at = datetime.now(timezone.utc)
job.expires_at = done_expiry(db, job.folder_id, job.kept)
job.expiry_warned = False
@@ -163,7 +164,7 @@ def upload_target(db: Session, user_id: int, folder_id: int) -> FileFolder:
def restamp(db: Session, job: DownloadJob) -> None:
"""Start a file's clock again from now: it moved to another folder, or its user stopped keeping
it. A file not done yet gets its expiry when it finishes (`stamp_expiry`), so it has none now."""
it. A file not done yet gets its expiry when it finishes (`stamp_done`), so it has none now."""
done = job.status == JobStatus.DONE
job.expires_at = done_expiry(db, job.folder_id, job.kept) if done else None
job.expiry_warned = False
+4 -3
View File
@@ -205,7 +205,7 @@ def enqueue(
if asset.status == "ready":
job.status = JobStatus.DONE
job.progress = 100
folders.stamp_expiry(db, job)
folders.stamp_done(db, job)
asset.last_access_at = datetime.now(timezone.utc)
db.commit()
@@ -285,7 +285,7 @@ def enqueue_edit(
if asset.status == "ready": # identical clip already produced for this user → instant
job.status = JobStatus.DONE
job.progress = 100
folders.stamp_expiry(db, job)
folders.stamp_done(db, job)
asset.last_access_at = datetime.now(timezone.utc)
db.commit()
@@ -374,7 +374,7 @@ def enqueue_clip(
if asset.status == "ready": # the same range was already cut for this user → instant
job.status = JobStatus.DONE
job.progress = 100
folders.stamp_expiry(db, job)
folders.stamp_done(db, job)
asset.last_access_at = datetime.now(timezone.utc)
db.commit()
@@ -399,6 +399,7 @@ def finalize_if_running(db: Session, job_id: int, **fields) -> bool:
return False
db.flush() # asset-then-job lock order: push pending asset writes before we lock the job row
if fields.get("status") == JobStatus.DONE:
fields = {**fields, "ready_at": datetime.now(timezone.utc)}
# Done is when a file's retention starts: its folder's, unless the user kept it.
held = db.execute(
select(DownloadJob.folder_id, DownloadJob.kept).where(DownloadJob.id == job_id)
+1 -1
View File
@@ -140,7 +140,7 @@ def ingest_upload(
if folder_id is not None:
job.folder_id = folder_id
if not convert:
folders.stamp_expiry(db, job)
folders.stamp_done(db, job)
service.incr_ref_count(db, asset.id)
db.flush()
audit.record(db, user_id, AuditAction.FILE_UPLOAD, target_type="file", target_id=job.id)