fix(clip): a clip is held to its owner's storage cap, and its limits and failures say the server's truth
- The cut file is measured against what its owner has left, for a YouTube range (whose fetch never reads yt-dlp's size ceiling) and for a Plex clip (which fetches nothing), before the fetch and again before it is placed; a refusal leaves the asset pending for a Retry. - A silent Plex file is cut silent; a file the probe cannot read says so. - A second job on a failed or renamed clip gets the same YouTube diagnosis and the fetched title. - The "too long" refusal names the server's cap; a contract test holds the client's copies of the clip limits to clip.py; the shipped player clients are pinned against the installed yt-dlp. - One HDR transfer set and one rating_key lookup; job status words from JobStatus; the display-name guard sees subscript writes and the clip route; stale comments and types follow the clip job.
This commit is contained in:
+139
-6
@@ -11,8 +11,10 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import worker as workermod
|
||||
from app.config import settings
|
||||
from app.downloads import clip as clipmod
|
||||
from app.downloads import errors as dlerrors
|
||||
from app.downloads import pipeline, quota, runner, service
|
||||
from app.downloads import probe as probemod
|
||||
from app.downloads.edit import INPUT_GUARD
|
||||
@@ -315,19 +317,20 @@ def test_a_ready_clip_asked_for_again_is_done_at_once(db, make_user, as_user, yt
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "status", "reason"),
|
||||
("body", "status", "reason", "params"),
|
||||
[
|
||||
({"end_s": 700}, 422, "too_long"),
|
||||
({"start_s": 299.5, "end_s": 330}, 422, "past_end"),
|
||||
({"ref": "nope"}, 422, "bad_source"),
|
||||
# The cap travels with its refusal, so the client never states a stale one.
|
||||
({"end_s": 700}, 422, "too_long", {"limit_s": clipmod.MAX_CLIP_S}),
|
||||
({"start_s": 299.5, "end_s": 330}, 422, "past_end", {}),
|
||||
({"ref": "nope"}, 422, "bad_source", {}),
|
||||
],
|
||||
)
|
||||
def test_a_youtube_clip_it_cannot_cut_says_why(
|
||||
db, make_user, as_user, yt_video, body, status, reason
|
||||
db, make_user, as_user, yt_video, body, status, reason, params
|
||||
):
|
||||
r = _clip(as_user(make_user()), **{"source": "youtube", "ref": yt_video, **body})
|
||||
assert r.status_code == status
|
||||
assert r.json()["detail"] == {"code": "clip", "reason": reason}
|
||||
assert r.json()["detail"] == {"code": "clip", "reason": reason, **params}
|
||||
assert db.query(DownloadJob).count() == 0
|
||||
|
||||
|
||||
@@ -650,3 +653,133 @@ def test_a_youtube_clip_on_the_bot_wall_is_diagnosed_like_a_download(
|
||||
job, asset = worker["run"](_clip(as_user(user), source="youtube", ref=yt_video).json())
|
||||
assert (job.status, asset.status) == ("error", "error")
|
||||
assert (job.error_code, job.error_detail) == ("login_required", detail)
|
||||
|
||||
|
||||
# --- held to the owner's storage cap, and a clip that cannot or need not go on ---------------------
|
||||
|
||||
|
||||
def _fetch_range(monkeypatch):
|
||||
def extract(job_id, asset_id, url, spec, staging, adapter, **kw):
|
||||
f = Path(staging) / "abcdefghijk.mp4"
|
||||
f.write_bytes(b"range")
|
||||
return {"requested_downloads": [{"filepath": str(f)}]}
|
||||
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", extract)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["youtube", "plex"])
|
||||
def test_a_clip_for_an_owner_whose_storage_is_full_is_refused_before_it_is_cut(
|
||||
db, make_user, as_user, yt_video, plex_episode, worker, monkeypatch, source
|
||||
):
|
||||
_fetch_range(monkeypatch)
|
||||
ref = yt_video if source == "youtube" else "777"
|
||||
job = _clip(as_user(make_user()), source=source, ref=ref).json()
|
||||
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 0)
|
||||
job, asset = worker["run"](job)
|
||||
# Refused like a download at its cap: the job says why, the asset waits for a Retry.
|
||||
assert (job.status, job.error, asset.status) == (
|
||||
"error",
|
||||
dlerrors.QUOTA_FULL_MESSAGE,
|
||||
"pending",
|
||||
)
|
||||
assert "cmd" not in worker
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["youtube", "plex"])
|
||||
def test_a_clip_larger_than_what_its_owner_has_left_is_refused_and_not_placed(
|
||||
db, make_user, as_user, yt_video, plex_episode, worker, monkeypatch, source
|
||||
):
|
||||
# The range fetch's own ceiling never reaches yt-dlp's ffmpeg downloader, and a Plex clip
|
||||
# fetches nothing: the cut file itself is held to the cap.
|
||||
_fetch_range(monkeypatch)
|
||||
ref = yt_video if source == "youtube" else "777"
|
||||
job = _clip(as_user(make_user()), source=source, ref=ref).json()
|
||||
monkeypatch.setattr(quota, "remaining_bytes", lambda _db, _uid: 3) # the cut is 4 bytes
|
||||
job, asset = worker["run"](job)
|
||||
assert (job.status, job.error, asset.status, asset.rel_path) == (
|
||||
"error",
|
||||
dlerrors.QUOTA_FULL_MESSAGE,
|
||||
"pending",
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def test_a_plex_clip_of_a_silent_file_is_cut_silent(db, make_user, as_user, plex_episode, worker):
|
||||
# The player names the track it plays 0 even when the file has none.
|
||||
worker["probe"] = {"streams": [{"codec_type": "video"}]}
|
||||
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=0).json()
|
||||
job, asset = worker["run"](job)
|
||||
assert (job.status, asset.status) == ("done", "ready")
|
||||
assert "0:a:0?" in worker["cmd"]
|
||||
|
||||
|
||||
def test_a_plex_clip_of_a_file_the_probe_cannot_read_says_so(
|
||||
db, make_user, as_user, plex_episode, worker
|
||||
):
|
||||
worker["probe"] = None
|
||||
job = _clip(as_user(make_user()), source="plex", ref="777", audio_track=0).json()
|
||||
job, asset = worker["run"](job)
|
||||
assert (job.status, asset.status) == ("error", "error")
|
||||
assert job.error == "The source file could not be read."
|
||||
assert "cmd" not in worker
|
||||
|
||||
|
||||
@pytest.mark.parametrize("where", ["fetch", "encode"])
|
||||
def test_a_paused_clip_frees_its_asset_for_a_later_retry(
|
||||
db, make_user, as_user, yt_video, worker, monkeypatch, where
|
||||
):
|
||||
def stopped(*_a, **_k):
|
||||
raise pipeline._Aborted() if where == "fetch" else pipeline.editmod.EditAborted()
|
||||
|
||||
if where == "fetch":
|
||||
monkeypatch.setattr(pipeline, "_extract_with_fallback", stopped)
|
||||
else:
|
||||
_fetch_range(monkeypatch)
|
||||
monkeypatch.setattr(pipeline.editmod, "run_ffmpeg", stopped)
|
||||
job = _clip(as_user(make_user()), source="youtube", ref=yt_video).json()
|
||||
job, asset = worker["run"](job)
|
||||
# The route already wrote the job's own state (paused / canceled); the asset is free again.
|
||||
assert (job.status, job.error, asset.status) == ("running", None, "pending")
|
||||
|
||||
|
||||
# --- a second job on the same clip -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _second_job(db, client, **body):
|
||||
"""Two jobs for one range: the second shares the first's asset."""
|
||||
first = _clip(client, **body).json()
|
||||
second = _clip(client, **body).json()
|
||||
assert first["asset"]["id"] == second["asset"]["id"]
|
||||
return db.get(DownloadJob, first["id"]), db.get(DownloadJob, second["id"])
|
||||
|
||||
|
||||
def test_a_second_clip_job_on_a_failed_clip_gets_the_same_youtube_diagnosis(
|
||||
db, make_user, as_user, yt_video
|
||||
):
|
||||
_first, second = _second_job(db, as_user(make_user()), source="youtube", ref=yt_video)
|
||||
asset = db.get(MediaAsset, second.asset_id)
|
||||
asset.status = "error"
|
||||
asset.error = f"ERROR: [youtube] {yt_video}: Sign in to confirm you’re not a bot."
|
||||
second.status = "running"
|
||||
db.commit()
|
||||
workermod._process_job(second.id)
|
||||
db.expire_all()
|
||||
job = db.get(DownloadJob, second.id)
|
||||
assert (job.status, job.error_code, job.error_detail) == (
|
||||
"error",
|
||||
"login_required",
|
||||
"youtube.bot_wall",
|
||||
)
|
||||
|
||||
|
||||
def test_a_second_clip_job_takes_the_title_its_sibling_found(db, make_user, as_user):
|
||||
_first, second = _second_job(db, as_user(make_user()), source="youtube", ref="zyxwvutsrqp")
|
||||
assert second.display_name == "Clip (clip)"
|
||||
asset = db.get(MediaAsset, second.asset_id)
|
||||
asset.status, asset.title = "ready", "Found On YouTube (clip)"
|
||||
second.status = "running"
|
||||
db.commit()
|
||||
workermod._finish_from_cache(second.id, asset.id)
|
||||
db.expire_all()
|
||||
job = db.get(DownloadJob, second.id)
|
||||
assert (job.status, job.display_name) == ("done", "Found On YouTube (clip)")
|
||||
|
||||
Reference in New Issue
Block a user