3 Commits
6 changed files with 181 additions and 33 deletions
+3 -3
View File
@@ -27,7 +27,7 @@ JELLYFIN_CACHE_ROOT=/data/cache
SERIES_COOLDOWN_SECONDS=3600
POLL_SECONDS=5
RESCAN_SECONDS=300
H264_CRF=20
H264_CRF=18
H264_PRESET=medium
H264_MAXRATE=12M
H264_BUFSIZE=24M
H264_MAXRATE=15M
H264_BUFSIZE=30M
+21 -4
View File
@@ -54,9 +54,11 @@ the `.env.example` values through Coolify's environment-variable UI.
## Codec policy
Cached video is H.264 High Profile, Level 4.1, 8-bit `yuv420p`, CRF 20, capped
at 12 Mbps. Existing audio, subtitles, chapters, and Matroska attachments are
preserved when the container supports them.
Cached video is H.264 High Profile, 8-bit `yuv420p`, CRF 18, capped at 15 Mbps.
Resolution and frame rate are preserved. Existing audio, subtitles, chapters,
and Matroska attachments are copied. Ten-bit SDR is converted with
error-diffusion dithering; sources carrying explicit HDR10 or HLG transfer
metadata are tone-mapped to BT.709 SDR.
The fixed source blacklist is:
@@ -65,7 +67,22 @@ The fixed source blacklist is:
- MPEG-2
- VC-1
Client error reports never modify this list.
Client error reports never modify this list. Encoding settings form part of
each cache manifest, so a global quality-policy change automatically invalidates
and replaces older renditions without touching originals.
## Cache resizing
`CACHE_MAX_BYTES` is the worker's configurable LRU limit. After changing it and
restarting the container:
- A smaller value evicts the oldest unlocked entries until usage fits.
- A larger value immediately makes the additional capacity available for
background population, up to the size of the mounted cache filesystem.
The host filesystem is deliberately separate from application logic. Enlarge or
shrink its preallocated image during infrastructure maintenance, then set the
matching `CACHE_MAX_BYTES` value in Coolify.
## Playback locking
+3 -3
View File
@@ -20,10 +20,10 @@ services:
SERIES_COOLDOWN_SECONDS: ${SERIES_COOLDOWN_SECONDS:-3600}
POLL_SECONDS: ${POLL_SECONDS:-5}
RESCAN_SECONDS: ${RESCAN_SECONDS:-300}
H264_CRF: ${H264_CRF:-20}
H264_CRF: ${H264_CRF:-18}
H264_PRESET: ${H264_PRESET:-medium}
H264_MAXRATE: ${H264_MAXRATE:-12M}
H264_BUFSIZE: ${H264_BUFSIZE:-24M}
H264_MAXRATE: ${H264_MAXRATE:-15M}
H264_BUFSIZE: ${H264_BUFSIZE:-30M}
volumes:
- ${MEDIA_LIBRARY_PATH:?}:/source:ro
- ${PRETRANSCODE_VIEW_PATH:?}:/view
+100 -18
View File
@@ -31,13 +31,15 @@ JELLYFIN_LIBRARY_ROOT = os.environ.get("JELLYFIN_LIBRARY_ROOT", "/data/library")
JELLYFIN_ORIGINAL_ROOT = os.environ.get("JELLYFIN_ORIGINAL_ROOT", "/data/original").rstrip("/")
JELLYFIN_CACHE_ROOT = os.environ.get("JELLYFIN_CACHE_ROOT", "/data/cache").rstrip("/")
JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "")
CACHE_MAX_BYTES = env_int("CACHE_MAX_BYTES", 1_000_000_000_000)
SERIES_COOLDOWN_SECONDS = env_int("SERIES_COOLDOWN_SECONDS", 3600)
POLL_SECONDS = env_int("POLL_SECONDS", 5)
RESCAN_SECONDS = env_int("RESCAN_SECONDS", 300)
H264_CRF = os.environ.get("H264_CRF", "20")
H264_CRF = os.environ.get("H264_CRF", "18")
H264_PRESET = os.environ.get("H264_PRESET", "medium")
H264_MAXRATE = os.environ.get("H264_MAXRATE", "12M")
H264_BUFSIZE = os.environ.get("H264_BUFSIZE", "24M")
H264_MAXRATE = os.environ.get("H264_MAXRATE", "15M")
H264_BUFSIZE = os.environ.get("H264_BUFSIZE", "30M")
ENCODE_PROFILE = f"h264-high-8bit-crf{H264_CRF}-{H264_PRESET}-{H264_MAXRATE}-quality-v3"
FFMPEG = os.environ.get("FFMPEG", "/usr/bin/ffmpeg")
FFPROBE = os.environ.get("FFPROBE", "/usr/bin/ffprobe")
@@ -224,7 +226,10 @@ class Manager:
return False
manifest = load_json(manifest_path, {})
try:
return manifest.get("source_fingerprint") == fingerprint(source)
return (
manifest.get("source_fingerprint") == fingerprint(source)
and manifest.get("encode_profile") == ENCODE_PROFILE
)
except OSError:
return False
@@ -291,7 +296,7 @@ class Manager:
def probe(self, path):
command = [
FFPROBE, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=codec_name,bits_per_raw_sample,pix_fmt,profile",
"-show_entries", "stream=codec_name,bits_per_raw_sample,pix_fmt,profile,color_transfer,color_primaries,color_space",
"-show_entries", "format=duration", "-of", "json", str(path),
]
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
@@ -317,6 +322,9 @@ class Manager:
"codec": (stream.get("codec_name") or "").lower(),
"bit_depth": depth,
"profile": profile,
"color_transfer": (stream.get("color_transfer") or "").lower(),
"color_primaries": (stream.get("color_primaries") or "").lower(),
"color_space": (stream.get("color_space") or "").lower(),
"duration": float((data.get("format") or {}).get("duration") or 0),
}
@@ -349,6 +357,9 @@ class Manager:
"codec": (video.get("Codec") or "").lower(),
"bit_depth": int(video.get("BitDepth") or 8),
"profile": (video.get("Profile") or "").lower(),
"color_transfer": (video.get("ColorTransfer") or "").lower(),
"color_primaries": (video.get("ColorPrimaries") or "").lower(),
"color_space": (video.get("ColorSpace") or "").lower(),
"duration": float(item.get("RunTimeTicks") or 0) / 10_000_000,
}
start += len(items)
@@ -378,8 +389,10 @@ class Manager:
continue
old = inventory.get(relative) or {}
if old.get("fingerprint") == current_fingerprint:
if relative in catalog and catalog[relative].get("item_id"):
old["item_id"] = catalog[relative]["item_id"]
if relative in catalog:
for key in ("item_id", "color_transfer", "color_primaries", "color_space"):
if catalog[relative].get(key):
old[key] = catalog[relative][key]
continue
details = catalog.get(relative)
view = VIEW_ROOT / relative
@@ -458,6 +471,9 @@ class Manager:
profile = (info.get("profile") or "").lower()
return codec in BLACKLISTED_CODECS or (codec == "h264" and (depth > 8 or "10" in profile))
def is_hdr(self, info):
return (info.get("color_transfer") or "").lower() in {"smpte2084", "arib-std-b67"}
def candidate_list(self):
now = time.time()
values = []
@@ -485,8 +501,8 @@ class Manager:
entries.append((output.stat().st_mtime, relative, output, manifest_path))
return sorted(entries)
def remove_cache(self, relative):
if self.is_locked(relative):
def remove_cache(self, relative, force=False):
if not force and self.is_locked(relative):
return False
output, manifest = self.cache_paths(relative)
link = VIEW_ROOT / relative
@@ -501,9 +517,40 @@ class Manager:
manifest.unlink(missing_ok=True)
return True
def cache_usage(self):
return sum(
path.stat().st_size
for path in (CACHE_ROOT / "files").iterdir()
if path.is_file()
)
def cache_available(self):
stat = os.statvfs(CACHE_ROOT)
filesystem_free = stat.f_bavail * stat.f_frsize
configured_free = max(0, CACHE_MAX_BYTES - self.cache_usage())
return min(filesystem_free, configured_free)
def purge_incompatible_cache_profiles(self):
for _, relative, _, _ in self.cache_entries():
if self.cache_valid(relative) or self.is_locked(relative):
continue
log(f"Dropping obsolete cache rendition for {relative}")
self.remove_cache(relative)
def enforce_cache_limit(self):
usage = self.cache_usage()
for _, relative, output, manifest in self.cache_entries():
if usage <= CACHE_MAX_BYTES:
break
if self.is_locked(relative):
continue
size = output.stat().st_size + manifest.stat().st_size
log(f"Evicting {relative} after cache resize")
if self.remove_cache(relative):
usage -= size
def ensure_space(self, required, may_evict):
free = os.statvfs(CACHE_ROOT).f_bavail * os.statvfs(CACHE_ROOT).f_frsize
if free >= required:
if self.cache_available() >= required:
return True
if not may_evict:
return False
@@ -512,8 +559,7 @@ class Manager:
continue
log(f"Evicting {relative}")
self.remove_cache(relative)
free = os.statvfs(CACHE_ROOT).f_bavail * os.statvfs(CACHE_ROOT).f_frsize
if free >= required:
if self.cache_available() >= required:
return True
return False
@@ -531,15 +577,36 @@ class Manager:
pass
self.process = None
def ffmpeg_command(self, source, partial):
def ffmpeg_command(self, source, partial, info):
hdr = self.is_hdr(info)
if hdr:
transfer = (info.get("color_transfer") or "").lower()
if transfer == "arib-std-b67":
input_colors = "tin=arib-std-b67:pin=bt2020:min=bt2020nc:"
else:
input_colors = ""
video_filter = (
f"zscale={input_colors}t=linear:npl=100,format=gbrpf32le,"
"zscale=p=bt709,tonemap=tonemap=hable:desat=0,"
"zscale=t=bt709:m=bt709:r=tv:dither=error_diffusion,format=yuv420p"
)
else:
video_filter = "zscale=dither=error_diffusion,format=yuv420p"
common = [
FFMPEG, "-nostdin", "-hide_banner", "-loglevel", "warning", "-i", str(source),
"-map", "0:v:0", "-map", "0:a?", "-map", "0:s?", "-map_metadata", "0", "-map_chapters", "0",
"-vf", video_filter,
"-c:v", "libx264", "-preset", H264_PRESET, "-crf", H264_CRF,
"-maxrate", H264_MAXRATE, "-bufsize", H264_BUFSIZE,
"-pix_fmt", "yuv420p", "-profile:v", "high", "-level:v", "4.1",
"-pix_fmt", "yuv420p", "-profile:v", "high",
"-c:a", "copy", "-c:s", "copy",
]
if hdr:
common += [
"-color_primaries", "bt709", "-color_trc", "bt709",
"-colorspace", "bt709", "-color_range", "tv",
]
if partial.name.endswith(".mp4.partial"):
common += ["-movflags", "+faststart", "-f", "mp4"]
else:
@@ -570,7 +637,7 @@ class Manager:
return "full"
log(f"Encoding {relative}")
command = self.ffmpeg_command(source, partial)
command = self.ffmpeg_command(source, partial, info)
log_path = STATE_ROOT / "ffmpeg.log"
with log_path.open("ab", buffering=0) as ffmpeg_log:
self.process = subprocess.Popen(
@@ -609,6 +676,7 @@ class Manager:
"relative": relative,
"source_fingerprint": fingerprint(source),
"created_at": time.time(),
"encode_profile": ENCODE_PROFILE,
})
if self.is_locked(relative):
output.unlink(missing_ok=True)
@@ -625,6 +693,8 @@ class Manager:
if not JELLYFIN_API_KEY:
raise RuntimeError("JELLYFIN_API_KEY is required")
self.read_sessions()
self.purge_incompatible_cache_profiles()
self.enforce_cache_limit()
self.scan_inventory()
self.sync_view()
self.save()
@@ -632,6 +702,8 @@ class Manager:
if not self.read_sessions():
time.sleep(POLL_SECONDS)
continue
self.purge_incompatible_cache_profiles()
self.enforce_cache_limit()
now = time.time()
if now - self.last_scan >= RESCAN_SECONDS:
changed = self.scan_inventory()
@@ -651,8 +723,7 @@ class Manager:
for rank, _, relative in candidates:
source = SOURCE_ROOT / relative
estimate = min(max(int(source.stat().st_size * 2.2), 1_000_000_000), 100_000_000_000)
free = os.statvfs(CACHE_ROOT).f_bavail * os.statvfs(CACHE_ROOT).f_frsize
if rank == 0 or free >= estimate:
if rank == 0 or self.cache_available() >= estimate:
selected = (rank == 0, relative)
break
if not selected:
@@ -681,6 +752,15 @@ class Manager:
return True
return bool(self.active_items)
def purge(self, force=False):
if not force and self.active():
raise RuntimeError("cache purge refused while media is playing")
for _, relative, _, _ in self.cache_entries():
log(f"Purging {relative}")
self.remove_cache(relative, force=force)
self.sync_view()
self.save()
def trim(self, target_bytes):
if self.active():
raise RuntimeError("cache resize refused while media is playing")
@@ -711,6 +791,8 @@ def main():
raise SystemExit(0 if not manager.active() else 1)
elif command == "trim" and len(sys.argv) == 3:
manager.trim(int(sys.argv[2]))
elif command == "purge":
manager.purge(force="--force" in sys.argv[2:])
else:
raise SystemExit(f"unknown command: {command}")
+14 -4
View File
@@ -14,17 +14,27 @@ docker run --rm --entrypoint ffmpeg -v "$ROOT/source:/source" "$IMAGE" \
-hide_banner -loglevel error -f lavfi -i testsrc2=size=1280x720:rate=24 \
-t 6 -c:v libx265 -pix_fmt yuv420p10le -an /source/movies/Test/Test.mkv
docker run --rm --entrypoint ffmpeg -v "$ROOT/source:/source" "$IMAGE" \
-hide_banner -loglevel error -f lavfi -i testsrc2=size=1280x720:rate=24 \
-t 6 -c:v libx265 -pix_fmt yuv420p10le \
-color_primaries bt2020 -color_trc smpte2084 -colorspace bt2020nc \
-x265-params 'colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc' \
-an /source/movies/Test/Test.HDR10.mkv
docker run --rm --network "$NETWORK" --entrypoint python3 \
-e SOURCE_ROOT=/source -e VIEW_ROOT=/view -e CACHE_ROOT=/cache -e STATE_ROOT=/state \
-e JELLYFIN_URL="$JELLYFIN_URL" -e JELLYFIN_API_KEY \
-e JELLYFIN_LIBRARY_ROOT=/data/library -e JELLYFIN_ORIGINAL_ROOT=/data/original \
-e JELLYFIN_CACHE_ROOT=/data/cache \
-v "$ROOT/source:/source:ro" -v "$ROOT/view:/view" -v "$ROOT/cache:/cache" -v "$ROOT/state:/state" \
"$IMAGE" -c 'import importlib.machinery; m=importlib.machinery.SourceFileLoader("p","/app/pretranscode").load_module(); x=m.Manager(); assert x.read_sessions(); x.scan_inventory(); x.sync_view(); assert x.encode("movies/Test/Test.mkv",False)=="completed"'
"$IMAGE" -c 'import importlib.machinery; m=importlib.machinery.SourceFileLoader("p","/app/pretranscode").load_module(); x=m.Manager(); assert x.read_sessions(); x.scan_inventory(); x.sync_view(); assert x.encode("movies/Test/Test.mkv",False)=="completed"; assert x.encode("movies/Test/Test.HDR10.mkv",False)=="completed"'
OUTPUT=$(find "$ROOT/cache/files" -name '*.mkv' ! -name '*.partial' | head -1)
SDR_TARGET=$(readlink "$ROOT/view/movies/Test/Test.mkv")
HDR_TARGET=$(readlink "$ROOT/view/movies/Test/Test.HDR10.mkv")
docker run --rm --entrypoint ffprobe -v "$ROOT/cache:/cache:ro" "$IMAGE" \
-v error -select_streams v:0 -show_entries stream=codec_name,pix_fmt \
-of default=nw=1 "/cache/files/$(basename "$OUTPUT")" | grep -qx 'codec_name=h264'
test "$(readlink "$ROOT/view/movies/Test/Test.mkv")" = "/data/cache/files/$(basename "$OUTPUT")"
-of default=nw=1 "${SDR_TARGET/\/data\/cache/\/cache}" | grep -qx 'codec_name=h264'
docker run --rm --entrypoint ffprobe -v "$ROOT/cache:/cache:ro" "$IMAGE" \
-v error -select_streams v:0 -show_entries stream=color_transfer,color_primaries,color_space \
-of default=nw=1 "${HDR_TARGET/\/data\/cache/\/cache}" | grep -qx 'color_transfer=bt709'
echo "integration test passed"
+40 -1
View File
@@ -50,6 +50,17 @@ class CoreTests(unittest.TestCase):
self.assertFalse(manager.incompatible({"codec": "h264", "bit_depth": 8}))
self.assertFalse(manager.incompatible({"codec": "av1", "bit_depth": 10}))
def test_quality_filters_are_global(self):
manager = self.module.Manager()
sdr = manager.ffmpeg_command(Path("show.mkv"), Path("out.mkv.partial"), {})
hdr = manager.ffmpeg_command(
Path("show.HDR10.mkv"), Path("out.mkv.partial"), {"color_transfer": "smpte2084"}
)
self.assertIn("zscale=dither=error_diffusion,format=yuv420p", sdr)
self.assertIn("18", sdr)
self.assertTrue(any("tonemap=tonemap=hable" in value for value in hdr))
self.assertIn("bt709", hdr)
def test_cache_requires_matching_source_fingerprint(self):
m = self.module
manager = m.Manager()
@@ -59,11 +70,39 @@ class CoreTests(unittest.TestCase):
source.write_bytes(b"original")
output, manifest = manager.cache_paths(relative)
output.write_bytes(b"cached")
manifest.write_text(json.dumps({"relative": relative, "source_fingerprint": m.fingerprint(source)}))
manifest.write_text(json.dumps({
"relative": relative,
"source_fingerprint": m.fingerprint(source),
"encode_profile": m.ENCODE_PROFILE,
}))
self.assertTrue(manager.cache_valid(relative))
source.write_bytes(b"changed")
self.assertFalse(manager.cache_valid(relative))
def test_smaller_cache_limit_evicts_oldest_content(self):
m = self.module
manager = m.Manager()
relative = "movies/Resize/Resize.mkv"
source = self.root / "source" / relative
source.parent.mkdir(parents=True, exist_ok=True)
source.write_bytes(b"original")
output, manifest = manager.cache_paths(relative)
output.write_bytes(b"cached rendition")
manifest.write_text(json.dumps({
"relative": relative,
"source_fingerprint": m.fingerprint(source),
"encode_profile": m.ENCODE_PROFILE,
}))
manager.ensure_link(relative, m.cache_target(relative))
old_limit = m.CACHE_MAX_BYTES
try:
m.CACHE_MAX_BYTES = 1
manager.enforce_cache_limit()
finally:
m.CACHE_MAX_BYTES = old_limit
self.assertFalse(output.exists())
self.assertEqual(os.readlink(self.root / "view" / relative), m.source_target(relative))
if __name__ == "__main__":
unittest.main()