A local Node.js application for standardising media imported by Sonarr and Radarr. Download webhooks add files to a persistent SQLite queue, and a single FFmpeg worker converts them with VAAPI hardware acceleration.
The browser interface provides:
- A reorderable one-at-a-time conversion queue.
- Current FFmpeg progress, frame rate and speed.
- Sonarr and Radarr media browsers with file size, GB/hour, resolution and conversion state.
- Manual conversion jobs.
- Saved custom profiles with quality and maximum-resolution sliders.
- Rough output-size previews and live estimates during encoding.
- Persistent history and space-saving statistics.
- Application and FFmpeg logs.
- Live transcode-cache size beside Clear Cache.
Media metadata returned by Sonarr and Radarr is cached in SQLite as it is browsed. Episode-file and movie views can fall back to the cached records after a temporary API failure. Probe refreshes exact file metadata and opens a modal with a readable summary and the full ffprobe format, streams, tags and chapters.
GB/hour uses the same 1024-based GB as file-size displays. Movies and episode files use their individual size and duration when available; probing refreshes both. When an episode file has no duration, the series' typical episode runtime gives an approximate rate (marked ~), accounting for files containing multiple episodes. The main TV browser shows an approximate average (also marked ~): total size divided by stored episode count and Sonarr's typical episode runtime. Missing size or runtime shows -. The column sorts by the unrounded value.
The cache size is the total size of files in the configured transcode-cache directory, including active output, and updates with the status display. Clear Cache removes inactive transcode temporary files, so active output and unrelated files may keep the total above zero.
The service intentionally has no user authentication. Deploy it only on a trusted local network or restrict access with a firewall or reverse proxy.
Sonarr / Radarr webhook
|
v
Express API on port 5000
|
v
SQLite queue and conversion records
|
v
Single FFmpeg VAAPI worker
|
v
Verified temporary output -> atomic media-file replacement
Queue state, history, converted-file records, browser media items and statistics are stored in SQLite. JSON database files and separate transcoding scripts are no longer used. The application uses the SQLite module included with Node.js, so it does not require a native SQLite npm package.
The previous Flask application committed live Sonarr and Radarr API keys in web/app.py. Rotate both keys in Sonarr and Radarr before deploying this version. The replacement application reads keys from /etc/transcode-manager.env, which is created with root-only permissions and is not stored in Git.
The installer supports Debian and Ubuntu systems using apt. It installs Node.js when required, FFmpeg, VAAPI tools, the application service and an automatic update timer.
git clone https://github.com/Invertee/Radarr-Sonarr-Transcoder.git
cd Radarr-Sonarr-Transcoder
sudo ./install.sh --user samUse the account that already has read/write access to the mounted media library. The installer adds that account to the local video and render groups when those groups exist.
Common options:
sudo ./install.sh \
--user sam \
--port 5000 \
--cache-dir /var/cache/transcode-managerRun ./install.sh --help for the complete option list.
| Purpose | Default path |
|---|---|
| Application checkout | /opt/transcode-manager |
| Configuration | /etc/transcode-manager.env |
| SQLite database | /var/lib/transcode-manager/transcode-manager.sqlite |
| Temporary FFmpeg output | /var/cache/transcode-manager |
| Application logs | /var/log/transcode-manager |
| Systemd service | transcode-manager.service |
| Update timer | transcode-manager-update.timer |
The legacy transcoder.service is stopped and disabled during installation so the new application can continue using port 5000. The old /opt/scripts/sonarr directory is not deleted automatically.
Edit the environment file after installation:
sudo nano /etc/transcode-manager.envSet at least the two API keys:
SONARR_URL=http://127.0.0.1:8989
SONARR_API_KEY=replace-with-new-key
RADARR_URL=http://127.0.0.1:7878
RADARR_API_KEY=replace-with-new-keyThen restart the service:
sudo systemctl restart transcode-manager.serviceOpen the interface at:
http://<transcoder-vm-address>:5000/
Sonarr, Radarr and the transcoder VM must refer to the same media file. Configure prefix translation when the paths differ.
Example: Sonarr reports /tv/Show/file.mkv, but the transcoder VM mounts the same share at /mnt/media/tv/Show/file.mkv:
SONARR_PATH_FROM=/tv
SONARR_PATH_TO=/mnt/media/tvA separate mapping is available for Radarr:
RADARR_PATH_FROM=/movies
RADARR_PATH_TO=/mnt/media/moviesWindows-style paths are normalised before mapping.
In each application, open Settings > Connect, add a webhook, and enable the download/import and upgrade triggers.
Use these endpoints:
| Service | URL |
|---|---|
| Sonarr | http://<transcoder-vm-address>:5000/api/webhook/sonarr |
| Radarr | http://<transcoder-vm-address>:5000/api/webhook/radarr |
| Generic auto-detection | http://<transcoder-vm-address>:5000/api/webhook |
A test webhook returns immediately and does not create a job. Download and upgrade events are queued. Other event types are ignored.
Tags are read from the Sonarr series or Radarr movie. Numeric tag IDs in webhook payloads are resolved through the relevant API.
| Profile | Recognised tags | Video settings |
|---|---|---|
| High | high, transcode-high, transcode:high |
HEVC VAAPI, 1920px maximum width, QP 22 |
| Medium | medium, transcode-medium, transcode:medium |
HEVC VAAPI, 1920px maximum width, QP 24 |
| Low | low, transcode-low, transcode:low |
HEVC VAAPI, 1920px maximum width, QP 30 |
| LowRes | lowres, 720p, transcode-lowres, transcode:lowres |
HEVC VAAPI, 1280px maximum width, QP 26 |
| Skip | skip, noconvert, no-convert, transcode-skip, transcode:skip |
Webhook is acknowledged without queuing a conversion |
Media with no recognised tag uses DEFAULT_PROFILE, which defaults to medium.
When conflicting tags are present, priority is: skip, lowres, low, medium, high. Use one transcoder profile tag per series or movie.
Open Profiles to create, edit or delete a named custom conversion profile. The quality slider moves from smaller files to higher quality; the resolution slider sets a maximum of 480p, 720p, 1080p, 1440p or 2160p. The resolution labels describe standard widescreen widths; aspect ratio is preserved and smaller sources are never enlarged. The built-in presets remain available.
Saved profiles are stored in SQLite and appear in the manual-file, movie and episode profile selectors. Each job snapshots its profile when queued, so later edits or deletion do not change pending conversions or their history. For automated imports, apply the stable Sonarr/Radarr tag displayed in the profile editor (or prefix it with transcode:). A skip tag takes priority, then a custom profile, then the built-in quality tags. Use only one custom profile tag per item. DEFAULT_PROFILE can also be set to the saved profile's key.
Movie and episode size cells show a rough output-size range for the selected profile when duration and dimensions are available. Use Probe to fill in missing metadata. For a manual file, enter its path, choose a profile and click Estimate size. Estimates update when you select another profile.
During every conversion, the status card first shows the rough range and then an estimated final size based on the bytes and media duration encoded so far. The initial estimate uses a broad HEVC quality/resolution heuristic and the configured AAC bitrate for each audio stream (assuming one stream when browser metadata lacks the count). It is a guide, not a size limit: scene complexity, source quality, copied subtitles/attachments and variable bitrate can change the result substantially. Live estimates can fluctuate when later scenes differ from the opening scenes. A conversion can produce a larger file, which the preview also indicates.
Profile API: GET/POST /api/profiles, PUT/DELETE /api/profiles/:key. Create/update bodies contain name, integer qp (16–36; lower is higher quality), and maxWidth (854, 1280, 1920, 2560 or 3840). POST /api/media/estimate accepts path and profileKey, probes the file and returns metadata plus a rough estimate.
The old application forced a single stereo track. This version instead:
- Maps every audio stream with
-map 0:a?. - Converts every mapped audio stream to AAC.
- Does not force
-ac 2, so the original channel count is retained where supported by FFmpeg's AAC encoder. - Retains stream order and normal FFmpeg stream metadata, including language metadata.
- Verifies that the output contains the same number of audio streams as the input before replacing the source file.
AUDIO_BITRATE=192k applies to each output audio stream and can be changed in the environment file.
Supported source/output containers are MKV, MP4, M4V and MOV. MKV subtitle and attachment streams are copied. MP4-family files omit subtitle and attachment streams because formats commonly present in downloaded media, such as PGS and ASS, cannot be copied safely into those containers.
For each job, the worker:
- Probes the original file with
ffprobe. - Writes FFmpeg output to the configured cache directory.
- Probes and validates the completed output.
- Confirms that video exists, duration is within tolerance and the audio-stream count matches.
- Copies the validated output to a hidden sidecar file in the original media directory.
- Flushes the sidecar to disk and atomically renames it over the original file.
- Updates SQLite statistics and conversion records.
- Requests a Sonarr or Radarr rescan when source identifiers are available.
The original file remains untouched if encoding or output verification fails. The media filesystem must have enough free space for the sidecar copy during the final replacement.
Only one worker is created. A process lock also prevents a second application instance from starting against the same data directory.
- Pending jobs can be moved up or down in the web interface.
- Duplicate active paths are not added twice.
- Pending jobs survive restarts.
- A job active during a controlled shutdown is cancelled safely and returned to the queue.
- A job left in
processingafter an unexpected process failure is requeued on the next start. - Stopping an active job deletes only its temporary output; it does not modify the source media.
transcode-manager-update.timer checks the configured Git branch every five minutes.
When a new commit is found, the updater:
- Defers deployment if a conversion is active.
- Resets the application checkout to the new remote commit.
- Installs production dependencies.
- Runs JavaScript and installer syntax checks, unit tests and the SQLite smoke test.
- Restarts the systemd service.
- Checks
/healthfor up to 30 seconds. - Rolls back to the previous commit if validation or the health check fails.
Useful commands:
systemctl status transcode-manager-update.timer
sudo systemctl start transcode-manager-update.service
journalctl -u transcode-manager-update.service -n 100 --no-pagerDisable automatic deployment with:
sudo systemctl disable --now transcode-manager-update.timerOr install without it:
sudo ./install.sh --user sam --no-auto-updateConfirm the render device and HEVC encoder before submitting real jobs:
ls -l /dev/dri/renderD128
vainfo --display drm --device /dev/dri/renderD128
ffmpeg -hide_banner -encoders | grep hevc_vaapiCheck that the service account can access the device and media files:
id sam
sudo -u sam test -r /dev/dri/renderD128 && echo readable
sudo -u sam test -w /mnt/media && echo media-writableChange VAAPI_DEVICE when the render node is different.
sudo systemctl status transcode-manager.service
sudo systemctl restart transcode-manager.service
sudo systemctl stop transcode-manager.service
journalctl -u transcode-manager.service -fApplication logs:
tail -f /var/log/transcode-manager/transcode-manager.log
tail -f /var/log/transcode-manager/ffmpeg-last.logThe most recent FFmpeg log is also available at /api/debug_log. This file is overwritten when each conversion starts. Failed jobs retain the captured FFmpeg error output in their History details and in the application log, together with the job ID and source path.
An exit code alone (for example, FFmpeg exited with code 234) does not identify the cause. Use the saved FFmpeg output for the failed job to find the encoder, filter, audio or container error before changing conversion settings.
Stop the service before copying the SQLite database so the database, WAL and shared-memory state are consistent:
sudo systemctl stop transcode-manager.service
sudo cp -a /var/lib/transcode-manager /var/lib/transcode-manager.backup
sudo systemctl start transcode-manager.serviceNode.js 24.15 or newer is required. The installer supplies the current Node.js 24 LTS release when the installed version is older.
cp .env.example .env
npm install
npm run check
npm test
npm run smoke
npm run start:envThe main API endpoints are:
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/health |
Service health |
GET |
/api/status?compact=1 |
Current job, queue and statistics |
GET |
/api/profiles |
Available quality profiles |
POST |
/api/queue |
Add a manual job |
POST |
/api/queue/reorder |
Replace pending queue order |
DELETE |
/api/queue/:id |
Remove a pending job |
POST |
/api/active/cancel |
Stop the active FFmpeg job |
GET |
/api/history |
Persistent job history |
GET |
/api/media/sonarr/series |
Sonarr series browser data |
GET |
/api/media/sonarr/series/:id/files |
Sonarr episode-file data |
GET |
/api/media/radarr/movies |
Radarr movie browser data |
POST |
/api/media/probe |
Read exact file metadata with ffprobe and update the SQLite media cache |
POST |
/api/webhook/sonarr |
Sonarr webhook |
POST |
/api/webhook/radarr |
Radarr webhook |
Podcast analysis now belongs to the separate Podwaffle Ad Detection add-on in
the Podwaffle repository.
This application handles only Sonarr/Radarr video conversion again. Existing video
queues, history and statistics are unchanged. Old PODCAST_* environment settings
are ignored and can be removed from your environment file.
Unused podcast_jobs and podcast_job_logs tables are deliberately left intact on
upgrade. No migration is required to run the video service. After draining pending
podcast work and switching Podwaffle's analyser URL, optionally stop the transcoder
and run:
node scripts/remove-podcast-tables.js --database /var/lib/transcode-manager/transcode-manager.sqliteThis makes a consistent SQLite backup alongside the database, then drops only the two podcast tables (and their indexes). It refuses to run while the normal service holds its instance lock. The printed backup path can be restored with the service stopped. No video tables, models, cache directories or log files are deleted. Retain the backup securely until you no longer need the old transcripts.
In Job History, use Download FFmpeg log on a failed job to download its full FFmpeg output, input path and command. Logs are retained under LOG_DIR/failed-jobs/job-<id>.log and survive subsequent conversions, service restarts and cache cleanup. Logs from successful or cancelled jobs are removed. Failed logs remain until manually removed.
Failures before FFmpeg starts, older jobs and jobs whose archived log has been removed download the saved failure details instead; these may be truncated and are labelled accordingly. The API endpoint is GET /api/history/:id/ffmpeg-log.