{"id":48986327,"url":"https://github.com/GeiserX/jellyfin-encoder","last_synced_at":"2026-05-04T16:01:09.123Z","repository":{"id":283750358,"uuid":"952758350","full_name":"GeiserX/jellyfin-encoder","owner":"GeiserX","description":"Automatic 720p HEVC/AV1 transcoding service for Jellyfin with NVIDIA and Intel hardware acceleration","archived":false,"fork":false,"pushed_at":"2026-04-24T15:37:39.000Z","size":147,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-04-24T17:26:08.525Z","etag":null,"topics":["automation","av1","docker","ffmpeg","hacktoberfest","hardware-acceleration","hevc","homelab","intel-qsv","jellyfin","media-management","media-server","nvidia","open-source","python","self-hosted","streaming","transcoding","video","video-encoding"],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/GeiserX.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null},"funding":{"github":"geiserx","patreon":"geiser","buy_me_a_coffee":"geiser","thanks_dev":"u/gh/geiserx"}},"created_at":"2025-03-21T20:38:25.000Z","updated_at":"2026-04-24T15:36:56.000Z","dependencies_parsed_at":"2025-03-22T00:29:40.712Z","dependency_job_id":null,"html_url":"https://github.com/GeiserX/jellyfin-encoder","commit_stats":null,"previous_names":["geiserx/automatic-ffmpeg","geiserx/jellyfin-encoder"],"tags_count":24,"template":false,"template_full_name":null,"purl":"pkg:github/GeiserX/jellyfin-encoder","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/GeiserX%2Fjellyfin-encoder","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/GeiserX%2Fjellyfin-encoder/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/GeiserX%2Fjellyfin-encoder/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/GeiserX%2Fjellyfin-encoder/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/GeiserX","download_url":"https://codeload.github.com/GeiserX/jellyfin-encoder/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/GeiserX%2Fjellyfin-encoder/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32614385,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-04T10:08:07.713Z","status":"ssl_error","status_checked_at":"2026-05-04T10:08:02.005Z","response_time":58,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["automation","av1","docker","ffmpeg","hacktoberfest","hardware-acceleration","hevc","homelab","intel-qsv","jellyfin","media-management","media-server","nvidia","open-source","python","self-hosted","streaming","transcoding","video","video-encoding"],"created_at":"2026-04-18T13:00:27.679Z","updated_at":"2026-05-04T16:01:09.113Z","avatar_url":"https://github.com/GeiserX.png","language":"Python","funding_links":["https://github.com/sponsors/geiserx","https://patreon.com/geiser","https://buymeacoffee.com/geiser","https://thanks.dev/u/gh/geiserx"],"categories":["👾 Companion Apps \u0026 Tools"],"sub_categories":["🔧 Server Administration"],"readme":"\u003cp align=\"center\"\u003e\n  \u003cimg src=\"docs/images/banner.svg\" alt=\"jellyfin-encoder banner\" width=\"900\"/\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003cstrong\u003eAutomatic video transcoding service for Jellyfin media streaming\u003c/strong\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://github.com/GeiserX/jellyfin-encoder/blob/main/LICENSE\"\u003e\u003cimg src=\"https://img.shields.io/github/license/GeiserX/jellyfin-encoder?style=flat-square\" alt=\"License\"\u003e\u003c/a\u003e\n  \u003ca href=\"https://hub.docker.com/r/drumsergio/jellyfin-encoder\"\u003e\u003cimg src=\"https://img.shields.io/docker/pulls/drumsergio/jellyfin-encoder?style=flat-square\" alt=\"Docker Pulls\"\u003e\u003c/a\u003e\n  \u003ca href=\"https://github.com/GeiserX/jellyfin-encoder/releases\"\u003e\u003cimg src=\"https://img.shields.io/github/v/release/GeiserX/jellyfin-encoder?style=flat-square\" alt=\"GitHub Release\"\u003e\u003c/a\u003e\n  \u003ca href=\"https://hub.docker.com/r/drumsergio/jellyfin-encoder\"\u003e\u003cimg src=\"https://img.shields.io/docker/image-size/drumsergio/jellyfin-encoder/latest?style=flat-square\u0026label=image%20size\" alt=\"Docker Image Size\"\u003e\u003c/a\u003e\n  \u003ca href=\"https://codecov.io/gh/GeiserX/jellyfin-encoder\"\u003e\u003cimg src=\"https://codecov.io/gh/GeiserX/jellyfin-encoder/graph/badge.svg\" alt=\"codecov\"\u003e\u003c/a\u003e\n  \u003ca href=\"https://github.com/awesome-jellyfin/awesome-jellyfin#readme\"\u003e\u003cimg src=\"https://img.shields.io/badge/listed%20on-awesome--jellyfin-00a4dc?style=flat-square\u0026logo=jellyfin\u0026logoColor=white\" alt=\"listed on awesome-jellyfin\"\u003e\u003c/a\u003e\n\u003c/p\u003e\n\n---\n\n**jellyfin-encoder** monitors your media library and automatically transcodes videos to optimized 720p HEVC or AV1 for bandwidth-efficient mobile and remote streaming. It runs as a Docker container, supports NVIDIA NVENC and Intel QSV hardware acceleration with automatic software fallback, and uses polling-based observation compatible with NFS, CIFS, and other network filesystems.\n\n## Features\n\n- **Automatic folder monitoring** -- watches source directories for new and deleted files using polling (NFS/CIFS compatible)\n- **Hardware-accelerated encoding** -- NVIDIA NVENC and Intel Quick Sync Video (QSV), with transparent software fallback (libx265 / libsvtav1)\n- **Smart skip logic** -- detects files already at 720p or lower via filename heuristics and ffprobe resolution analysis\n- **Jellyfin multi-version support** -- creates version symlinks so Jellyfin presents both original and transcoded copies to the user\n- **Audio normalization** -- re-encodes all audio tracks to stereo AC3 at 192 kbps for consistent mobile playback\n- **Subtitle preservation** -- copies MKV-native subtitle codecs and converts incompatible ones (MOV text, WebVTT) to SRT\n- **Guarded automatic cleanup** -- periodically removes orphaned encodes and stale symlinks with mount-health checks to prevent mass deletion (see [Safety \u0026 Cleanup](#safety--cleanup) below)\n- **Temp-file workflow** -- encodes to `.tmp` and atomically renames on success, so Jellyfin never indexes incomplete files (note: no cross-container locking — avoid pointing two encoders at the same destination subfolder)\n- **Configurable quality presets** -- LOW, MEDIUM, and HIGH profiles with per-codec CQ/CRF tuning\n\n## Quick Start\n\n### Docker Compose\n\n```yaml\nservices:\n  jellyfin-encoder:\n    image: drumsergio/jellyfin-encoder:1.1.4\n    container_name: jellyfin-encoder\n    devices:\n      - /dev/dri:/dev/dri  # Intel QSV -- remove if using NVIDIA or software encoding\n    volumes:\n      - /path/to/source:/app/source\n      - /path/to/destination:/app/destination\n    environment:\n      ENABLE_HW_ACCEL: \"true\"\n      HW_ENCODING_TYPE: \"intel\"   # nvidia | intel\n      ENCODING_QUALITY: \"LOW\"     # LOW | MEDIUM | HIGH\n      ENCODING_CODEC: \"hevc\"      # hevc | av1\n    restart: always\n\n    # For NVIDIA GPU support, replace the devices block above with:\n    # deploy:\n    #   resources:\n    #     reservations:\n    #       devices:\n    #         - capabilities: [gpu]\n```\n\n### Docker CLI\n\n```bash\ndocker run -d \\\n  --name jellyfin-encoder \\\n  --device /dev/dri:/dev/dri \\\n  -v /path/to/source:/app/source \\\n  -v /path/to/destination:/app/destination \\\n  -e ENABLE_HW_ACCEL=true \\\n  -e HW_ENCODING_TYPE=intel \\\n  -e ENCODING_CODEC=hevc \\\n  -e ENCODING_QUALITY=LOW \\\n  --restart always \\\n  drumsergio/jellyfin-encoder:1.1.4\n```\n\n## Configuration\n\nAll settings are controlled via environment variables.\n\n| Variable | Default | Description |\n|---|---|---|\n| `SOURCE_FOLDER` | `/app/source` | Path to the directory containing original videos |\n| `DEST_FOLDER` | `/app/destination` | Path to the directory for encoded output |\n| `ENABLE_HW_ACCEL` | `true` | Enable hardware-accelerated encoding |\n| `HW_ENCODING_TYPE` | `nvidia` | Hardware encoder: `nvidia` or `intel` |\n| `ENCODING_CODEC` | `hevc` | Output codec: `hevc` or `av1` |\n| `ENCODING_QUALITY` | `LOW` | Quality preset: `LOW`, `MEDIUM`, or `HIGH` |\n| `SYMLINK_TARGET_PREFIX` | _(empty)_ | Absolute path prefix for Jellyfin version symlinks (same-host mode) |\n| `SYMLINK_MANIFEST_TARGET` | _(empty)_ | Path prefix for cross-host manifest-based symlinks (see [Cross-Host Setup](#cross-host-manifest-mode)) |\n| `SYMLINK_VERSION_SUFFIX` | ` - 720p` | Suffix appended to symlink filenames |\n| `CLEANUP_INTERVAL_HOURS` | `6` | Hours between automatic orphan cleanup runs |\n\n## Quality Presets\n\nEach preset defines constant-quality (CQ) values for hardware encoding and constant rate factor (CRF) values for software fallback.\n\n| Preset | HEVC CQ / CRF | AV1 CQ / CRF | Intended Use |\n|---|---|---|---|\n| **LOW** | 32 / 30 | 45 / 40 | Mobile devices, minimal storage footprint |\n| **MEDIUM** | 26 / 26 | 35 / 35 | Balanced quality and file size |\n| **HIGH** | 22 / 22 | 28 / 28 | Higher fidelity, larger files |\n\n## Hardware Acceleration\n\n### NVIDIA (NVENC)\n\nRequires the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Add a GPU reservation to your Compose file:\n\n```yaml\ndeploy:\n  resources:\n    reservations:\n      devices:\n        - capabilities: [gpu]\n```\n\nSet `HW_ENCODING_TYPE: \"nvidia\"`. Supported encoders: `hevc_nvenc`, `av1_nvenc`.\n\n### Intel (Quick Sync Video)\n\nPass the render device into the container:\n\n```yaml\ndevices:\n  - /dev/dri:/dev/dri\n```\n\nSet `HW_ENCODING_TYPE: \"intel\"`. Supported encoders: `hevc_qsv`, `av1_qsv`.\n\n### Software Fallback\n\nIf hardware acceleration is disabled or unavailable, the encoder falls back to `libx265` (HEVC) or `libsvtav1` (AV1) using CRF-based quality control. Worker count scales to the number of available CPU cores.\n\n## Safety \u0026 Cleanup\n\nThe encoder periodically removes orphaned encodes (files in `DEST_FOLDER` with no matching source) and stale version symlinks. Several safety rails prevent accidental mass deletion:\n\n| Guard | Scope | Behavior |\n|---|---|---|\n| **Source not accessible** | `cleanup_destination`, `cleanup_orphaned_symlinks` | Aborts if `SOURCE_FOLDER` is not a directory |\n| **Empty source** | `cleanup_destination` | Aborts if zero video files are found in source |\n| **Persisted count** (primary) | `cleanup_destination`, `cleanup_orphaned_symlinks` | After each successful cleanup, the source video count is written to `DEST_FOLDER/.encoder_source_count`. If the current count drops below 50% of the persisted value, cleanup is refused. To reset after intentionally shrinking the library, delete the `.encoder_source_count` file. |\n| **Source vs destination ratio** (secondary) | `cleanup_destination`, `cleanup_orphaned_symlinks` | If source video count is less than 50% of destination encode count, cleanup is refused |\n| **Mount health on delete events** | `VideoHandler.on_deleted` | Before trusting a file-delete event from the polling observer, the handler verifies the source mount is responsive. If not, the event is ignored. |\n| **Growing tmp files** | `cleanup_destination` | `.tmp` files are kept if they are still being written |\n\n**Same-folder mode** (`SOURCE_FOLDER == DEST_FOLDER`): versioned output filenames (e.g., `Movie - 720p.mkv`) are recognized as valid encodes and excluded from orphan cleanup.\n\n**Delete-event rate limiter**: If more than 50 delete events fire within 60 seconds, further deletes are suppressed. This prevents mount outages from cascading into mass encode deletion. The limit resets automatically after the window expires.\n\n**Limitations**: The persisted-count and ratio guards use a 50% threshold. A mount that exposes more than half its files will pass both guards, potentially allowing cleanup of files in invisible subtrees. After bulk intentional deletions, you may need to delete `DEST_FOLDER/.encoder_source_count` to reset the baseline — cleanup will refuse to run until the persisted count is reset or the source count recovers above 50%.\n\n### Upgrading from \u003c 1.1.0\n\nStarting with v1.1.0, encoded outputs always include the version suffix (e.g., `Movie - 720p.mkv` instead of `Movie.mkv`). Existing encodes without the suffix will be re-encoded. To avoid this, rename them before upgrading:\n\n```bash\n# Dry-run (shows what would be renamed)\ndocker exec jellyfin-encoder python /app/scripts/migrate_encode_names.py\n\n# Apply renames\ndocker exec jellyfin-encoder python /app/scripts/migrate_encode_names.py --apply\n```\n\n## Cross-Host Manifest Mode\n\nWhen the encoder and Jellyfin run on **different hosts** (e.g., encoder on a NAS, Jellyfin on another server connected via CIFS/SMB), real symlinks cannot be created over the network mount. The manifest mode solves this:\n\n1. **Encoder** writes a `.symlink-manifest.json` to `DEST_FOLDER` listing all encoded files and their Jellyfin container target paths.\n2. **Jellyfin host** reads the manifest via a CIFS mount and creates real local symlinks.\n\n### Encoder Configuration\n\nSet `SYMLINK_MANIFEST_TARGET` to the path prefix as seen **inside the Jellyfin container**:\n\n```yaml\nservices:\n  jellyfin-encoder:\n    image: drumsergio/jellyfin-encoder:1.1.4\n    environment:\n      SYMLINK_MANIFEST_TARGET: \"/media-720/Peliculas\"  # Jellyfin container path\n      # ...other settings\n```\n\nThe manifest is updated on encode, delete, and cleanup, and fully rebuilt at startup.\n\n### Jellyfin Host\n\nInstall `scripts/symlink-from-manifest.sh` on the Jellyfin host and run it via cron:\n\n```bash\n# Copy script to Jellyfin host\ncp scripts/symlink-from-manifest.sh /boot/config/symlink-from-manifest.sh\nchmod +x /boot/config/symlink-from-manifest.sh\n\n# Add cron (runs every 5 minutes)\necho '*/5 * * * * /boot/config/symlink-from-manifest.sh' | crontab -\n```\n\nEdit the script's configuration variables (`REMOTE_ROOT`, `MEDIA_ROOT`, `LIBRARIES`) to match your setup. The script creates symlinks in `MEDIA_ROOT` pointing to the Jellyfin container path from the manifest, and removes orphaned symlinks not present in the manifest.\n\n### Manifest Format\n\n```json\n{\n  \"version\": 1,\n  \"symlinks\": {\n    \"Movie (2024)/Movie (2024) - 720p.mkv\": \"/media-720/Peliculas/Movie (2024)/Movie (2024) - 720p.mkv\"\n  }\n}\n```\n\n### Same-Host vs Cross-Host\n\n| Mode | Variable | Use Case |\n|---|---|---|\n| **Same-host** | `SYMLINK_TARGET_PREFIX` | Encoder and Jellyfin share a filesystem — encoder creates real symlinks directly |\n| **Cross-host** | `SYMLINK_MANIFEST_TARGET` | Encoder and Jellyfin on different hosts — encoder writes manifest, Jellyfin host creates symlinks |\n\nBoth modes can coexist. If only `SYMLINK_MANIFEST_TARGET` is set, symlinks are managed exclusively via the manifest.\n\n## Architecture\n\n```\nSource folder (polling observer)\n        |\n        v\n  New file detected ──\u003e Wait for file completion (size-stable for 60s)\n        |\n        v\n  Resolution check ──\u003e Skip if \u003c= 720p\n        |\n        v\n  FFmpeg transcode ──\u003e scale to 720p, encode video, stereo AC3 audio, copy/convert subtitles\n        |\n        v\n  Verify output (ffprobe duration check)\n        |\n        v\n  Atomic rename .tmp -\u003e .mkv ──\u003e Create Jellyfin version symlink (optional)\n```\n\nKey design decisions:\n\n- **Polling observer** (`watchdog.PollingObserver`) instead of inotify, ensuring compatibility with NFS, CIFS, and other network filesystems.\n- **Temp-file workflow** -- encodes to a `.tmp` file first and atomically renames on success, preventing Jellyfin from indexing incomplete files.\n- **File-growth detection** -- before deleting stale `.tmp` files, the cleanup routine checks whether the file is still being written by another instance.\n- **ProcessPoolExecutor** -- one worker for hardware encoding (GPU is the bottleneck), multiple workers for software encoding (CPU-bound).\n\n## Utilities\n\n### compare_encodes.py\n\nA standalone diagnostic script that compares source and destination folders to report encoding coverage.\n\n```bash\n# Command-line usage\npython scripts/compare_encodes.py --source /media/movies --dest /media/movies-720p\n\n# Inside a running container\ndocker exec jellyfin-encoder python /app/scripts/compare_encodes.py\n\n# Output as JSON or CSV\npython scripts/compare_encodes.py -s /media/movies -d /media/movies-720p --format json\npython scripts/compare_encodes.py -s /media/movies -d /media/movies-720p --format csv\n\n# Include files that were skipped (already 720p or lower)\npython scripts/compare_encodes.py -s /media/movies -d /media/movies-720p --show-skipped\n```\n\n| Option | Env Variable | Description |\n|---|---|---|\n| `-s, --source` | `SOURCE_FOLDER` | Source folder with original videos |\n| `-d, --dest` | `DEST_FOLDER` | Destination folder with encoded videos |\n| `-f, --format` | `OUTPUT_FORMAT` | Output format: `text`, `json`, `csv` |\n| `--show-skipped` | `SHOW_SKIPPED` | Include skipped low-quality files in the report |\n| `--ignore` | `IGNORE_PATTERNS` | Additional regex patterns to ignore (comma-separated) |\n\n\u003cdetails\u003e\n\u003csummary\u003eExample output\u003c/summary\u003e\n\n```\n================================================================================\nENCODING COMPARISON REPORT\n================================================================================\n\nSource folder:      /media/movies\nDestination folder: /media/movies-720p\n\n----------------------------------------\nSUMMARY\n----------------------------------------\nTotal source files:     4,463\nTotal destination files: 4,440\nMatched (encoded):      4,420\nMissing encodes:        23\nOrphaned encodes:       20\nSkipped (low quality):  20\n\n----------------------------------------\nMISSING ENCODES (23 files, 45.2 GiB total)\n----------------------------------------\n  [   2.1 GiB] Movie Title (2024) [BDRemux 1080p].mkv\n  [   1.8 GiB] Another Movie (2023) [UHD 2160p].mkv\n  ...\n\n================================================================================\nSTATUS: Issues found - 23 missing encodes, 20 orphaned files\n================================================================================\n```\n\n\u003c/details\u003e\n\n## Other Jellyfin Projects by GeiserX\n\n- [quality-gate](https://github.com/GeiserX/quality-gate) — Restrict users to specific media versions based on filename regex patterns\n- [smart-covers](https://github.com/GeiserX/smart-covers) — Cover extraction for books, audiobooks, comics, magazines, and music libraries with online fallback\n- [whisper-subs](https://github.com/GeiserX/whisper-subs) — Automatically generates subtitles using local AI models powered by Whisper\n- [jellyfin-telegram-channel-sync](https://github.com/GeiserX/jellyfin-telegram-channel-sync) — Sync Jellyfin access with Telegram channel membership\n\n## Related Music Pipeline Tools\n\n- [telegram-slskd-local-bot](https://github.com/GeiserX/telegram-slskd-local-bot) — Automated music discovery and download via Telegram\n- [slskd-transform](https://github.com/GeiserX/slskd-transform) — Bulk upgrade lossy to lossless FLAC via Soulseek\n- [audio-transcode-watcher](https://github.com/GeiserX/audio-transcode-watcher) — Automated multi-format audio transcoding\n\n\n## Contributing\n\nContributions are welcome. Please open an issue to discuss proposed changes before submitting a pull request.\n\n1. Fork the repository\n2. Create a feature branch (`git checkout -b feature/my-change`)\n3. Commit your changes\n4. Open a pull request against `main`\n\n\n## License\n\nThis project is licensed under the [GPL-3.0 License](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2FGeiserX%2Fjellyfin-encoder","html_url":"https://awesome.ecosyste.ms/projects/github.com%2FGeiserX%2Fjellyfin-encoder","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2FGeiserX%2Fjellyfin-encoder/lists"}