"""An upload after ingest: what it costs its owner from the moment it lands, how the worker's conversion names, checks and cleans up the file it makes, and what the owner and a share link see. DB-backed. The conversion's ffmpeg step (`pipeline._ensure_phone_safe`) is stubbed to write a file of a chosen size — these tests are about what happens around the encode; `test_upload.py` runs the real one, and `test_playable.py` judges its bytes. """ import os import time from datetime import datetime, timedelta, timezone from pathlib import Path import pytest from app import sysconfig from app.config import settings from app.downloads import gc as gcmod from app.downloads import pipeline, quota, storage 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, update from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session @pytest.fixture def _root(tmp_path, monkeypatch): monkeypatch.setattr(settings, "download_root", str(tmp_path)) return tmp_path def _probe(monkeypatch, vcodec="hevc"): """ffprobe's view of every file here: `vcodec` + AAC, 8-bit 4:2:0 — HEVC is queued for the worker, H.264 lands as it came.""" data = { "streams": [ {"codec_type": "video", "codec_name": vcodec, "pix_fmt": "yuv420p", "width": 640, "height": 360}, {"codec_type": "audio", "codec_name": "aac"}, ], "format": {"format_name": "mov,mp4", "duration": "6"}, } # fmt: skip monkeypatch.setattr(probemod, "probe_json", lambda _p: data) def _ingest(db, root, user_id, name="clip.mov", size=10): src = root / "incoming.bin" src.write_bytes(b"o" * size) job = uploadmod.ingest_upload(db, user_id, filename=name, src_path=src, size=size) return job, db.get(MediaAsset, job.asset_id) def _claim(db, job, asset): """What the worker's claim does before it converts.""" job.status, asset.status = "running", "downloading" db.commit() def _converts_to(monkeypatch, size, phases=None): """The encode writes a `size`-byte .mp4 into its staging dir (and notes the phase it reports).""" def _encode(job_id, produced, out_dir, phase, total_s=None, **_k): if phases is not None: phases.append(phase) out = Path(out_dir) / "converted.mp4" out.write_bytes(b"c" * size) return out monkeypatch.setattr(pipeline, "_ensure_phone_safe", _encode) def _cap(db, user_id, max_bytes): db.add( DownloadQuota( user_id=user_id, max_bytes=max_bytes, max_concurrent=2, max_jobs=50, unlimited=False ) ) db.commit() def _uploads_dir(root, user_id) -> Path: return root / ".uploads" / str(user_id) # --- what it costs --------------------------------------------------------------------------- def test_a_queued_upload_counts_against_its_owner_from_the_moment_it_lands( db, make_user, _root, monkeypatch ): _probe(monkeypatch) u = make_user() job, asset = _ingest(db, _root, u.id, size=10) assert (job.status, asset.status) == ("queued", "pending") assert quota.footprint(db, u.id) == 10 # its original is on disk already def test_a_failed_conversion_still_counts_its_kept_original(db, make_user, _root, monkeypatch): _probe(monkeypatch) u = make_user() job, asset = _ingest(db, _root, u.id, size=10) job.status, asset.status = "error", "error" # kept for Retry db.commit() assert storage.abs_path(str(_root), asset.rel_path).exists() assert quota.footprint(db, u.id) == 10 def test_a_conversion_that_outgrows_the_cap_is_refused_and_keeps_the_original( db, make_user, _root, monkeypatch ): _probe(monkeypatch) u = make_user() _cap(db, u.id, max_bytes=100) job, asset = _ingest(db, _root, u.id, size=10) original = storage.abs_path(str(_root), asset.rel_path) _claim(db, job, asset) _converts_to(monkeypatch, size=500) pipeline._convert_upload(job.id, asset.id) db.expire_all() job = db.get(DownloadJob, job.id) assert (job.status, job.error_code) == ("error", "quota_exceeded") assert original.exists() assert [p.name for p in _uploads_dir(_root, u.id).iterdir()] == [original.name] def test_a_conversion_that_shrinks_passes_an_owner_at_the_cap(db, make_user, _root, monkeypatch): _probe(monkeypatch) u = make_user() _cap(db, u.id, max_bytes=10) job, asset = _ingest(db, _root, u.id, size=10) _claim(db, job, asset) _converts_to(monkeypatch, size=5) pipeline._convert_upload(job.id, asset.id) db.expire_all() assert db.get(DownloadJob, job.id).status == "done" assert db.get(MediaAsset, asset.id).size_bytes == 5 # --- the converted file's name and its leftovers ----------------------------------------------- def test_two_dotted_bare_names_keep_a_file_each(db, make_user, _root, monkeypatch): # 'Holiday.2024.ä': its suffix is no extension (nothing shell-safe is left of it), so the stem's # own dot once read as one — and the converted name lost `_a{id}`, the one thing keeping two apart. _probe(monkeypatch) u = make_user() _converts_to(monkeypatch, size=5) finals = [] for _ in range(2): job, asset = _ingest(db, _root, u.id, name="Holiday.2024.ä") _claim(db, job, asset) pipeline._convert_upload(job.id, asset.id) db.expire_all() asset = db.get(MediaAsset, asset.id) assert asset.status == "ready" assert asset.rel_path.endswith(f"_a{asset.id}.mp4"), asset.rel_path finals.append(asset.rel_path) assert finals[0] != finals[1] 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 ): _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) def _probe_fails(_path): raise RuntimeError("probe crashed") monkeypatch.setattr(pipeline.probe, "probe_media", _probe_fails) pipeline._convert_upload(job.id, asset.id) db.expire_all() assert db.get(DownloadJob, job.id).status == "error" assert original.exists() # Retry starts from it # The row still names the original: a converted file left beside it is one nothing would find. 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 ------------------------------------------------------------------ def test_a_failed_encode_is_a_failed_conversion_not_a_download_step( db, make_user, _root, monkeypatch ): _probe(monkeypatch) job, asset = _ingest(db, _root, make_user().id) _claim(db, job, asset) def _ffmpeg_fails(*_a, **_k): raise StatedFailure("ffmpeg failed (1): unsupported") monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", _ffmpeg_fails) pipeline._convert_upload(job.id, asset.id) db.expire_all() assert db.get(DownloadJob, job.id).error_code == "convert_failed" def test_a_missing_original_says_so(db, make_user, _root, monkeypatch): _probe(monkeypatch) job, asset = _ingest(db, _root, make_user().id) _claim(db, job, asset) storage.abs_path(str(_root), asset.rel_path).unlink() pipeline._convert_upload(job.id, asset.id) db.expire_all() assert db.get(DownloadJob, job.id).error_code == "upload_missing" def test_the_conversion_reports_its_own_phase(db, make_user, _root, monkeypatch): _probe(monkeypatch) job, asset = _ingest(db, _root, make_user().id) _claim(db, job, asset) phases = [] _converts_to(monkeypatch, size=5, phases=phases) pipeline._convert_upload(job.id, asset.id) assert phases == ["converting"] # not yt-dlp's "processing": the queue row shows ffmpeg's % def test_the_owner_is_told_their_upload_is_ready(db, make_user, _root, monkeypatch): _probe(monkeypatch) u = make_user() job, asset = _ingest(db, _root, u.id) _claim(db, job, asset) _converts_to(monkeypatch, size=5) pipeline._convert_upload(job.id, asset.id) notes = db.execute(select(Notification).where(Notification.user_id == u.id)).scalars().all() assert [n.data.get("kind") for n in notes] == ["upload"] def test_an_extension_is_judged_as_it_is_stored(db, make_user, _root, monkeypatch): # 'clip.mp4~' is stored as .mp4; judged by the raw 'mp4~' it queued a conversion of a file that # was phone-safe already. _probe(monkeypatch, vcodec="h264") job, asset = _ingest(db, _root, make_user().id, name="clip.mp4~") assert (job.status, asset.status, asset.container) == ("done", "ready", "mp4") # --- the garbage collector --------------------------------------------------------------------- def _aged(path: Path, hours: float = 5) -> None: old = time.time() - hours * 3600 os.utime(path, (old, old)) def test_the_sweep_takes_a_stranded_upload_file_and_keeps_a_waiting_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) stranded = original.with_name("left_by_a_stopped_conversion_a999.mp4") stranded.write_bytes(b"x") _aged(original) _aged(stranded) gcmod._sweep_orphan_files(db, str(_root)) assert original.exists() # queued for conversion: its row names it, ready or not 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() _job, upload = _ingest(db, _root, u.id, name="mine.mp4", size=10) db.add( MediaAsset( source_kind="youtube", source_ref="vid1", format_sig="best", status="ready", rel_path="Chan/clip.mp4", size_bytes=10, title="downloaded", last_access_at=datetime.now(timezone.utc), ) ) # fmt: skip # The upload is the least recently used: an LRU pass that may take it takes it first. upload.last_access_at = datetime.now(timezone.utc) - timedelta(days=30) db.commit() (_root / "Chan").mkdir() (_root / "Chan" / "clip.mp4").write_bytes(b"d" * 10) sysconfig.set_value(db, "download_total_max_bytes", 15) # 20 on disk db.commit() gcmod.run_download_gc(db) db.expire_all() assert db.get(MediaAsset, upload.id).status == "ready" assert storage.abs_path(str(_root), upload.rel_path).exists() # --- a share link ---------------------------------------------------------------------------- @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. 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( "/api/downloads/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4") ).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 = 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"] == reason def test_the_admin_storage_view_counts_a_waiting_upload(db, as_user, make_user, _root, monkeypatch): # The same disk the owner's footprint and the GC's cap count (`quota.ON_DISK`). _probe(monkeypatch) 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["files_on_disk"], r["total_bytes"]) == (1, 10) assert {p["user_id"]: p["footprint_bytes"] for p in r["per_user"]} == {u.id: 10} @pytest.mark.parametrize( ("job_status", "asset_status", "ref_count"), [ ("done", "ready", 1), # a finished upload its owner keeps ("queued", "pending", 1), # one waiting for the worker ("canceled", "downloading", 0), # cancelled mid-conversion: the worker still reads it ], ) def test_the_upload_reap_leaves_one_still_held_or_still_read( job_status, asset_status, ref_count, db, make_user, _root, monkeypatch ): _probe(monkeypatch) job, asset = _ingest(db, _root, make_user().id) path = storage.abs_path(str(_root), asset.rel_path) asset.status = asset_status db.commit() _route_moved(db, job, asset, job_status, ref_count) gcmod.run_download_gc(db) assert _row(db, asset.id) is not None assert path.exists()