fix(downloads): a conversion's commit, a stopped upload, the fast join and a clip's copy hold up
- The fast join gives each ffconcat entry its own media-only format whitelist, so a library file
can no longer be opened as a nested concat list (the list's whitelist was handed down to it).
- A file the guarded probe cannot read is a download-only file, not a guess from yt-dlp's info.
- A conversion's or an ingest's commit that raises is re-read in a fresh session before any file
is removed: a commit that landed keeps the file its row now names.
- An original that cannot be removed after conversion is logged; the upload stays ready.
- An upload whose conversion is cancelled or deleted midway is freed once nothing holds it; a
paused one is kept.
- A shared link says "preparing" only while its video's job is queued or running.
- The admin storage view counts every file on disk ("Files on disk"), not only ready ones.
- A clip's failure title, unknown failure and cancel dialog, and a clip's and an upload's delete
dialog speak of what the job made; an upload's quota refusal explains that the original is kept.
- /watch announces "preparing" as an alert and moves focus to the new Retry after an action.
This commit is contained in:
@@ -21,7 +21,9 @@ from app.downloads import probe as probemod
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads.stated import StatedFailure
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset, Notification
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -164,6 +166,33 @@ def test_two_dotted_bare_names_keep_a_file_each(db, make_user, _root, monkeypatc
|
||||
assert all(storage.abs_path(str(_root), r).exists() for r in finals)
|
||||
|
||||
|
||||
def test_a_bare_names_stem_holds_no_dot_so_a_swapped_suffix_keeps_its_id():
|
||||
# Each of the two name rules is held on its own: a new name's stem has no dot, so a sibling
|
||||
# derived by `with_suffix` (the poster, the converted file) keeps `_a{id}`.
|
||||
rel = storage.upload_rel_path(1, 5, "Holiday.2024.ä")
|
||||
assert rel == ".uploads/1/Holiday_2024_a5"
|
||||
assert Path(rel).with_suffix(".jpg").name == "Holiday_2024_a5.jpg"
|
||||
|
||||
|
||||
def test_an_upload_placed_with_a_dotted_bare_name_keeps_its_id_when_converted(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
# A row from before the stems lost their dots: its stem's tail is no extension.
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
_converts_to(monkeypatch, size=5)
|
||||
job, asset = _ingest(db, _root, u.id, name="Holiday.2024.ä")
|
||||
legacy = f".uploads/{u.id}/Holiday.2024_a{asset.id}"
|
||||
storage.abs_path(str(_root), asset.rel_path).rename(storage.abs_path(str(_root), legacy))
|
||||
asset.rel_path, asset.container = legacy, None
|
||||
_claim(db, job, asset)
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(MediaAsset, asset.id).rel_path == f"{legacy}.mp4"
|
||||
|
||||
|
||||
def test_a_conversion_failing_after_it_placed_its_file_removes_it(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
@@ -187,6 +216,94 @@ def test_a_conversion_failing_after_it_placed_its_file_removes_it(
|
||||
assert [p.name for p in _uploads_dir(_root, u.id).iterdir()] == [original.name]
|
||||
|
||||
|
||||
def _finishing_commit_raises(monkeypatch, *, lands: bool) -> None:
|
||||
"""The commit that makes the converted upload ready raises — after it reached the server
|
||||
(`lands`: the connection dropped before its answer) or before (it rolled back)."""
|
||||
real_finalize, real_commit = pipeline.service.finalize_if_running, Session.commit
|
||||
state = {"session": None, "fired": False}
|
||||
|
||||
def _finalize(db, *a, **k):
|
||||
if not state["fired"] and state["session"] is None:
|
||||
state["session"] = db
|
||||
return real_finalize(db, *a, **k)
|
||||
|
||||
def _commit(self):
|
||||
if self is state["session"] and not state["fired"]:
|
||||
state["fired"] = True
|
||||
if lands:
|
||||
real_commit(self)
|
||||
raise OperationalError("COMMIT", {}, Exception("connection lost"))
|
||||
return real_commit(self)
|
||||
|
||||
monkeypatch.setattr(pipeline.service, "finalize_if_running", _finalize)
|
||||
monkeypatch.setattr(Session, "commit", _commit)
|
||||
|
||||
|
||||
def test_a_finishing_commit_that_landed_but_raised_keeps_the_converted_file(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
# The row names the converted file now: removing it as "never committed" would leave the asset
|
||||
# pointing at nothing and its original named by no row, for the sweep to take — both copies gone.
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id)
|
||||
_claim(db, job, asset)
|
||||
_converts_to(monkeypatch, size=5)
|
||||
_finishing_commit_raises(monkeypatch, lands=True)
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
asset = db.get(MediaAsset, asset.id)
|
||||
assert (db.get(DownloadJob, job.id).status, asset.status) == ("done", "ready")
|
||||
assert storage.abs_path(str(_root), asset.rel_path).exists()
|
||||
|
||||
|
||||
def test_a_finishing_commit_that_rolled_back_fails_and_keeps_the_original(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id)
|
||||
original = storage.abs_path(str(_root), asset.rel_path)
|
||||
_claim(db, job, asset)
|
||||
_converts_to(monkeypatch, size=5)
|
||||
_finishing_commit_raises(monkeypatch, lands=False)
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(DownloadJob, job.id).status == "error"
|
||||
assert [p.name for p in _uploads_dir(_root, u.id).iterdir()] == [original.name]
|
||||
|
||||
|
||||
def test_an_original_that_cannot_be_removed_leaves_the_upload_ready(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
# The converted file is committed and the job done: the original's unlink failing must not flip
|
||||
# a working asset to an error no Retry is offered for. The sweep takes the original later.
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id)
|
||||
original = storage.abs_path(str(_root), asset.rel_path)
|
||||
_claim(db, job, asset)
|
||||
_converts_to(monkeypatch, size=5)
|
||||
real_unlink = Path.unlink
|
||||
|
||||
def _unlink(self, missing_ok=False):
|
||||
if self == original:
|
||||
raise PermissionError("read-only")
|
||||
return real_unlink(self, missing_ok=missing_ok)
|
||||
|
||||
monkeypatch.setattr(Path, "unlink", _unlink)
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
asset = db.get(MediaAsset, asset.id)
|
||||
assert (db.get(DownloadJob, job.id).status, asset.status) == ("done", "ready")
|
||||
assert original.exists()
|
||||
|
||||
|
||||
# --- what the owner is told ------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -272,6 +389,116 @@ def test_the_sweep_takes_a_stranded_upload_file_and_keeps_a_waiting_original(
|
||||
assert not stranded.exists() # no row names it
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("job_status", "asset_status"),
|
||||
[
|
||||
("queued", "pending"), # waiting for the worker
|
||||
("running", "downloading"), # being converted, for longer than the sweep's age floor
|
||||
("error", "error"), # failed, kept for Retry
|
||||
("done", "ready"), # converted
|
||||
],
|
||||
)
|
||||
def test_the_sweep_keeps_an_aged_upload_file_its_row_names_in_any_state(
|
||||
job_status, asset_status, db, make_user, _root, monkeypatch
|
||||
):
|
||||
_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
|
||||
db.commit()
|
||||
_aged(path)
|
||||
|
||||
gcmod._sweep_orphan_files(db, str(_root))
|
||||
|
||||
assert path.exists()
|
||||
|
||||
|
||||
def _row(db, asset_id: int) -> MediaAsset | None:
|
||||
"""The asset as the database has it now (a `get` would refresh the session's own copy, and
|
||||
raise for one deleted under it)."""
|
||||
db.expire_all()
|
||||
return db.execute(select(MediaAsset).where(MediaAsset.id == asset_id)).scalar_one_or_none()
|
||||
|
||||
|
||||
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(MediaAsset).where(MediaAsset.id == asset.id).values(ref_count=ref_count))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _stopped_by(monkeypatch, db, job, asset, job_status: str, ref_count: int) -> None:
|
||||
"""The conversion is stopped mid-encode after the route moved the job to `job_status` and its
|
||||
hold on the asset to `ref_count` (cancel and delete release it; pause keeps it)."""
|
||||
|
||||
def _encode(*_a, **_k):
|
||||
_route_moved(db, job, asset, job_status, ref_count)
|
||||
raise pipeline.editmod.EditAborted()
|
||||
|
||||
monkeypatch.setattr(pipeline, "_ensure_phone_safe", _encode)
|
||||
|
||||
|
||||
def test_an_upload_cancelled_mid_conversion_is_freed_once_the_conversion_stops(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
# `release_asset` leaves it while the conversion reads the file; once it stopped, nothing holds it
|
||||
# — kept, it would sit on disk outside every footprint until the GC's upload pass.
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id, size=10)
|
||||
original = storage.abs_path(str(_root), asset.rel_path)
|
||||
_claim(db, job, asset)
|
||||
_stopped_by(monkeypatch, db, job, asset, "canceled", 0)
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
assert _row(db, asset.id) is None
|
||||
assert not original.exists()
|
||||
assert quota.footprint(db, u.id) == 0
|
||||
|
||||
|
||||
def test_an_upload_paused_mid_conversion_keeps_its_original_for_the_resume(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id, size=10)
|
||||
original = storage.abs_path(str(_root), asset.rel_path)
|
||||
_claim(db, job, asset)
|
||||
_stopped_by(monkeypatch, db, job, asset, "paused", 1)
|
||||
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(MediaAsset, asset.id).status == "pending"
|
||||
assert original.exists()
|
||||
|
||||
|
||||
def test_an_upload_whose_conversion_finished_after_its_job_was_cancelled_is_freed(
|
||||
db, make_user, _root, monkeypatch
|
||||
):
|
||||
_probe(monkeypatch)
|
||||
u = make_user()
|
||||
job, asset = _ingest(db, _root, u.id, size=10)
|
||||
_claim(db, job, asset)
|
||||
_converts_to(monkeypatch, size=5)
|
||||
real_encode = pipeline._ensure_phone_safe
|
||||
|
||||
def _encode_then_cancelled(*a, **k):
|
||||
out = real_encode(*a, **k)
|
||||
_route_moved(db, job, asset, "canceled", 0)
|
||||
return out
|
||||
|
||||
monkeypatch.setattr(pipeline, "_ensure_phone_safe", _encode_then_cancelled)
|
||||
pipeline._convert_upload(job.id, asset.id)
|
||||
|
||||
assert _row(db, asset.id) is None
|
||||
left = _uploads_dir(_root, u.id)
|
||||
assert not left.exists() or list(left.iterdir()) == []
|
||||
|
||||
|
||||
def test_the_cache_cap_never_evicts_an_upload(db, make_user, _root, monkeypatch):
|
||||
_probe(monkeypatch, vcodec="h264")
|
||||
u = make_user()
|
||||
@@ -301,12 +528,21 @@ def test_the_cache_cap_never_evicts_an_upload(db, make_user, _root, monkeypatch)
|
||||
# --- a share link ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["pending", "downloading"])
|
||||
def test_a_link_whose_video_is_being_made_again_says_preparing_not_gone(
|
||||
state, db, as_user, make_user, _root, monkeypatch, upload_headers
|
||||
@pytest.mark.parametrize(
|
||||
("job_status", "asset_status", "reason"),
|
||||
[
|
||||
("queued", "pending", "video_preparing"), # a re-download waiting for the worker
|
||||
("running", "downloading", "video_preparing"), # being made now
|
||||
("canceled", "pending", "video_gone"), # a rebuild cancelled: nothing will finish it
|
||||
("paused", "pending", "video_gone"), # held until its owner resumes it, maybe never
|
||||
],
|
||||
)
|
||||
def test_a_link_says_preparing_only_while_its_video_is_being_made(
|
||||
job_status, asset_status, reason, db, as_user, make_user, _root, monkeypatch, upload_headers
|
||||
):
|
||||
# A link is made on a finished job; its asset can be (re)made under it later — a shared cache row
|
||||
# re-fetched, a conversion retried. Until it is ready the page says "try again shortly".
|
||||
# re-fetched, a conversion retried. While that runs the page says "try again shortly"; once
|
||||
# nothing is making it, it says the video is gone rather than promising it forever.
|
||||
_probe(monkeypatch, vcodec="h264")
|
||||
c = as_user(make_user())
|
||||
job = c.post(
|
||||
@@ -314,11 +550,12 @@ def test_a_link_whose_video_is_being_made_again_says_preparing_not_gone(
|
||||
).json()
|
||||
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
|
||||
assert c.get(f"/api/public/watch/{link['token']}").status_code == 200
|
||||
db.get(MediaAsset, job["asset"]["id"]).status = state
|
||||
db.get(MediaAsset, job["asset"]["id"]).status = asset_status
|
||||
db.get(DownloadJob, job["id"]).status = job_status
|
||||
db.commit()
|
||||
r = c.get(f"/api/public/watch/{link['token']}")
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"]["reason"] == "video_preparing"
|
||||
assert r.json()["detail"]["reason"] == reason
|
||||
|
||||
|
||||
def test_the_admin_storage_view_counts_a_waiting_upload(db, as_user, make_user, _root, monkeypatch):
|
||||
@@ -327,7 +564,7 @@ def test_the_admin_storage_view_counts_a_waiting_upload(db, as_user, make_user,
|
||||
u = make_user()
|
||||
_ingest(db, _root, u.id, size=10)
|
||||
r = as_user(make_user(role="admin")).get("/api/admin/downloads/storage").json()
|
||||
assert (r["ready_files"], r["total_bytes"]) == (1, 10)
|
||||
assert (r["files_on_disk"], r["total_bytes"]) == (1, 10)
|
||||
assert {p["user_id"]: p["footprint_bytes"] for p in r["per_user"]} == {u.id: 10}
|
||||
|
||||
|
||||
@@ -345,11 +582,11 @@ def test_the_upload_reap_leaves_one_still_held_or_still_read(
|
||||
_probe(monkeypatch)
|
||||
job, asset = _ingest(db, _root, make_user().id)
|
||||
path = storage.abs_path(str(_root), asset.rel_path)
|
||||
job.status, asset.status, asset.ref_count = job_status, asset_status, ref_count
|
||||
asset.status = asset_status
|
||||
db.commit()
|
||||
_route_moved(db, job, asset, job_status, ref_count)
|
||||
|
||||
gcmod.run_download_gc(db)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(MediaAsset, asset.id) is not None
|
||||
assert _row(db, asset.id) is not None
|
||||
assert path.exists()
|
||||
|
||||
Reference in New Issue
Block a user