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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+21
-2
@@ -1,4 +1,4 @@
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
@@ -949,6 +949,13 @@ class FileFolder(Base, TimestampMixin):
|
||||
retention_days: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
|
||||
def _ready_on_insert(context) -> datetime | None:
|
||||
"""A job inserted already done (an upload kept as it came) was ready the moment it was made; one
|
||||
that becomes done later is stamped then (`folders.stamp_done`, `service.finalize_if_running`)."""
|
||||
done = context.get_current_parameters().get("status") == "done"
|
||||
return datetime.now(timezone.utc) if done else None
|
||||
|
||||
|
||||
class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A user's request to download one source — the per-user layer over `MediaAsset`.
|
||||
|
||||
@@ -967,7 +974,13 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
||||
point at the parent download the clip was cut from; `edit_spec` is the (trim/crop) recipe."""
|
||||
|
||||
__tablename__ = "download_jobs"
|
||||
__table_args__ = (Index("ix_download_jobs_claim", "status", "queue_pos"),)
|
||||
__table_args__ = (
|
||||
Index("ix_download_jobs_claim", "status", "queue_pos"),
|
||||
# Every way a job becomes done stamps when (`folders.stamp_done`, `finalize_if_running`).
|
||||
CheckConstraint(
|
||||
"status <> 'done' OR ready_at IS NOT NULL", name="ck_download_jobs_ready_at"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
@@ -1026,6 +1039,12 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
||||
kept: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
expiry_warned: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
# When the file became done — what the library's "Added" means: a download that failed for days
|
||||
# and then went through is new to its user, so it sorts as new. `created_at` stays when it was
|
||||
# asked for (the queue's order). Set again when a file is made anew (a retry, a re-download).
|
||||
ready_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), default=_ready_on_insert
|
||||
)
|
||||
|
||||
|
||||
class DownloadShare(Base, TimestampMixin):
|
||||
|
||||
@@ -78,6 +78,7 @@ def serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
|
||||
"error_detail": job.error_detail,
|
||||
"queue_pos": job.queue_pos,
|
||||
"created_at": iso(job.created_at),
|
||||
"ready_at": iso(job.ready_at),
|
||||
"source_kind": job.source_kind,
|
||||
"source_ref": job.source_ref,
|
||||
"source_url": service.reference_url(job, asset),
|
||||
@@ -545,10 +546,10 @@ def list_files(
|
||||
func.coalesce(DownloadJob.display_uploader, MediaAsset.uploader, "").ilike(like),
|
||||
)
|
||||
)
|
||||
# A file shared with the caller was added to THEIR list when it was shared, not when its owner
|
||||
# downloaded it.
|
||||
# A file is added to its owner's list when it is done, not when it was asked for (a download that
|
||||
# failed for days and then went through is new); one shared with the caller, when it was shared.
|
||||
key = {
|
||||
"added": DownloadShare.created_at if shared else DownloadJob.created_at,
|
||||
"added": DownloadShare.created_at if shared else DownloadJob.ready_at,
|
||||
"name": collated(name, lang_for_user(user)),
|
||||
"size": MediaAsset.size_bytes,
|
||||
}[sort]
|
||||
|
||||
Reference in New Issue
Block a user