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
+1 -1
View File
@@ -374,7 +374,7 @@ def test_a_clip_is_not_redownloadable(db, make_user, as_user, yt_video):
c = as_user(make_user())
job = _clip(c, source="youtube", ref=yt_video).json()
row = db.get(DownloadJob, job["id"])
row.status = "done"
row.status, row.ready_at = "done", row.created_at
db.commit()
r = c.post(f"/api/downloads/{job['id']}/redownload")
assert (r.status_code, r.json()["detail"]["reason"]) == (409, "not_redownloadable")
+33 -1
View File
@@ -59,7 +59,7 @@ def _file(db, user_id, *, kind="youtube", ref=None, title="A title", size=10, re
db.add(job)
db.flush()
folders.place(db, job)
folders.stamp_expiry(db, job)
folders.stamp_done(db, job)
service.incr_ref_count(db, asset.id)
db.commit()
return job
@@ -245,6 +245,38 @@ def test_the_list_sorts_by_name_size_or_when_added(db, make_user, as_user):
assert ids(sort="size", order="desc") == [b.id, z.id, a.id]
def test_a_file_is_added_when_it_is_done_not_when_it_was_asked_for(db, make_user, as_user):
"""A download that failed for days and then went through is new: it lists first, dated today."""
alice = make_user()
early = _file(db, alice.id, created_at=NOW - timedelta(days=1))
early.ready_at = NOW - timedelta(days=1)
late = _file(db, alice.id, status=JobStatus.RUNNING, created_at=NOW - timedelta(days=5))
late.ready_at = None
db.commit()
assert service.finalize_if_running(db, late.id, status=JobStatus.DONE)
db.commit()
db.expire_all() # the finalize is a Core UPDATE; the route reads this same session
got = as_user(alice).get("/api/files").json()
assert [f["id"] for f in got] == [late.id, early.id]
ready = datetime.fromisoformat(got[0]["ready_at"])
assert ready > NOW - timedelta(minutes=5)
assert datetime.fromisoformat(got[0]["created_at"]) < NOW - timedelta(days=4)
def test_a_job_made_done_is_ready_as_it_is_made(db, make_user):
# An upload kept as it came is inserted done; the database refuses a done job without the moment.
job = DownloadJob(
user_id=make_user().id,
source_kind="upload",
source_ref="r",
profile_snapshot={},
status=JobStatus.DONE,
)
db.add(job)
db.commit()
assert job.ready_at is not None
def test_shared_with_me_lists_what_others_shared_and_nothing_else(db, make_user, as_user):
alice, bob, carol = make_user(), make_user(), make_user()
theirs = _file(db, bob.id)
+52
View File
@@ -0,0 +1,52 @@
"""Migration 0104's data step, DB-lane: a done file from before is dated by when it finished where
its "ready to watch" notice says so, else by when it was asked for; a job not done has no date."""
from alembic import command
from alembic.config import Config
from sqlalchemy import text
from app.db import engine
def test_a_done_file_takes_its_notice_time_else_its_request_time(db, make_user):
uid = make_user().id
cfg = Config("alembic.ini")
command.downgrade(cfg, "0103_files_sha256")
try:
with engine.begin() as conn:
def job(status, created):
return conn.execute(
text(
"INSERT INTO download_jobs (user_id, source_kind, source_ref,"
" profile_snapshot, status, created_at) VALUES (:u, 'url', 'x', '{}', :s,"
" CAST(:c AS timestamptz)) RETURNING id"
),
{"u": uid, "s": status, "c": created},
).scalar_one()
retried = job("done", "2026-10-01T10:00:00+00:00")
quiet = job("done", "2026-10-02T10:00:00+00:00")
failed = job("error", "2026-10-03T10:00:00+00:00")
for at in ("2026-10-05T09:00:00+00:00", "2026-10-06T09:00:00+00:00"):
conn.execute(
text(
"INSERT INTO notifications (user_id, type, title, data, created_at) VALUES"
" (:u, 'download_done', 't', CAST(:d AS json), CAST(:c AS timestamptz))"
),
{"u": uid, "d": f'{{"job_id": {retried}}}', "c": at},
)
finally:
command.upgrade(cfg, "head")
with engine.connect() as conn:
rows = dict(
conn.execute(
text(
"SELECT id, to_char(ready_at AT TIME ZONE 'UTC', 'MM-DD HH24') FROM download_jobs"
)
).all()
)
assert rows[retried] == "10-06 09" # its last finish
assert rows[quiet] == "10-02 10" # no notice left: where it already sat
assert rows[failed] is None
+1 -1
View File
@@ -320,7 +320,7 @@ def test_upload_route_media_html_extension_not_served_as_html(
assert job["status"] == "queued"
row = db.get(DownloadJob, job["id"])
db.get(MediaAsset, row.asset_id).status = "ready"
row.status = "done"
row.status, row.ready_at = "done", row.created_at
db.commit()
# Authenticated endpoint: never text/html.
+7 -2
View File
@@ -407,7 +407,7 @@ def test_the_sweep_keeps_an_aged_upload_file_its_row_names_in_any_state(
_probe(monkeypatch)
job, asset = _ingest(db, _root, make_user().id)
path = storage.abs_path(str(_root), asset.rel_path)
job.status, asset.status = job_status, asset_status
job.status, asset.status, job.ready_at = job_status, asset_status, job.created_at
db.commit()
_aged(path)
@@ -427,7 +427,11 @@ def _route_moved(db, job, asset, job_status: str, ref_count: int) -> None:
"""What the cancel/pause/delete route leaves behind, written as SQL: the session's copy of
`ref_count` is stale (the routes count it in SQL), so an ORM assignment of the same value would
write nothing."""
db.execute(update(DownloadJob).where(DownloadJob.id == job.id).values(status=job_status))
db.execute(
update(DownloadJob)
.where(DownloadJob.id == job.id)
.values(status=job_status, ready_at=DownloadJob.created_at)
)
db.execute(update(MediaAsset).where(MediaAsset.id == asset.id).values(ref_count=ref_count))
db.commit()
@@ -609,6 +613,7 @@ def test_the_admin_storage_view_names_each_users_quota_and_files(
done, _ = _ingest(db, _root, u.id, size=10)
waiting, _ = _ingest(db, _root, u.id, name="b.mov", size=5)
done.status, waiting.status = "done", "queued"
done.ready_at = done.created_at
db.commit()
admin = as_user(make_user(role="admin"))
put = admin.put(f"/api/admin/downloads/quota/{u.id}", json={"max_bytes": 1000})