The Files library starts here. A user's every download, clip and upload now lands in a source folder made per origin (YouTube, Plex clips, uploads, one per other site), decided once in downloads/folders.py at every place a job is made. How long a file stays moves from the shared asset to the user's job: stamped from its folder when it is done, never for a file the user keeps. The GC warns each user once, then takes the expired file out of their library; the cache cap never evicts a kept file, and an evicted one leaves every holder's library. The media mount is /files now (FILES_ROOT, FILES_HOST_PATH, volume siftlode_files); docs/self-hosting.md says how to carry an old volume over. Migration 0102 places every existing job and carries each done file's expiry over from its asset. safe_abs_path takes a missing path and answers None, which drops one baselined type error.
74 lines
3.1 KiB
Python
74 lines
3.1 KiB
Python
"""A libx264 re-encode of an odd-sized frame: 4:2:0 halves the chroma, so libx264 refuses an odd
|
|
width or height ("width not divisible by 2"). Measured: a source that is odd AND not 4:2:0 already
|
|
(a 4:4:4 or RGB screen capture, 1365x767) failed its conversion outright; a 4:2:0 source never
|
|
reaches the encoder odd (ffmpeg's crop rounds to its chroma grid), but the clip then stored the
|
|
size the user dragged, not the one the file has. Every re-encode now ends its filter chain on
|
|
`edit.EVEN_FRAME` (at most one pixel row or column), and a clip stores its probed frame.
|
|
|
|
Real ffmpeg on both paths — the refusal is the encoder's, so only the encoder can show it is gone.
|
|
"""
|
|
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.config import settings
|
|
from app.downloads import pipeline, playable, probe, service, storage
|
|
from app.downloads import upload as uploadmod
|
|
from app.models import DownloadJob, MediaAsset
|
|
|
|
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
|
|
|
|
|
def _ffmpeg(*args: str) -> None:
|
|
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args], check=True)
|
|
|
|
|
|
def _frame(path: Path) -> tuple[int | None, int | None]:
|
|
_, _, w, h, _ = probe.probe_media(path)
|
|
return w, h
|
|
|
|
|
|
def test_an_odd_sized_4_4_4_video_converts_to_the_even_frame_below_it(tmp_path):
|
|
src = tmp_path / "in.mkv"
|
|
_ffmpeg(
|
|
"-f", "lavfi", "-i", "testsrc=size=161x121:rate=10:duration=1",
|
|
"-c:v", "libx264", "-pix_fmt", "yuv444p", str(src),
|
|
) # fmt: skip
|
|
assert _frame(src) == (161, 121) # odd for real (`testsrc2` rounds its own size)
|
|
p = playable.plan(probe.probe_json(src), "mkv")
|
|
assert p is not None and p.reencode_video
|
|
dest = tmp_path / "out.mp4"
|
|
subprocess.run(playable.build_cmd(src, dest, p), check=True, capture_output=True)
|
|
assert _frame(dest) == (160, 120)
|
|
|
|
|
|
def test_an_odd_crop_makes_a_clip_and_stores_the_frame_it_has(db, make_user, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
|
src = tmp_path / "in.mp4"
|
|
_ffmpeg(
|
|
"-f", "lavfi", "-i", "testsrc2=size=160x120:rate=10:duration=2",
|
|
"-f", "lavfi", "-i", "sine=duration=2",
|
|
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(src),
|
|
) # fmt: skip
|
|
u = make_user()
|
|
source = uploadmod.ingest_upload(
|
|
db, u.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
|
|
)
|
|
assert source.status == "done" # already phone-safe: the clip's source as it came
|
|
job = service.enqueue_edit(db, u.id, source, {"crop": {"x": 3, "y": 5, "w": 101, "h": 51}})
|
|
asset = db.get(MediaAsset, job.asset_id)
|
|
job.status, asset.status = "running", "downloading" # the worker's claim
|
|
db.commit()
|
|
|
|
pipeline._process_edit(job.id, asset.id)
|
|
|
|
db.expire_all()
|
|
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
|
|
assert (job.status, job.error, asset.status) == ("done", None, "ready")
|
|
final = storage.abs_path(str(tmp_path), asset.rel_path)
|
|
assert _frame(final) == (100, 50)
|
|
assert (asset.width, asset.height) == (100, 50) # what the file is, not what was asked
|