Initial portable pretranscode service
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
# Host paths
|
||||||
|
MEDIA_LIBRARY_PATH=/srv/media/library
|
||||||
|
PRETRANSCODE_ROOT=/srv/pretranscode
|
||||||
|
PRETRANSCODE_VIEW_PATH=/srv/pretranscode/view
|
||||||
|
PRETRANSCODE_CACHE_PATH=/srv/pretranscode/cache
|
||||||
|
PRETRANSCODE_STATE_PATH=/srv/pretranscode/state
|
||||||
|
PRETRANSCODE_BUILD_CONTEXT=/srv/pretranscode/repository
|
||||||
|
|
||||||
|
# Cache allocation (decimal bytes)
|
||||||
|
CACHE_MAX_BYTES=1000000000000
|
||||||
|
PRETRANSCODE_CACHE_IMAGE=/srv/pretranscode/cache.ext4
|
||||||
|
|
||||||
|
# Container identity and network
|
||||||
|
PRETRANSCODE_UID=1000
|
||||||
|
PRETRANSCODE_GID=1000
|
||||||
|
PRETRANSCODE_IMAGE=pretranscode:latest
|
||||||
|
MEDIA_NETWORK=media-internal
|
||||||
|
|
||||||
|
# Jellyfin API and paths visible inside Jellyfin
|
||||||
|
JELLYFIN_URL=http://jellyfin:8096
|
||||||
|
JELLYFIN_API_KEY=replace-me
|
||||||
|
JELLYFIN_LIBRARY_ROOT=/data/library
|
||||||
|
JELLYFIN_ORIGINAL_ROOT=/data/original
|
||||||
|
JELLYFIN_CACHE_ROOT=/data/cache
|
||||||
|
|
||||||
|
# Worker behavior
|
||||||
|
SERIES_COOLDOWN_SECONDS=3600
|
||||||
|
POLL_SECONDS=5
|
||||||
|
RESCAN_SECONDS=300
|
||||||
|
H264_CRF=20
|
||||||
|
H264_PRESET=medium
|
||||||
|
H264_MAXRATE=12M
|
||||||
|
H264_BUFSIZE=24M
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg procps python3 util-linux \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY pretranscode.py /app/pretranscode
|
||||||
|
RUN chmod 0755 /app/pretranscode
|
||||||
|
|
||||||
|
ENTRYPOINT ["nice","-n","19","ionice","-c","3","python3","/app/pretranscode"]
|
||||||
|
CMD ["run"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 SpiderFish
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Pretranscode
|
||||||
|
|
||||||
|
A small Jellyfin compatibility cache. It converts only known-incompatible video
|
||||||
|
codecs to uniform 8-bit H.264 while preserving immutable originals.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
- Originals are mounted read-only at `/source`.
|
||||||
|
- Jellyfin reads a mirror tree at `/data/library` containing symlinks.
|
||||||
|
- Each symlink points either to `/data/original` or a validated file under
|
||||||
|
`/data/cache`.
|
||||||
|
- Completed cache files replace symlinks atomically. Partial files are never
|
||||||
|
exposed.
|
||||||
|
- Cache eviction restores the original symlink before deleting cached content.
|
||||||
|
- Jellyfin integration uses only its supported HTTP API.
|
||||||
|
|
||||||
|
There is one worker process. x264 uses available CPU cores; `nice 19`, idle I/O
|
||||||
|
priority, and minimal Docker CPU shares make normal services take precedence.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Linux, Docker Compose, and ext4 tools
|
||||||
|
- An existing Jellyfin server and API key
|
||||||
|
- A shared Docker network between Jellyfin and Pretranscode
|
||||||
|
- Jellyfin must have read-only access to the originals and cache
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` and set all host paths and the Jellyfin API key.
|
||||||
|
2. Provision the claimed cache allocation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo scripts/provision-cache.sh .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Mount the same paths in Jellyfin while keeping its existing logical library
|
||||||
|
path:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ${PRETRANSCODE_VIEW_PATH}:/data/library:ro
|
||||||
|
- ${MEDIA_LIBRARY_PATH}:/data/original:ro
|
||||||
|
- ${PRETRANSCODE_CACHE_PATH}:/data/cache:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Start Pretranscode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
For Coolify, import `compose.yaml` as a Custom Docker Compose resource and enter
|
||||||
|
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.
|
||||||
|
|
||||||
|
The fixed source blacklist is:
|
||||||
|
|
||||||
|
- HEVC/H.265, including Dolby Vision
|
||||||
|
- H.264 Hi10/10-bit
|
||||||
|
- MPEG-2
|
||||||
|
- VC-1
|
||||||
|
|
||||||
|
Client error reports never modify this list.
|
||||||
|
|
||||||
|
## Playback locking
|
||||||
|
|
||||||
|
- An active movie locks that movie.
|
||||||
|
- An active episode locks its complete series.
|
||||||
|
- Encoding is cancelled and its partial deleted if the item becomes locked.
|
||||||
|
- A series remains locked for `SERIES_COOLDOWN_SECONDS` after playback.
|
||||||
|
- Recently stopped content receives queue priority.
|
||||||
|
|
||||||
|
## Failure behavior
|
||||||
|
|
||||||
|
Graceful stops delete partial output. After a crash, startup removes stale
|
||||||
|
partials and orphan files. Atomic symlinks always point to either an original or
|
||||||
|
a completed cache file. If the cache mount marker is absent, the worker refuses
|
||||||
|
to start.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Copy or recreate these resources on the destination:
|
||||||
|
|
||||||
|
- Repository and `.env`
|
||||||
|
- State directory
|
||||||
|
- Symlink-view directory
|
||||||
|
- Cache image/mount, if retaining completed transcodes
|
||||||
|
|
||||||
|
The cache is optional and can be rebuilt. The source fingerprint in every
|
||||||
|
manifest prevents stale cache files from replacing changed originals. No
|
||||||
|
Coolify UUID, Docker volume UUID, container name, or Jellyfin database path is
|
||||||
|
stored by the application.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
services:
|
||||||
|
pretranscode:
|
||||||
|
build:
|
||||||
|
context: ${PRETRANSCODE_BUILD_CONTEXT:-.}
|
||||||
|
image: ${PRETRANSCODE_IMAGE:-pretranscode:latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
user: "${PRETRANSCODE_UID:-1000}:${PRETRANSCODE_GID:-1000}"
|
||||||
|
environment:
|
||||||
|
SOURCE_ROOT: /source
|
||||||
|
VIEW_ROOT: /view
|
||||||
|
CACHE_ROOT: /cache
|
||||||
|
STATE_ROOT: /state
|
||||||
|
JELLYFIN_URL: ${JELLYFIN_URL:?}
|
||||||
|
JELLYFIN_API_KEY: ${JELLYFIN_API_KEY:?}
|
||||||
|
JELLYFIN_LIBRARY_ROOT: ${JELLYFIN_LIBRARY_ROOT:-/data/library}
|
||||||
|
JELLYFIN_ORIGINAL_ROOT: ${JELLYFIN_ORIGINAL_ROOT:-/data/original}
|
||||||
|
JELLYFIN_CACHE_ROOT: ${JELLYFIN_CACHE_ROOT:-/data/cache}
|
||||||
|
CACHE_MAX_BYTES: ${CACHE_MAX_BYTES:-1000000000000}
|
||||||
|
SERIES_COOLDOWN_SECONDS: ${SERIES_COOLDOWN_SECONDS:-3600}
|
||||||
|
POLL_SECONDS: ${POLL_SECONDS:-5}
|
||||||
|
RESCAN_SECONDS: ${RESCAN_SECONDS:-300}
|
||||||
|
H264_CRF: ${H264_CRF:-20}
|
||||||
|
H264_PRESET: ${H264_PRESET:-medium}
|
||||||
|
H264_MAXRATE: ${H264_MAXRATE:-12M}
|
||||||
|
H264_BUFSIZE: ${H264_BUFSIZE:-24M}
|
||||||
|
volumes:
|
||||||
|
- ${MEDIA_LIBRARY_PATH:?}:/source:ro
|
||||||
|
- ${PRETRANSCODE_VIEW_PATH:?}:/view
|
||||||
|
- ${PRETRANSCODE_CACHE_PATH:?}:/cache
|
||||||
|
- ${PRETRANSCODE_STATE_PATH:?}:/state
|
||||||
|
networks:
|
||||||
|
- media
|
||||||
|
cpu_shares: 2
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
stop_grace_period: 30s
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pgrep -f '/app/pretranscode run' >/dev/null"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
media:
|
||||||
|
external: true
|
||||||
|
name: ${MEDIA_NETWORK:-media-internal}
|
||||||
Executable
+719
@@ -0,0 +1,719 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Immutable Jellyfin H.264 cache backed by an atomic symlink view."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
VIDEO_EXTENSIONS = {".mkv", ".mp4", ".m4v", ".avi", ".mov", ".ts", ".m2ts", ".webm"}
|
||||||
|
BLACKLISTED_CODECS = {"hevc", "h265", "mpeg2video", "vc1", "vc-1"}
|
||||||
|
|
||||||
|
|
||||||
|
def env_int(name, default):
|
||||||
|
return int(os.environ.get(name, default))
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE_ROOT = Path(os.environ.get("SOURCE_ROOT", "/source"))
|
||||||
|
VIEW_ROOT = Path(os.environ.get("VIEW_ROOT", "/view"))
|
||||||
|
CACHE_ROOT = Path(os.environ.get("CACHE_ROOT", "/cache"))
|
||||||
|
STATE_ROOT = Path(os.environ.get("STATE_ROOT", "/state"))
|
||||||
|
STATE_FILE = STATE_ROOT / "state.json"
|
||||||
|
JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "http://jellyfin:8096").rstrip("/")
|
||||||
|
JELLYFIN_LIBRARY_ROOT = os.environ.get("JELLYFIN_LIBRARY_ROOT", "/data/library").rstrip("/")
|
||||||
|
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", "")
|
||||||
|
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_PRESET = os.environ.get("H264_PRESET", "medium")
|
||||||
|
H264_MAXRATE = os.environ.get("H264_MAXRATE", "12M")
|
||||||
|
H264_BUFSIZE = os.environ.get("H264_BUFSIZE", "24M")
|
||||||
|
FFMPEG = os.environ.get("FFMPEG", "/usr/bin/ffmpeg")
|
||||||
|
FFPROBE = os.environ.get("FFPROBE", "/usr/bin/ffprobe")
|
||||||
|
|
||||||
|
|
||||||
|
def log(message):
|
||||||
|
print(time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), message, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_json(path, value):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(path.name + ".tmp")
|
||||||
|
with temporary.open("w") as handle:
|
||||||
|
json.dump(value, handle, sort_keys=True, separators=(",", ":"))
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary, path)
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(path, default):
|
||||||
|
try:
|
||||||
|
with path.open() as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(path):
|
||||||
|
stat = path.stat()
|
||||||
|
return f"{stat.st_size}:{stat.st_mtime_ns}"
|
||||||
|
|
||||||
|
|
||||||
|
def relative_source(path):
|
||||||
|
value = str(path)
|
||||||
|
for root in (JELLYFIN_LIBRARY_ROOT, str(SOURCE_ROOT)):
|
||||||
|
prefix = root.rstrip("/") + "/"
|
||||||
|
if value.startswith(prefix):
|
||||||
|
return value[len(prefix):]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def item_key(relative):
|
||||||
|
parts = PurePosixPath(relative).parts
|
||||||
|
if len(parts) >= 2 and parts[0].lower() == "tv":
|
||||||
|
return "series:" + parts[1]
|
||||||
|
return "item:" + relative
|
||||||
|
|
||||||
|
|
||||||
|
def cache_basename(relative):
|
||||||
|
digest = hashlib.sha256(relative.encode()).hexdigest()
|
||||||
|
suffix = ".mp4" if Path(relative).suffix.lower() in {".mp4", ".m4v"} else ".mkv"
|
||||||
|
return digest + suffix
|
||||||
|
|
||||||
|
|
||||||
|
def source_target(relative):
|
||||||
|
return JELLYFIN_ORIGINAL_ROOT + "/" + PurePosixPath(relative).as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def cache_target(relative):
|
||||||
|
return JELLYFIN_CACHE_ROOT + "/files/" + cache_basename(relative)
|
||||||
|
|
||||||
|
|
||||||
|
class Manager:
|
||||||
|
def __init__(self):
|
||||||
|
STATE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not (CACHE_ROOT / ".pretranscode-cache").is_file():
|
||||||
|
raise RuntimeError(f"cache filesystem is not mounted at {CACHE_ROOT}")
|
||||||
|
files_root = CACHE_ROOT / "files"
|
||||||
|
files_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
for partial in files_root.glob("*.partial"):
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
for output in list(files_root.glob("*.mkv")) + list(files_root.glob("*.mp4")):
|
||||||
|
if not output.with_suffix(output.suffix + ".json").is_file():
|
||||||
|
output.unlink(missing_ok=True)
|
||||||
|
for manifest in files_root.glob("*.json"):
|
||||||
|
if not manifest.with_suffix("").is_file():
|
||||||
|
manifest.unlink(missing_ok=True)
|
||||||
|
VIEW_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.state = load_json(STATE_FILE, {})
|
||||||
|
self.state.setdefault("inventory", {})
|
||||||
|
self.state.setdefault("priorities", {})
|
||||||
|
self.state.setdefault("cooldowns", {})
|
||||||
|
self.state.setdefault("retry_after", {})
|
||||||
|
self.active_items = set()
|
||||||
|
self.active_keys = set()
|
||||||
|
self.previous_active_items = set()
|
||||||
|
self.previous_active_keys = set()
|
||||||
|
self.sessions_healthy = False
|
||||||
|
self.running = True
|
||||||
|
self.process = None
|
||||||
|
self.last_scan = 0
|
||||||
|
self.last_activity_save = 0
|
||||||
|
signal.signal(signal.SIGTERM, self.stop)
|
||||||
|
signal.signal(signal.SIGINT, self.stop)
|
||||||
|
|
||||||
|
def stop(self, *_):
|
||||||
|
self.running = False
|
||||||
|
self.cancel_process("service stopping")
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
atomic_json(STATE_FILE, self.state)
|
||||||
|
|
||||||
|
def api(self, path, method="GET"):
|
||||||
|
request = urllib.request.Request(
|
||||||
|
JELLYFIN_URL + path,
|
||||||
|
method=method,
|
||||||
|
headers={"X-Emby-Token": JELLYFIN_API_KEY, "Content-Length": "0"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=10) as response:
|
||||||
|
data = response.read()
|
||||||
|
return json.loads(data) if data else None
|
||||||
|
|
||||||
|
def read_sessions(self):
|
||||||
|
try:
|
||||||
|
sessions = self.api("/Sessions")
|
||||||
|
except Exception as error:
|
||||||
|
if self.sessions_healthy:
|
||||||
|
log(f"Jellyfin sessions unavailable; stopping cache work: {error}")
|
||||||
|
self.sessions_healthy = False
|
||||||
|
self.active_items = set()
|
||||||
|
self.active_keys = set()
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
current_items = set()
|
||||||
|
current_keys = set()
|
||||||
|
for session in sessions or []:
|
||||||
|
item = session.get("NowPlayingItem") or {}
|
||||||
|
path = item.get("Path")
|
||||||
|
relative = relative_source(path) if path else None
|
||||||
|
if not relative and item.get("Id"):
|
||||||
|
try:
|
||||||
|
detail = self.api("/Items/" + item["Id"] + "?Fields=Path") or {}
|
||||||
|
relative = relative_source(detail.get("Path", ""))
|
||||||
|
except Exception:
|
||||||
|
relative = None
|
||||||
|
if not relative:
|
||||||
|
continue
|
||||||
|
current_items.add(relative)
|
||||||
|
current_keys.add(item_key(relative))
|
||||||
|
self.touch_cache(relative)
|
||||||
|
|
||||||
|
ended_items = self.previous_active_items - current_items
|
||||||
|
ended_keys = self.previous_active_keys - current_keys
|
||||||
|
changed = current_items != self.previous_active_items or current_keys != self.previous_active_keys
|
||||||
|
for relative in current_items | ended_items:
|
||||||
|
self.state["priorities"][relative] = now
|
||||||
|
for key in current_keys | ended_keys:
|
||||||
|
if key.startswith("series:"):
|
||||||
|
self.state["cooldowns"][key] = now + SERIES_COOLDOWN_SECONDS
|
||||||
|
if changed or (current_items and now - self.last_activity_save >= 60):
|
||||||
|
self.save()
|
||||||
|
self.last_activity_save = now
|
||||||
|
|
||||||
|
self.previous_active_items = current_items
|
||||||
|
self.previous_active_keys = current_keys
|
||||||
|
self.active_items = current_items
|
||||||
|
self.active_keys = current_keys
|
||||||
|
self.sessions_healthy = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def locked_keys(self):
|
||||||
|
now = time.time()
|
||||||
|
expired = [key for key, until in self.state["cooldowns"].items() if float(until) <= now]
|
||||||
|
if expired:
|
||||||
|
for key in expired:
|
||||||
|
self.state["cooldowns"].pop(key, None)
|
||||||
|
self.save()
|
||||||
|
return self.active_keys | {
|
||||||
|
key for key, until in self.state["cooldowns"].items() if float(until) > now
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_locked(self, relative):
|
||||||
|
return item_key(relative) in self.locked_keys()
|
||||||
|
|
||||||
|
def cache_paths(self, relative):
|
||||||
|
output = CACHE_ROOT / "files" / cache_basename(relative)
|
||||||
|
manifest = output.with_suffix(output.suffix + ".json")
|
||||||
|
return output, manifest
|
||||||
|
|
||||||
|
def cache_valid(self, relative):
|
||||||
|
source = SOURCE_ROOT / relative
|
||||||
|
output, manifest_path = self.cache_paths(relative)
|
||||||
|
if not source.is_file() or not output.is_file():
|
||||||
|
return False
|
||||||
|
manifest = load_json(manifest_path, {})
|
||||||
|
try:
|
||||||
|
return manifest.get("source_fingerprint") == fingerprint(source)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def touch_cache(self, relative):
|
||||||
|
if not self.cache_valid(relative):
|
||||||
|
return
|
||||||
|
output, manifest = self.cache_paths(relative)
|
||||||
|
now = time.time()
|
||||||
|
for path in (output, manifest):
|
||||||
|
try:
|
||||||
|
os.utime(path, (now, now), follow_symlinks=False)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def ensure_link(self, relative, target, locked=False):
|
||||||
|
link = VIEW_ROOT / relative
|
||||||
|
link.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
if link.is_symlink() and os.readlink(link) == target:
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if locked and (link.exists() or link.is_symlink()):
|
||||||
|
return False
|
||||||
|
temporary = link.with_name(link.name + ".pretranscode-tmp")
|
||||||
|
try:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
os.symlink(target, temporary)
|
||||||
|
os.replace(temporary, link)
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def swap_to_original(self, relative):
|
||||||
|
return self.ensure_link(relative, source_target(relative), locked=self.is_locked(relative))
|
||||||
|
|
||||||
|
def swap_to_cache(self, relative):
|
||||||
|
if self.is_locked(relative) or not self.cache_valid(relative):
|
||||||
|
return False
|
||||||
|
output, manifest_path = self.cache_paths(relative)
|
||||||
|
manifest = load_json(manifest_path, {})
|
||||||
|
changed = self.ensure_link(relative, cache_target(relative), locked=False)
|
||||||
|
if changed or manifest.get("refresh_version") != 2:
|
||||||
|
self.refresh_item(relative)
|
||||||
|
manifest["refresh_version"] = 2
|
||||||
|
atomic_json(manifest_path, manifest)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def refresh_item(self, relative):
|
||||||
|
item_id = (self.state["inventory"].get(relative) or {}).get("item_id")
|
||||||
|
if not item_id:
|
||||||
|
return
|
||||||
|
query = urllib.parse.urlencode({
|
||||||
|
"metadataRefreshMode": "FullRefresh",
|
||||||
|
"imageRefreshMode": "None",
|
||||||
|
"replaceAllMetadata": "false",
|
||||||
|
"replaceAllImages": "false",
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
self.api(f"/Items/{item_id}/Refresh?{query}", method="POST")
|
||||||
|
except Exception as error:
|
||||||
|
log(f"Unable to refresh Jellyfin item {relative}: {error}")
|
||||||
|
|
||||||
|
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", "format=duration", "-of", "json", str(path),
|
||||||
|
]
|
||||||
|
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(result.stderr.strip() or "ffprobe failed")
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
stream = (data.get("streams") or [{}])[0]
|
||||||
|
raw_depth = stream.get("bits_per_raw_sample")
|
||||||
|
try:
|
||||||
|
depth = int(raw_depth or 0)
|
||||||
|
except ValueError:
|
||||||
|
depth = 0
|
||||||
|
pixel = (stream.get("pix_fmt") or "").lower()
|
||||||
|
profile = (stream.get("profile") or "").lower()
|
||||||
|
if not depth:
|
||||||
|
if "12" in pixel or "12" in profile:
|
||||||
|
depth = 12
|
||||||
|
elif "10" in pixel or "10" in profile:
|
||||||
|
depth = 10
|
||||||
|
else:
|
||||||
|
depth = 8
|
||||||
|
return {
|
||||||
|
"codec": (stream.get("codec_name") or "").lower(),
|
||||||
|
"bit_depth": depth,
|
||||||
|
"profile": profile,
|
||||||
|
"duration": float((data.get("format") or {}).get("duration") or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
def jellyfin_items(self):
|
||||||
|
values = {}
|
||||||
|
start = 0
|
||||||
|
limit = 1000
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
query = urllib.parse.urlencode({
|
||||||
|
"Recursive": "true",
|
||||||
|
"IncludeItemTypes": "Movie,Episode",
|
||||||
|
"Fields": "Path,MediaStreams",
|
||||||
|
"StartIndex": start,
|
||||||
|
"Limit": limit,
|
||||||
|
})
|
||||||
|
page = self.api("/Items?" + query) or {}
|
||||||
|
items = page.get("Items") or []
|
||||||
|
for item in items:
|
||||||
|
relative = relative_source(item.get("Path", ""))
|
||||||
|
if not relative:
|
||||||
|
continue
|
||||||
|
video = next(
|
||||||
|
(stream for stream in item.get("MediaStreams", [])
|
||||||
|
if (stream.get("Type") or "").lower() == "video"),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
values[relative] = {
|
||||||
|
"item_id": item.get("Id"),
|
||||||
|
"codec": (video.get("Codec") or "").lower(),
|
||||||
|
"bit_depth": int(video.get("BitDepth") or 8),
|
||||||
|
"profile": (video.get("Profile") or "").lower(),
|
||||||
|
"duration": float(item.get("RunTimeTicks") or 0) / 10_000_000,
|
||||||
|
}
|
||||||
|
start += len(items)
|
||||||
|
if not items or start >= int(page.get("TotalRecordCount") or start):
|
||||||
|
break
|
||||||
|
except Exception as error:
|
||||||
|
log(f"Unable to read Jellyfin inventory: {error}")
|
||||||
|
return values
|
||||||
|
|
||||||
|
def scan_inventory(self):
|
||||||
|
catalog = self.jellyfin_items()
|
||||||
|
inventory = self.state["inventory"]
|
||||||
|
seen = set()
|
||||||
|
added = False
|
||||||
|
for root, directories, files in os.walk(SOURCE_ROOT):
|
||||||
|
directories.sort()
|
||||||
|
files.sort()
|
||||||
|
for filename in files:
|
||||||
|
if Path(filename).suffix.lower() not in VIDEO_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
source = Path(root) / filename
|
||||||
|
relative = source.relative_to(SOURCE_ROOT).as_posix()
|
||||||
|
seen.add(relative)
|
||||||
|
try:
|
||||||
|
current_fingerprint = fingerprint(source)
|
||||||
|
except OSError:
|
||||||
|
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"]
|
||||||
|
continue
|
||||||
|
details = catalog.get(relative)
|
||||||
|
view = VIEW_ROOT / relative
|
||||||
|
view_uses_cache = view.is_symlink() and os.readlink(view) == cache_target(relative)
|
||||||
|
if details and not old and not view_uses_cache:
|
||||||
|
details = dict(details)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
details = self.probe(source)
|
||||||
|
except Exception as error:
|
||||||
|
log(f"Probe failed for {relative}: {error}")
|
||||||
|
self.state["retry_after"][relative] = time.time() + 21600
|
||||||
|
continue
|
||||||
|
if relative in catalog:
|
||||||
|
details["item_id"] = catalog[relative].get("item_id")
|
||||||
|
details["fingerprint"] = current_fingerprint
|
||||||
|
details["size"] = source.stat().st_size
|
||||||
|
details["mtime"] = source.stat().st_mtime
|
||||||
|
inventory[relative] = details
|
||||||
|
self.state["retry_after"].pop(relative, None)
|
||||||
|
added = True
|
||||||
|
|
||||||
|
removed = set(inventory) - seen
|
||||||
|
for relative in removed:
|
||||||
|
if self.is_locked(relative):
|
||||||
|
continue
|
||||||
|
self.remove_cache(relative)
|
||||||
|
inventory.pop(relative, None)
|
||||||
|
self.state["priorities"].pop(relative, None)
|
||||||
|
self.state["retry_after"].pop(relative, None)
|
||||||
|
added = True
|
||||||
|
if added:
|
||||||
|
self.save()
|
||||||
|
return added
|
||||||
|
|
||||||
|
def sync_view(self):
|
||||||
|
changed = False
|
||||||
|
source_files = set()
|
||||||
|
for root, directories, files in os.walk(SOURCE_ROOT):
|
||||||
|
relative_root = Path(root).relative_to(SOURCE_ROOT)
|
||||||
|
(VIEW_ROOT / relative_root).mkdir(parents=True, exist_ok=True)
|
||||||
|
directories.sort()
|
||||||
|
files.sort()
|
||||||
|
for filename in files:
|
||||||
|
relative = (relative_root / filename).as_posix()
|
||||||
|
source_files.add(relative)
|
||||||
|
is_video = Path(filename).suffix.lower() in VIDEO_EXTENSIONS
|
||||||
|
valid_cache = is_video and self.cache_valid(relative)
|
||||||
|
if valid_cache and not self.is_locked(relative):
|
||||||
|
changed |= self.swap_to_cache(relative)
|
||||||
|
else:
|
||||||
|
changed |= self.ensure_link(
|
||||||
|
relative, source_target(relative), locked=is_video and self.is_locked(relative)
|
||||||
|
)
|
||||||
|
|
||||||
|
for root, directories, files in os.walk(VIEW_ROOT, topdown=False):
|
||||||
|
for filename in files:
|
||||||
|
path = Path(root) / filename
|
||||||
|
relative = path.relative_to(VIEW_ROOT).as_posix()
|
||||||
|
if relative.endswith(".pretranscode-tmp"):
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
elif relative not in source_files and path.is_symlink() and not self.is_locked(relative):
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
changed = True
|
||||||
|
for directory in directories:
|
||||||
|
path = Path(root) / directory
|
||||||
|
try:
|
||||||
|
path.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def incompatible(self, info):
|
||||||
|
codec = (info.get("codec") or "").lower()
|
||||||
|
depth = int(info.get("bit_depth") or 8)
|
||||||
|
profile = (info.get("profile") or "").lower()
|
||||||
|
return codec in BLACKLISTED_CODECS or (codec == "h264" and (depth > 8 or "10" in profile))
|
||||||
|
|
||||||
|
def candidate_list(self):
|
||||||
|
now = time.time()
|
||||||
|
values = []
|
||||||
|
for relative, info in self.state["inventory"].items():
|
||||||
|
if not self.incompatible(info) or self.cache_valid(relative) or self.is_locked(relative):
|
||||||
|
continue
|
||||||
|
if float(self.state["retry_after"].get(relative, 0)) > now:
|
||||||
|
continue
|
||||||
|
priority = self.state["priorities"].get(relative)
|
||||||
|
values.append((0 if priority is not None else 1, -float(priority or info.get("mtime", 0)), relative))
|
||||||
|
values.sort()
|
||||||
|
return values
|
||||||
|
|
||||||
|
def cache_entries(self):
|
||||||
|
entries = []
|
||||||
|
for manifest_path in (CACHE_ROOT / "files").glob("*.json"):
|
||||||
|
manifest = load_json(manifest_path, {})
|
||||||
|
relative = manifest.get("relative")
|
||||||
|
if not relative:
|
||||||
|
continue
|
||||||
|
output = manifest_path.with_suffix("")
|
||||||
|
if not output.is_file():
|
||||||
|
manifest_path.unlink(missing_ok=True)
|
||||||
|
continue
|
||||||
|
entries.append((output.stat().st_mtime, relative, output, manifest_path))
|
||||||
|
return sorted(entries)
|
||||||
|
|
||||||
|
def remove_cache(self, relative):
|
||||||
|
if self.is_locked(relative):
|
||||||
|
return False
|
||||||
|
output, manifest = self.cache_paths(relative)
|
||||||
|
link = VIEW_ROOT / relative
|
||||||
|
if link.is_symlink():
|
||||||
|
try:
|
||||||
|
if os.readlink(link) == cache_target(relative):
|
||||||
|
self.ensure_link(relative, source_target(relative), locked=False)
|
||||||
|
self.refresh_item(relative)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
output.unlink(missing_ok=True)
|
||||||
|
manifest.unlink(missing_ok=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def ensure_space(self, required, may_evict):
|
||||||
|
free = os.statvfs(CACHE_ROOT).f_bavail * os.statvfs(CACHE_ROOT).f_frsize
|
||||||
|
if free >= required:
|
||||||
|
return True
|
||||||
|
if not may_evict:
|
||||||
|
return False
|
||||||
|
for _, relative, _, _ in self.cache_entries():
|
||||||
|
if self.is_locked(relative):
|
||||||
|
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:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cancel_process(self, reason):
|
||||||
|
if not self.process or self.process.poll() is not None:
|
||||||
|
return
|
||||||
|
log(f"Stopping encode: {reason}")
|
||||||
|
try:
|
||||||
|
os.killpg(self.process.pid, signal.SIGTERM)
|
||||||
|
self.process.wait(timeout=10)
|
||||||
|
except (ProcessLookupError, subprocess.TimeoutExpired):
|
||||||
|
try:
|
||||||
|
os.killpg(self.process.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
self.process = None
|
||||||
|
|
||||||
|
def ffmpeg_command(self, source, partial):
|
||||||
|
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",
|
||||||
|
"-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",
|
||||||
|
"-c:a", "copy", "-c:s", "copy",
|
||||||
|
]
|
||||||
|
if partial.name.endswith(".mp4.partial"):
|
||||||
|
common += ["-movflags", "+faststart", "-f", "mp4"]
|
||||||
|
else:
|
||||||
|
common += ["-map", "0:t?", "-c:t", "copy", "-f", "matroska"]
|
||||||
|
return common + [str(partial)]
|
||||||
|
|
||||||
|
def validate_output(self, source, output, expected_duration):
|
||||||
|
details = self.probe(output)
|
||||||
|
if details.get("codec") != "h264" or int(details.get("bit_depth") or 0) > 8:
|
||||||
|
raise RuntimeError("output is not 8-bit H.264")
|
||||||
|
source_duration = float(expected_duration or 0)
|
||||||
|
output_duration = float(details.get("duration") or 0)
|
||||||
|
if source_duration and abs(source_duration - output_duration) > max(3, source_duration * 0.01):
|
||||||
|
raise RuntimeError(f"duration mismatch: source={source_duration}, output={output_duration}")
|
||||||
|
if output.stat().st_size < 1_000_000:
|
||||||
|
raise RuntimeError("output is unexpectedly small")
|
||||||
|
|
||||||
|
def encode(self, relative, priority):
|
||||||
|
source = SOURCE_ROOT / relative
|
||||||
|
if not source.is_file() or self.is_locked(relative):
|
||||||
|
return "skipped"
|
||||||
|
output, manifest_path = self.cache_paths(relative)
|
||||||
|
partial = output.with_name(output.name + ".partial")
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
info = self.state["inventory"].get(relative) or {}
|
||||||
|
estimate = min(max(int(source.stat().st_size * 2.2), 1_000_000_000), 100_000_000_000)
|
||||||
|
if not self.ensure_space(estimate, may_evict=priority):
|
||||||
|
return "full"
|
||||||
|
|
||||||
|
log(f"Encoding {relative}")
|
||||||
|
command = self.ffmpeg_command(source, partial)
|
||||||
|
log_path = STATE_ROOT / "ffmpeg.log"
|
||||||
|
with log_path.open("ab", buffering=0) as ffmpeg_log:
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
command, stdout=ffmpeg_log, stderr=ffmpeg_log, start_new_session=True
|
||||||
|
)
|
||||||
|
last_mirror = 0
|
||||||
|
while self.running and self.process.poll() is None:
|
||||||
|
time.sleep(POLL_SECONDS)
|
||||||
|
if not self.read_sessions() or self.is_locked(relative):
|
||||||
|
self.cancel_process("item or series is being watched")
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
return "cancelled"
|
||||||
|
if time.time() - last_mirror >= 60:
|
||||||
|
if self.sync_view():
|
||||||
|
try:
|
||||||
|
self.api("/Library/Refresh", method="POST")
|
||||||
|
except Exception as error:
|
||||||
|
log(f"Unable to request library refresh: {error}")
|
||||||
|
last_mirror = time.time()
|
||||||
|
returncode = self.process.wait() if self.process else -1
|
||||||
|
self.process = None
|
||||||
|
|
||||||
|
if not self.running:
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
return "cancelled"
|
||||||
|
if returncode != 0:
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
raise RuntimeError(f"ffmpeg exited with status {returncode}")
|
||||||
|
if self.is_locked(relative):
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
return "cancelled"
|
||||||
|
|
||||||
|
self.validate_output(source, partial, info.get("duration"))
|
||||||
|
os.replace(partial, output)
|
||||||
|
atomic_json(manifest_path, {
|
||||||
|
"relative": relative,
|
||||||
|
"source_fingerprint": fingerprint(source),
|
||||||
|
"created_at": time.time(),
|
||||||
|
})
|
||||||
|
if self.is_locked(relative):
|
||||||
|
output.unlink(missing_ok=True)
|
||||||
|
manifest_path.unlink(missing_ok=True)
|
||||||
|
return "cancelled"
|
||||||
|
self.swap_to_cache(relative)
|
||||||
|
self.state["priorities"].pop(relative, None)
|
||||||
|
self.state["retry_after"].pop(relative, None)
|
||||||
|
self.save()
|
||||||
|
log(f"Completed {relative}")
|
||||||
|
return "completed"
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
if not JELLYFIN_API_KEY:
|
||||||
|
raise RuntimeError("JELLYFIN_API_KEY is required")
|
||||||
|
self.read_sessions()
|
||||||
|
self.scan_inventory()
|
||||||
|
self.sync_view()
|
||||||
|
self.save()
|
||||||
|
while self.running:
|
||||||
|
if not self.read_sessions():
|
||||||
|
time.sleep(POLL_SECONDS)
|
||||||
|
continue
|
||||||
|
now = time.time()
|
||||||
|
if now - self.last_scan >= RESCAN_SECONDS:
|
||||||
|
changed = self.scan_inventory()
|
||||||
|
changed |= self.sync_view()
|
||||||
|
if changed:
|
||||||
|
try:
|
||||||
|
self.api("/Library/Refresh", method="POST")
|
||||||
|
except Exception as error:
|
||||||
|
log(f"Unable to request library refresh: {error}")
|
||||||
|
self.last_scan = now
|
||||||
|
|
||||||
|
candidates = self.candidate_list()
|
||||||
|
if not candidates:
|
||||||
|
time.sleep(POLL_SECONDS)
|
||||||
|
continue
|
||||||
|
selected = None
|
||||||
|
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:
|
||||||
|
selected = (rank == 0, relative)
|
||||||
|
break
|
||||||
|
if not selected:
|
||||||
|
time.sleep(60)
|
||||||
|
continue
|
||||||
|
priority, relative = selected
|
||||||
|
try:
|
||||||
|
result = self.encode(relative, priority)
|
||||||
|
if result == "full":
|
||||||
|
time.sleep(60)
|
||||||
|
except Exception as error:
|
||||||
|
log(f"Encode failed for {relative}: {error}")
|
||||||
|
self.state["retry_after"][relative] = time.time() + 21600
|
||||||
|
self.save()
|
||||||
|
output, manifest = self.cache_paths(relative)
|
||||||
|
output.with_name(output.name + ".partial").unlink(missing_ok=True)
|
||||||
|
time.sleep(POLL_SECONDS)
|
||||||
|
|
||||||
|
def sync_once(self):
|
||||||
|
self.scan_inventory()
|
||||||
|
self.sync_view()
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def active(self):
|
||||||
|
if not self.read_sessions():
|
||||||
|
return True
|
||||||
|
return bool(self.active_items)
|
||||||
|
|
||||||
|
def trim(self, target_bytes):
|
||||||
|
if self.active():
|
||||||
|
raise RuntimeError("cache resize refused while media is playing")
|
||||||
|
target = max(0, int(target_bytes) - 5_000_000_000)
|
||||||
|
entries = self.cache_entries()
|
||||||
|
used = sum(output.stat().st_size + manifest.stat().st_size for _, _, output, manifest in entries)
|
||||||
|
for _, relative, output, manifest in entries:
|
||||||
|
if used <= target:
|
||||||
|
break
|
||||||
|
size = output.stat().st_size + manifest.stat().st_size
|
||||||
|
if self.remove_cache(relative):
|
||||||
|
log(f"Trimmed {relative}")
|
||||||
|
used -= size
|
||||||
|
if used > target:
|
||||||
|
raise RuntimeError("unable to trim cache below target because content is locked")
|
||||||
|
self.sync_view()
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
manager = Manager()
|
||||||
|
command = sys.argv[1] if len(sys.argv) > 1 else "run"
|
||||||
|
if command == "run":
|
||||||
|
manager.run()
|
||||||
|
elif command == "sync":
|
||||||
|
manager.sync_once()
|
||||||
|
elif command == "active":
|
||||||
|
raise SystemExit(0 if not manager.active() else 1)
|
||||||
|
elif command == "trim" and len(sys.argv) == 3:
|
||||||
|
manager.trim(int(sys.argv[2]))
|
||||||
|
else:
|
||||||
|
raise SystemExit(f"unknown command: {command}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[ "${EUID}" -eq 0 ] || { echo "Run as root." >&2; exit 1; }
|
||||||
|
ENV_FILE=${1:-.env}
|
||||||
|
[ -f "$ENV_FILE" ] || { echo "Missing $ENV_FILE" >&2; exit 1; }
|
||||||
|
set -a
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
: "${PRETRANSCODE_ROOT:?}"
|
||||||
|
: "${PRETRANSCODE_VIEW_PATH:?}"
|
||||||
|
: "${PRETRANSCODE_CACHE_PATH:?}"
|
||||||
|
: "${PRETRANSCODE_STATE_PATH:?}"
|
||||||
|
: "${PRETRANSCODE_CACHE_IMAGE:?}"
|
||||||
|
: "${CACHE_MAX_BYTES:?}"
|
||||||
|
: "${PRETRANSCODE_UID:?}"
|
||||||
|
: "${PRETRANSCODE_GID:?}"
|
||||||
|
|
||||||
|
mkdir -p "$PRETRANSCODE_ROOT" "$PRETRANSCODE_VIEW_PATH" \
|
||||||
|
"$PRETRANSCODE_CACHE_PATH" "$PRETRANSCODE_STATE_PATH"
|
||||||
|
|
||||||
|
if [ ! -f "$PRETRANSCODE_CACHE_IMAGE" ]; then
|
||||||
|
fallocate -l "$CACHE_MAX_BYTES" "$PRETRANSCODE_CACHE_IMAGE"
|
||||||
|
mkfs.ext4 -q -F -m 0 -L pretranscode "$PRETRANSCODE_CACHE_IMAGE"
|
||||||
|
# mkfs may discard unused extents; claim the configured allocation again.
|
||||||
|
fallocate -l "$CACHE_MAX_BYTES" "$PRETRANSCODE_CACHE_IMAGE"
|
||||||
|
elif [ "$(stat -c %s "$PRETRANSCODE_CACHE_IMAGE")" -ne "$CACHE_MAX_BYTES" ]; then
|
||||||
|
echo "Cache image exists with a different size; resize it explicitly before provisioning." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
FSTAB_LINE="$PRETRANSCODE_CACHE_IMAGE $PRETRANSCODE_CACHE_PATH ext4 loop,nofail,noatime 0 2"
|
||||||
|
grep -qF "$FSTAB_LINE" /etc/fstab || echo "$FSTAB_LINE" >> /etc/fstab
|
||||||
|
mountpoint -q "$PRETRANSCODE_CACHE_PATH" || mount "$PRETRANSCODE_CACHE_PATH"
|
||||||
|
touch "$PRETRANSCODE_CACHE_PATH/.pretranscode-cache"
|
||||||
|
chown -R "$PRETRANSCODE_UID:$PRETRANSCODE_GID" \
|
||||||
|
"$PRETRANSCODE_VIEW_PATH" "$PRETRANSCODE_STATE_PATH" "$PRETRANSCODE_CACHE_PATH"
|
||||||
|
chmod 0755 "$PRETRANSCODE_VIEW_PATH" "$PRETRANSCODE_STATE_PATH" "$PRETRANSCODE_CACHE_PATH"
|
||||||
|
|
||||||
|
echo "Cache provisioned at $PRETRANSCODE_CACHE_PATH"
|
||||||
|
df -h "$PRETRANSCODE_CACHE_PATH"
|
||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
: "${JELLYFIN_API_KEY:?}"
|
||||||
|
IMAGE=${IMAGE:-pretranscode:test}
|
||||||
|
NETWORK=${MEDIA_NETWORK:-media-internal}
|
||||||
|
JELLYFIN_URL=${JELLYFIN_URL:-http://jellyfin:8096}
|
||||||
|
ROOT=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$ROOT"' EXIT
|
||||||
|
mkdir -p "$ROOT"/{source/movies/Test,view,cache,state}
|
||||||
|
touch "$ROOT/cache/.pretranscode-cache"
|
||||||
|
|
||||||
|
docker build -q -t "$IMAGE" . >/dev/null
|
||||||
|
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 --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"'
|
||||||
|
|
||||||
|
OUTPUT=$(find "$ROOT/cache/files" -name '*.mkv' ! -name '*.partial' | head -1)
|
||||||
|
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")"
|
||||||
|
echo "integration test passed"
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
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_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)}))
|
||||||
|
self.assertTrue(manager.cache_valid(relative))
|
||||||
|
source.write_bytes(b"changed")
|
||||||
|
self.assertFalse(manager.cache_valid(relative))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user