Replace stateful cache with atomic C controller
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user