Replace stateful cache with atomic C controller
This commit is contained in:
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
view = os.environ["TEST_VIEW"]
|
||||
state_path = os.environ["TEST_STATE"]
|
||||
codec = "hevc"
|
||||
depth = 10
|
||||
refresh_attempts = 0
|
||||
|
||||
|
||||
def record_state():
|
||||
with open(state_path, "w") as handle:
|
||||
json.dump({"codec": codec, "depth": depth, "attempts": refresh_attempts}, handle)
|
||||
|
||||
|
||||
def refresh_media():
|
||||
global codec, depth
|
||||
link = os.path.join(view, "movies/Test/Test.mkv")
|
||||
try:
|
||||
target = os.readlink(link)
|
||||
except OSError:
|
||||
target = ""
|
||||
codec, depth = ("h264", 8) if target.startswith("/data/cache/") else ("hevc", 10)
|
||||
record_state()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def send_json(self, value, status=200):
|
||||
body = json.dumps(value, separators=(",", ":")).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
path = urlparse(self.path).path
|
||||
if path == "/Sessions":
|
||||
self.send_json([])
|
||||
elif path == "/Items/test-item":
|
||||
self.send_json({"Id": "test-item", "Path": "/data/library/movies/Test/Test.mkv"})
|
||||
elif path == "/Items":
|
||||
self.send_json({
|
||||
"Items": [{
|
||||
"Id": "test-item",
|
||||
"Path": "/data/library/movies/Test/Test.mkv",
|
||||
"RunTimeTicks": 6 * 10_000_000,
|
||||
"MediaStreams": [{
|
||||
"Type": "Video", "Codec": codec, "BitDepth": depth,
|
||||
"Profile": "Main 10" if depth == 10 else "High",
|
||||
"Width": 1280, "Height": 720,
|
||||
}],
|
||||
}],
|
||||
"TotalRecordCount": 1,
|
||||
})
|
||||
else:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
|
||||
def do_POST(self):
|
||||
global refresh_attempts
|
||||
path = urlparse(self.path).path
|
||||
if path.startswith("/Items/test-item/Refresh"):
|
||||
refresh_attempts += 1
|
||||
if refresh_attempts == 1:
|
||||
record_state()
|
||||
self.send_response(503)
|
||||
self.end_headers()
|
||||
return
|
||||
refresh_media()
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
elif path == "/Library/Refresh":
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
else:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
|
||||
record_state()
|
||||
server = ThreadingHTTPServer(("0.0.0.0", int(sys.argv[1])), Handler)
|
||||
server.serve_forever()
|
||||
+128
-29
@@ -1,40 +1,139 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
: "${JELLYFIN_API_KEY:?}"
|
||||
IMAGE=${IMAGE:-pretranscode:test}
|
||||
NETWORK=${MEDIA_NETWORK:-media-internal}
|
||||
JELLYFIN_URL=${JELLYFIN_URL:-http://jellyfin:8096}
|
||||
IMAGE=${IMAGE:-localhost/pretranscode-c-review}
|
||||
ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$ROOT"' EXIT
|
||||
mkdir -p "$ROOT"/{source/movies/Test,view,cache,state}
|
||||
PORT=$(python3 - <<'PY'
|
||||
import socket
|
||||
s=socket.socket(); s.bind(('127.0.0.1', 0)); print(s.getsockname()[1]); s.close()
|
||||
PY
|
||||
)
|
||||
PID=
|
||||
CONTAINER=
|
||||
cleanup() {
|
||||
[ -z "$CONTAINER" ] || podman rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
[ -z "$PID" ] || kill "$PID" >/dev/null 2>&1 || true
|
||||
rm -rf "$ROOT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
mkdir -p "$ROOT"/{source/movies/Test,view,cache/files,state}
|
||||
touch "$ROOT/cache/.pretranscode-cache"
|
||||
printf partial >"$ROOT/cache/files/failed.partial"
|
||||
printf orphan >"$ROOT/cache/files/orphan.bin"
|
||||
|
||||
docker build -q -t "$IMAGE" . >/dev/null
|
||||
docker run --rm --entrypoint ffmpeg -v "$ROOT/source:/source" "$IMAGE" \
|
||||
cat >"$ROOT/source/movies/Test/embedded.srt" <<'EOF'
|
||||
1
|
||||
00:00:00,000 --> 00:00:05,000
|
||||
Compatibility test
|
||||
EOF
|
||||
|
||||
podman run --rm --userns=keep-id --entrypoint ffmpeg -v "$ROOT/source:/source:Z" "$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
|
||||
-f lavfi -i sine=frequency=440:sample_rate=48000 \
|
||||
-f srt -i /source/movies/Test/embedded.srt -t 6 \
|
||||
-map 0:v:0 -map 1:a:0 -map 2:s:0 -c:v libx265 -pix_fmt yuv420p10le \
|
||||
-c:a eac3 -c:s srt /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 \
|
||||
TEST_VIEW="$ROOT/view" TEST_STATE="$ROOT/jellyfin-state.json" \
|
||||
python3 "$(dirname "$0")/fake_jellyfin.py" "$PORT" &
|
||||
PID=$!
|
||||
sleep 0.2
|
||||
CONTAINER=pretranscode-c-integration-$RANDOM
|
||||
podman run -d --userns=keep-id --name "$CONTAINER" \
|
||||
--add-host host.containers.internal:host-gateway \
|
||||
-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_URL="http://host.containers.internal:$PORT" -e JELLYFIN_API_KEY=test \
|
||||
-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"; assert x.encode("movies/Test/Test.HDR10.mkv",False)=="completed"'
|
||||
-e JELLYFIN_CACHE_ROOT=/data/cache -e POLL_SECONDS=1 -e RESCAN_SECONDS=1 \
|
||||
-e H264_PRESET=ultrafast \
|
||||
-v "$ROOT/source:/source:ro,Z" -v "$ROOT/view:/view:Z" \
|
||||
-v "$ROOT/cache:/cache:Z" -v "$ROOT/state:/state:Z" "$IMAGE" >/dev/null
|
||||
|
||||
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 "${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'
|
||||
for _ in $(seq 1 90); do
|
||||
LINK=$(readlink "$ROOT/view/movies/Test/Test.mkv" 2>/dev/null || true)
|
||||
case "$LINK" in
|
||||
/data/cache/files/*) break ;;
|
||||
esac
|
||||
if [ "$(podman inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" != true ]; then
|
||||
podman logs "$CONTAINER" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
LINK=$(readlink "$ROOT/view/movies/Test/Test.mkv" 2>/dev/null || true)
|
||||
if [[ "$LINK" != /data/cache/files/* ]]; then
|
||||
podman logs "$CONTAINER" >&2 || true
|
||||
cat "$ROOT/state/ffmpeg.log" >&2 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
[[ "$LINK" == /data/cache/files/* ]]
|
||||
BASENAME=${LINK##*/}
|
||||
[[ "$BASENAME" =~ ^[0-9a-f]{32}\.[0-9a-f]{32}\.[0-9a-f]{32}\.[0-9a-f]{32}\.(mkv|mp4)$ ]]
|
||||
OUTPUT="$ROOT/cache/${LINK#/data/cache/}"
|
||||
test -s "$OUTPUT"
|
||||
! find "$ROOT/cache/files" -type f \( -name '*.json' -o -name '*.partial' \) | grep -q .
|
||||
test ! -e "$ROOT/cache/files/orphan.bin"
|
||||
podman run --rm --userns=keep-id --entrypoint ffprobe -v "$ROOT/cache:/cache:ro,Z" "$IMAGE" \
|
||||
-v error -show_entries stream=codec_type,codec_name,pix_fmt,channels:stream_side_data=side_data_type \
|
||||
-show_entries chapter=id -of json "/cache/${LINK#/data/cache/}" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin); streams=d.get("streams",[])
|
||||
video=[s for s in streams if s.get("codec_type")=="video"]
|
||||
audio=[s for s in streams if s.get("codec_type")=="audio"]
|
||||
subs=[s for s in streams if s.get("codec_type")=="subtitle"]
|
||||
assert len(video)==1 and video[0].get("codec_name")=="h264" and video[0].get("pix_fmt")=="yuv420p"
|
||||
assert len(audio)==1 and audio[0].get("codec_name")=="aac" and audio[0].get("channels")==2
|
||||
assert not subs and not d.get("chapters")
|
||||
assert not any("dovi" in x.get("side_data_type","").lower() for x in video[0].get("side_data_list",[]))
|
||||
'
|
||||
python3 - "$ROOT/jellyfin-state.json" <<'PY'
|
||||
import json, sys
|
||||
state=json.load(open(sys.argv[1]))
|
||||
assert state["codec"] == "h264" and state["depth"] == 8
|
||||
assert state["attempts"] >= 2
|
||||
PY
|
||||
|
||||
# A profile change, including a published 1.x object, keeps the old object live
|
||||
# until its replacement exists.
|
||||
podman stop -t 10 "$CONTAINER" >/dev/null
|
||||
podman rm "$CONTAINER" >/dev/null
|
||||
CONTAINER=
|
||||
mv "$OUTPUT" "$ROOT/cache/files/legacy.mkv"
|
||||
ln -sfn /data/cache/files/legacy.mkv "$ROOT/view/movies/Test/Test.mkv"
|
||||
OLD_LINK=/data/cache/files/legacy.mkv
|
||||
podman run --rm --userns=keep-id \
|
||||
--add-host host.containers.internal:host-gateway \
|
||||
-e SOURCE_ROOT=/source -e VIEW_ROOT=/view -e CACHE_ROOT=/cache -e STATE_ROOT=/state \
|
||||
-e JELLYFIN_URL="http://host.containers.internal:$PORT" -e JELLYFIN_API_KEY=test \
|
||||
-e JELLYFIN_LIBRARY_ROOT=/data/library -e JELLYFIN_ORIGINAL_ROOT=/data/original \
|
||||
-e JELLYFIN_CACHE_ROOT=/data/cache -e POLL_SECONDS=1 -e RESCAN_SECONDS=1 \
|
||||
-e H264_PRESET=ultrafast -e H264_CRF=19 \
|
||||
-v "$ROOT/source:/source:ro,Z" -v "$ROOT/view:/view:Z" \
|
||||
-v "$ROOT/cache:/cache:Z" -v "$ROOT/state:/state:Z" "$IMAGE" sync
|
||||
[[ "$(readlink "$ROOT/view/movies/Test/Test.mkv")" == "$OLD_LINK" ]]
|
||||
test -s "$ROOT/cache/${OLD_LINK#/data/cache/}"
|
||||
|
||||
CONTAINER=pretranscode-c-integration-$RANDOM
|
||||
podman run -d --userns=keep-id --name "$CONTAINER" \
|
||||
--add-host host.containers.internal:host-gateway \
|
||||
-e SOURCE_ROOT=/source -e VIEW_ROOT=/view -e CACHE_ROOT=/cache -e STATE_ROOT=/state \
|
||||
-e JELLYFIN_URL="http://host.containers.internal:$PORT" -e JELLYFIN_API_KEY=test \
|
||||
-e JELLYFIN_LIBRARY_ROOT=/data/library -e JELLYFIN_ORIGINAL_ROOT=/data/original \
|
||||
-e JELLYFIN_CACHE_ROOT=/data/cache -e POLL_SECONDS=1 -e RESCAN_SECONDS=1 \
|
||||
-e H264_PRESET=ultrafast -e H264_CRF=19 \
|
||||
-v "$ROOT/source:/source:ro,Z" -v "$ROOT/view:/view:Z" \
|
||||
-v "$ROOT/cache:/cache:Z" -v "$ROOT/state:/state:Z" "$IMAGE" >/dev/null
|
||||
for _ in $(seq 1 90); do
|
||||
LINK=$(readlink "$ROOT/view/movies/Test/Test.mkv" 2>/dev/null || true)
|
||||
[[ "$LINK" == /data/cache/files/* && "$LINK" != "$OLD_LINK" ]] && break
|
||||
sleep 1
|
||||
done
|
||||
[[ "$LINK" == /data/cache/files/* && "$LINK" != "$OLD_LINK" ]]
|
||||
for _ in $(seq 1 10); do
|
||||
test ! -e "$ROOT/cache/${OLD_LINK#/data/cache/}" && break
|
||||
sleep 1
|
||||
done
|
||||
test ! -e "$ROOT/cache/${OLD_LINK#/data/cache/}"
|
||||
podman stop -t 10 "$CONTAINER" >/dev/null
|
||||
podman rm "$CONTAINER" >/dev/null
|
||||
CONTAINER=
|
||||
echo "integration test passed"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
#define PRETRANSCODE_TEST
|
||||
#include "../pretranscode.c"
|
||||
|
||||
#include <assert.h>
|
||||
|
||||
static void write_file(const char *path, const char *value)
|
||||
{
|
||||
char *parent = xstrdup(path), *slash = strrchr(parent, '/');
|
||||
assert(slash); *slash = 0; assert(mkdirs(parent) == 0); free(parent);
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
|
||||
assert(fd >= 0); assert(write(fd, value, strlen(value)) == (ssize_t)strlen(value));
|
||||
assert(close(fd) == 0);
|
||||
}
|
||||
|
||||
static void remove_tree(const char *root)
|
||||
{
|
||||
char *roots[] = {(char *)root, NULL};
|
||||
FTS *tree = fts_open(roots, FTS_PHYSICAL | FTS_NOCHDIR, NULL); assert(tree);
|
||||
FTSENT *entry;
|
||||
while ((entry = fts_read(tree))) {
|
||||
if (entry->fts_info == FTS_DP) assert(rmdir(entry->fts_path) == 0);
|
||||
else if (entry->fts_info != FTS_D) assert(unlink(entry->fts_path) == 0);
|
||||
}
|
||||
fts_close(tree);
|
||||
}
|
||||
|
||||
static void test_hashes(void)
|
||||
{
|
||||
char a[33], b[33];
|
||||
path_hash("tv/Test/one.mkv", a); path_hash("tv/Test/one.mkv", b);
|
||||
assert(strcmp(a, b) == 0); path_hash("tv/Test/two.mkv", b); assert(strcmp(a, b));
|
||||
struct stat st = {.st_dev = 1, .st_ino = 2, .st_size = 3};
|
||||
st.st_mtim.tv_sec = 4; st.st_mtim.tv_nsec = 5;
|
||||
generation_hash(&st, a); st.st_ino++; generation_hash(&st, b); assert(strcmp(a, b));
|
||||
|
||||
char path[] = "/tmp/pretranscode-hash-XXXXXX";
|
||||
int fd = mkstemp(path); assert(fd >= 0);
|
||||
const char value[] = "immutable output";
|
||||
assert(write(fd, value, sizeof(value) - 1) == (ssize_t)(sizeof(value) - 1)); close(fd);
|
||||
assert(file_hash(path, a) == 0); hash_bytes(value, sizeof(value) - 1, b);
|
||||
assert(strcmp(a, b) == 0); assert(unlink(path) == 0);
|
||||
}
|
||||
|
||||
static void test_atomic_link_keeps_open_inode(void)
|
||||
{
|
||||
char root[] = "/tmp/pretranscode-link-XXXXXX"; assert(mkdtemp(root));
|
||||
char *view = xprintf("%s/view/file.mkv", root);
|
||||
char *original = xprintf("%s/original", root), *cached = xprintf("%s/cached", root);
|
||||
write_file(original, "original"); write_file(cached, "cached");
|
||||
assert(atomic_link(view, original) == 1);
|
||||
int old = open(view, O_RDONLY | O_CLOEXEC); assert(old >= 0);
|
||||
assert(atomic_link(view, cached) == 1);
|
||||
char data[16] = {0}; assert(read(old, data, sizeof(data)) == 8); assert(!strcmp(data, "original")); close(old);
|
||||
memset(data, 0, sizeof(data)); int current = open(view, O_RDONLY | O_CLOEXEC); assert(current >= 0);
|
||||
assert(read(current, data, sizeof(data)) == 6); assert(!strcmp(data, "cached")); close(current);
|
||||
free(view); free(original); free(cached); remove_tree(root);
|
||||
}
|
||||
|
||||
static void test_cache_name(void)
|
||||
{
|
||||
const char *hash = "00112233445566778899aabbccddeeff";
|
||||
char *name = xprintf("%s.%s.%s.%s.mkv", hash, hash, hash, hash);
|
||||
struct cache cache = {0}; assert(parse_cache_name(name, &cache)); cache.named = true;
|
||||
assert(!strcmp(cache.path_hash, hash)); assert(!strcmp(cache.extension, ".mkv"));
|
||||
struct item item = {.extension = ".mkv"};
|
||||
memcpy(item.path_hash, hash, HASH_HEX + 1); memcpy(item.generation_hash, hash, HASH_HEX + 1);
|
||||
assert(cache_matches(&cache, &item));
|
||||
name[2] = 'X'; assert(!parse_cache_name(name, &cache)); free(name);
|
||||
}
|
||||
|
||||
static void test_lru_does_not_change_media_mtime(void)
|
||||
{
|
||||
char root[] = "/tmp/pretranscode-atime-XXXXXX"; assert(mkdtemp(root));
|
||||
char *view_root = xprintf("%s/view", root), *media = xprintf("%s/media.mkv", root);
|
||||
char *view = xprintf("%s/movie.mkv", view_root); write_file(media, "cached");
|
||||
assert(atomic_link(view, "/cache/files/media.mkv") == 1);
|
||||
struct timespec old[2] = {{.tv_sec = 100}, {.tv_sec = 100}};
|
||||
assert(utimensat(AT_FDCWD, media, old, 0) == 0);
|
||||
struct manager manager = {.cfg.view_root = view_root};
|
||||
struct cache cache = {.path = media, .target = "/cache/files/media.mkv"};
|
||||
manager.caches.v = &cache; manager.caches.n = 1;
|
||||
touch_target(&manager, "movie.mkv");
|
||||
struct stat st; assert(stat(media, &st) == 0);
|
||||
assert(st.st_mtim.tv_sec == 100); assert(st.st_atim.tv_sec > 100);
|
||||
manager.caches.v = NULL; manager.caches.n = 0;
|
||||
free(view_root); free(media); free(view); remove_tree(root);
|
||||
}
|
||||
|
||||
static void test_old_profile_is_replacement_candidate(void)
|
||||
{
|
||||
const char *hash = "00112233445566778899aabbccddeeff";
|
||||
struct cache old = {.named = true, .target = "/cache/files/old.mkv"};
|
||||
memcpy(old.path_hash, hash, HASH_HEX + 1); memcpy(old.generation_hash, hash, HASH_HEX + 1);
|
||||
memcpy(old.extension, ".mkv", 5); memset(old.profile_hash, '1', HASH_HEX); old.profile_hash[HASH_HEX] = 0;
|
||||
struct item item = {.relative = "movie.mkv", .extension = ".mkv", .fallback = &old, .mtime = 1};
|
||||
memcpy(item.path_hash, hash, HASH_HEX + 1); memcpy(item.generation_hash, hash, HASH_HEX + 1);
|
||||
struct manager manager = {0}; manager.items.v = &item; manager.items.n = 1;
|
||||
memset(manager.cfg.profile_hash, '2', HASH_HEX); manager.cfg.profile_hash[HASH_HEX] = 0;
|
||||
assert(valid_cache(&manager, &item) == NULL);
|
||||
assert(candidate(&manager) == &item);
|
||||
}
|
||||
|
||||
static void test_single_writer_lock(void)
|
||||
{
|
||||
char root[] = "/tmp/pretranscode-lock-XXXXXX";
|
||||
assert(mkdtemp(root));
|
||||
int first = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
|
||||
int second = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC);
|
||||
assert(first >= 0 && second >= 0);
|
||||
assert(flock(first, LOCK_EX | LOCK_NB) == 0);
|
||||
assert(flock(second, LOCK_EX | LOCK_NB) < 0 && (errno == EWOULDBLOCK || errno == EAGAIN));
|
||||
close(second); close(first); assert(rmdir(root) == 0);
|
||||
}
|
||||
|
||||
static void test_scheduling(void)
|
||||
{
|
||||
int pipes[2]; assert(pipe(pipes) == 0); pid_t pid = fork(); assert(pid >= 0);
|
||||
if (!pid) {
|
||||
close(pipes[0]); background_or_exit();
|
||||
int values[2] = {sched_getscheduler(0),
|
||||
IOPRIO_PRIO_CLASS((int)syscall(SYS_ioprio_get, IOPRIO_WHO_PROCESS, 0))};
|
||||
if (write(pipes[1], values, sizeof(values)) != (ssize_t)sizeof(values))
|
||||
_exit(1);
|
||||
_exit(0);
|
||||
}
|
||||
close(pipes[1]); int values[2]; assert(read(pipes[0], values, sizeof(values)) == (ssize_t)sizeof(values));
|
||||
close(pipes[0]); assert(waitpid(pid, NULL, 0) == pid);
|
||||
assert(values[0] == SCHED_IDLE); assert(values[1] == IOPRIO_CLASS_IDLE);
|
||||
}
|
||||
|
||||
static void test_output_validation(void)
|
||||
{
|
||||
struct media source = {.codec = "hevc", .depth = 10, .width = 1920, .height = 1080,
|
||||
.audio = 2, .subtitles = 3, .attachments = 1, .chapters = 12, .duration = 1200};
|
||||
struct media output = {.codec = "h264", .depth = 8, .width = 1920, .height = 1080,
|
||||
.audio = 1, .audio_compatible = true, .duration = 1200};
|
||||
assert(valid_output(&source, &output));
|
||||
output.dolby_vision = true; assert(!valid_output(&source, &output)); output.dolby_vision = false;
|
||||
output.subtitles = 1; assert(!valid_output(&source, &output)); output.subtitles = 0;
|
||||
output.audio_compatible = false; assert(!valid_output(&source, &output));
|
||||
}
|
||||
|
||||
static void test_canonical_policy(void)
|
||||
{
|
||||
struct media media = {.codec = "h264", .depth = 8, .audio = 1, .audio_compatible = true};
|
||||
assert(!incompatible(&media));
|
||||
snprintf(media.codec, sizeof(media.codec), "av1"); assert(incompatible(&media));
|
||||
snprintf(media.codec, sizeof(media.codec), "h264"); media.audio_compatible = false; assert(incompatible(&media));
|
||||
media.audio_compatible = true; media.subtitles = 1; assert(incompatible(&media));
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_hashes(); test_atomic_link_keeps_open_inode(); test_cache_name();
|
||||
test_lru_does_not_change_media_mtime(); test_old_profile_is_replacement_candidate();
|
||||
test_single_writer_lock(); test_scheduling();
|
||||
test_output_validation(); test_canonical_policy();
|
||||
puts("all core tests passed"); return 0;
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
|
||||
class CoreTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temp = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temp.name)
|
||||
for name in ("source", "view", "cache", "state"):
|
||||
(root / name).mkdir()
|
||||
(root / "cache" / ".pretranscode-cache").touch()
|
||||
os.environ.update({
|
||||
"SOURCE_ROOT": str(root / "source"),
|
||||
"VIEW_ROOT": str(root / "view"),
|
||||
"CACHE_ROOT": str(root / "cache"),
|
||||
"STATE_ROOT": str(root / "state"),
|
||||
"JELLYFIN_LIBRARY_ROOT": "/library",
|
||||
"JELLYFIN_ORIGINAL_ROOT": "/original",
|
||||
"JELLYFIN_CACHE_ROOT": "/cached",
|
||||
})
|
||||
spec = importlib.util.spec_from_file_location("pretranscode", Path(__file__).parents[1] / "pretranscode.py")
|
||||
cls.module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(cls.module)
|
||||
cls.root = root
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temp.cleanup()
|
||||
|
||||
def test_paths_are_configurable(self):
|
||||
m = self.module
|
||||
self.assertEqual(m.relative_source("/library/tv/Show/episode.mkv"), "tv/Show/episode.mkv")
|
||||
self.assertEqual(m.source_target("tv/Show/episode.mkv"), "/original/tv/Show/episode.mkv")
|
||||
self.assertTrue(m.cache_target("tv/Show/episode.mkv").startswith("/cached/files/"))
|
||||
|
||||
def test_series_lock_key(self):
|
||||
m = self.module
|
||||
self.assertEqual(m.item_key("tv/Show/Season 1/Episode.mkv"), "series:Show")
|
||||
self.assertEqual(m.item_key("movies/Movie/file.mkv"), "item:movies/Movie/file.mkv")
|
||||
|
||||
def test_fixed_codec_blacklist(self):
|
||||
manager = self.module.Manager()
|
||||
self.assertTrue(manager.incompatible({"codec": "hevc", "bit_depth": 10}))
|
||||
self.assertTrue(manager.incompatible({"codec": "h264", "bit_depth": 10}))
|
||||
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.assertTrue(any("zscale=dither=error_diffusion,format=yuv420p" in value for value in sdr))
|
||||
self.assertIn("-filter_complex", sdr)
|
||||
self.assertIn("[video]", sdr)
|
||||
self.assertNotIn("-vf", sdr)
|
||||
self.assertIn("18", sdr)
|
||||
self.assertTrue(any("tonemap=tonemap=hable" in value for value in hdr))
|
||||
self.assertIn("bt709", hdr)
|
||||
|
||||
def test_probe_detects_dolby_vision_stream_metadata(self):
|
||||
manager = self.module.Manager()
|
||||
result = SimpleNamespace(
|
||||
returncode=0,
|
||||
stderr="",
|
||||
stdout=json.dumps({
|
||||
"streams": [{
|
||||
"codec_name": "h264",
|
||||
"profile": "High",
|
||||
"pix_fmt": "yuv420p",
|
||||
"bits_per_raw_sample": "8",
|
||||
"color_transfer": "bt709",
|
||||
"side_data_list": [{"side_data_type": "DOVI configuration record"}],
|
||||
}],
|
||||
"format": {"duration": "10.0"},
|
||||
}),
|
||||
)
|
||||
with mock.patch.object(self.module.subprocess, "run", return_value=result):
|
||||
details = manager.probe(Path("cached.mkv"))
|
||||
self.assertTrue(details["dolby_vision"])
|
||||
|
||||
def test_validation_rejects_inherited_dolby_vision_metadata(self):
|
||||
manager = self.module.Manager()
|
||||
output = self.root / "cache" / "invalid.mkv"
|
||||
output.write_bytes(b"x" * 1_000_001)
|
||||
details = {"codec": "h264", "bit_depth": 8, "dolby_vision": True, "duration": 10.0}
|
||||
with mock.patch.object(manager, "probe", return_value=details):
|
||||
with self.assertRaisesRegex(RuntimeError, "Dolby Vision"):
|
||||
manager.validate_output(Path("source.mkv"), output, 10.0)
|
||||
|
||||
def test_cache_requires_matching_source_fingerprint(self):
|
||||
m = self.module
|
||||
manager = m.Manager()
|
||||
relative = "movies/Test/Test.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")
|
||||
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_lru_touches_only_manifest(self):
|
||||
m = self.module
|
||||
manager = m.Manager()
|
||||
relative = "movies/LRU/LRU.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")
|
||||
manifest.write_text(json.dumps({
|
||||
"relative": relative,
|
||||
"source_fingerprint": m.fingerprint(source),
|
||||
"encode_profile": m.ENCODE_PROFILE,
|
||||
}))
|
||||
os.utime(output, (100, 100))
|
||||
os.utime(manifest, (100, 100))
|
||||
manager.touch_cache(relative)
|
||||
self.assertEqual(output.stat().st_mtime, 100)
|
||||
self.assertGreater(manifest.stat().st_mtime, 100)
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user