Replace stateful cache with atomic C controller

This commit is contained in:
SpiderFish
2026-08-29 21:56:05 +10:00
parent a66945c245
commit bd2b623633
12 changed files with 1823 additions and 1199 deletions
+73 -91
View File
@@ -1,40 +1,57 @@
# Pretranscode
A small Jellyfin compatibility cache. It converts only known-incompatible video
codecs to uniform 8-bit H.264 while preserving immutable originals.
A small, single-writer Jellyfin compatibility cache.
## Design
Immutable source media is the only source of truth. The cache is a disposable,
filesystem-derived projection and contains no manifests, inventory database, or
migration state.
## Compatibility output
A cached rendition contains only:
- one H.264 High, 8-bit `yuv420p` video stream;
- one AAC-LC, 192 kbps stereo audio stream when the source has audio;
- BT.709 SDR output for HDR10, HLG, and Dolby Vision sources.
Embedded subtitles, attachments, chapters, and inherited metadata are omitted.
Sidecar subtitle files are mirrored unchanged. This deliberately small profile
prevents EAC3/Atmos, PGS, or stale Dolby Vision metadata from forcing another
Jellyfin transcode.
A source needs a compatibility rendition unless it is already H.264 8-bit with
AAC stereo audio and no embedded subtitles.
## Durable model
- 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.
- Jellyfin reads `/data/library`, a symlink mirror under `/view`.
- A link targets either `/data/original` or an immutable cache object.
- Cache names encode hashes of the logical path, source generation, complete
encoding profile, and output bytes.
- FFmpeg writes only a `.partial` file.
- Publication is validation, `fsync`, an atomic output rename, then an atomic
symlink rename.
- Open clients retain their old inode during publication.
- A profile change builds a new object while the published old-profile object
remains available. The old object is collected only after replacement.
- LRU updates file access time only; published media modification time and bytes
never change.
- Scheduling, retries, priorities, and cooldowns exist only in process memory.
- One controller owns the cache directory through `flock()`.
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
There is no cache migration command. Restarting with a changed profile is enough.
## Installation
1. Copy `.env.example` to `.env` and set all host paths and the Jellyfin API key.
2. Provision the claimed cache allocation:
1. Copy `.env.example` to `.env` and set the host paths and Jellyfin API key.
2. Provision the cache filesystem:
```bash
sudo scripts/provision-cache.sh .env
```
3. Mount the same paths in Jellyfin while keeping its existing logical library
path:
3. Mount the same paths in Jellyfin:
```yaml
volumes:
@@ -43,90 +60,55 @@ priority, and minimal Docker CPU shares make normal services take precedence.
- ${PRETRANSCODE_CACHE_PATH}:/data/cache:ro
```
4. Start Pretranscode:
4. Start the controller:
```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.
`.env.example` values through the environment-variable UI.
## Codec policy
## Operation
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 filtered video is mapped as a new
stream so source-only Dolby Vision configuration data cannot leak into H.264
outputs.
The controller has one worker. FFmpeg and file hashing run with `SCHED_IDLE` and
idle I/O priority so foreground services take precedence.
The fixed source blacklist is:
Playback locking rules:
- HEVC/H.265, including Dolby Vision
- H.264 Hi10/10-bit
- MPEG-2
- VC-1
- an active movie locks that movie;
- an active episode locks its series;
- an encode is cancelled if its item becomes locked;
- a series remains locked for `SERIES_COOLDOWN_SECONDS` after playback;
- recently watched content receives in-memory queue priority.
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
- 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.
## Upgrading from v1.2.0
Version 1.2.1 uses a v4 cache profile. Before starting the new worker, stop the
old worker and migrate its manifests:
Commands:
```bash
docker compose stop pretranscode
docker compose run --rm pretranscode migrate-v4
pretranscode run
pretranscode sync
pretranscode active
pretranscode trim BYTES
pretranscode purge
pretranscode --version
```
The migration re-probes immutable sources, updates every valid v3 manifest, and
creates a state backup. Cached H.264 streams that inherited Dolby Vision
configuration data are repaired with a lossless stream-copy remux; video is not
re-encoded. Other cached media files are not rewritten. The command refuses to
run while Jellyfin reports active playback.
`purge` and `trim` refuse to operate during active playback.
## Migration
## Failure behaviour
Copy or recreate these resources on the destination:
Partial outputs are never published. Startup removes stale partials and
unreferenced objects. A missing cache mount marker, second controller, failed
probe, failed validation, failed `fsync`, or unavailable Jellyfin session API
causes work to stop safely rather than mutate published media.
- Repository and `.env`
- State directory
- Symlink-view directory
- Cache image/mount, if retaining completed transcodes
## Tests
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.
The image build treats compiler warnings as errors and runs the C core tests.
The integration test creates incompatible media and verifies canonical streams,
atomic publication, refresh retry, and stateless profile replacement:
```bash
podman build -t pretranscode-test .
IMAGE=pretranscode-test tests/integration.sh
```