-
Notifications
You must be signed in to change notification settings - Fork 0
Modules
CHUB ships thirteen modules. Each one is a scheduled chore you can also run on demand. Every module has its own section in config.yml and its own page under Settings → Modules in the UI.
Jump to a module:
- 🖼️ poster_renamerr — rename posters to match your library
- 🎨 border_replacerr — apply brand or holiday borders
- 🧹 poster_cleanarr — sweep Plex Metadata bloat and orphan asset files
- 🏷️ labelarr — mirror ARR tags into Plex labels
- 🔍 jduparr — find duplicate media files
- 🔗 nohl — find non-hardlinked media
- ❓ unmatched_assets — report media without posters
- ⬆️ upgradinatorr — trigger ARR upgrade searches
- ✏️ renameinatorr — apply ARR naming to existing files
- 🩺 health_checkarr — surface ARR health problems
- 🪺 nestarr — detect mismatches and nested paths
- ☁️ sync_gdrive — pull poster assets from Google Drive
- 🧼 plex_maintenance — run Plex-side cleanup (trash, bundles, DB, photo cache)
-
Dry run — when
dry_run: true, the module logs what it would do without making changes. Turn this on the first time you try a module. -
Log level —
debug/info/warning/error, per module. Default isinfo. Flip todebugwhile you're diagnosing a problem, then back. -
Cancel from the UI — Settings → Jobs → click the running job → Cancel. Most modules stop cleanly on the next iteration.
border_replacerris the lone full exception — it runs to completion.plex_maintenanceis partial: its PhotoTranscoder cleanup loop checks the cancel flag, but the three Plex-API tasks (empty_trash,clean_bundles,optimize_db) run to completion because Plex's own API has no interrupt. Restart the container if you truly need to kill one of those mid-run. - Run history — visible in Settings → Jobs with full log output.
What it does. Walks your Kometa (or other) asset folders, matches each image against your Radarr/Sonarr/Plex libraries, renames the files to match, and copies/moves/hardlinks them into your destination tree. Can optionally chain into border_replacerr and into the orphan-asset cleanup pass from poster_cleanarr as a post-hook.
Cancellable: yes.
Gotcha: if nothing seems to be moving, check that dry_run is off, that destination_dir is writable by your PUID/PGID, and that action_type: hardlink isn't crossing filesystems.
See Kometa Integration for the end-to-end setup.
poster_renamerr:
dry_run: false
log_level: info
action_type: copy # copy | move | hardlink
asset_folders: true # expect Kometa-style per-item folders
sync_posters: false
print_only_renames: false
run_border_replacerr: false # chain border_replacerr after rename
clean_orphan_assets: false # chain orphan-asset cleanup after rename
orphan_assets_mode: report # report | move | remove (when chained)
report_unmatched_assets: false # chain unmatched_assets report
source_dirs:
- /kometa
destination_dir: /posters
instances:
- radarr_main
- sonarr_main
- plex_main:
library_names: ["Movies", "TV Shows"]
add_posters: true # push straight to Plex via APIWhat it does. Re-applies a brand or holiday border to every matched poster. Two modes:
-
Color mode (default): crops
border_widthpixels off all four edges and re-paints a flat colored border using a cycling color fromborder_colors. Holiday windows substitute that holiday'scolorslist. -
Image mode: composites a 1000×1500 decorative PNG over the poster — the PNG's transparent center lets the poster show through. Activates per-holiday by populating the holiday's
borders:field.border_widthis ignored in this mode (the width is baked into the artwork).
The cropping step in color mode is color-agnostic — it trusts that the source poster follows the TPDB convention of a default 26 px white border. If no colors and no borders are configured for the active holiday (or none is active), the cropped artwork is just resized back to 1000×1500 with no border.
The Border Replacerr settings are split across two pages so the simple "just strip the 26 px white border" flow stays uncluttered:
| Page | Settings |
|---|---|
| Module Settings → Border Replacerr (form) |
border_width, skip (holiday-only mode), exclusion_list, ignore_folders, log_level, dry_run, and the list of holidays with their name + schedule
|
Border Replacerr page (/poster/border-replacerr) |
border_colors (default), per-holiday colors, per-holiday borders (bundled + custom thumbnail picker), and the live preview gallery |
In other words: Module Settings is the "structural" form — what holidays exist, when they're active, and the strip-only mechanics. The Border Replacerr page is the "visual" editor — colors and themed border art, with a sample-poster preview underneath. The config schema is unchanged; the YAML below is still the source of truth.
Add a holiday in Module Settings, then pop over to the Border Replacerr page to pick its colors and themed art. The Border Replacerr page has a "Save changes" button at the top with an unsaved-changes badge, and the preview always reflects the saved configuration — there's a hint if you have unsaved edits.
Cancellable: not yet. If you start a big run and need to stop it, you'll need to restart the container.
Gotcha (color mode): border_width must match the actual border on your source posters. The default 26 is correct for any poster sourced from MediUX/TPDB or generated from the standard PSD template; non-standard art will lose 26 px of real content on every edge.
Holiday-only mode: flip skip: true to gate the whole module to holiday days — outside any active holiday window, border_replacerr will skip the run entirely. Useful if you only want themed borders during Halloween / Christmas / etc. and prefer to leave the default white border untouched the rest of the year. (The setting is labeled Holiday-only mode in the UI.)
If two holidays overlap, whichever is listed first wins.
CHUB ships 56 decorative SVG borders across 13 holidays. Each variant is a complete decorative ring (florals, ornaments, gradients) that wraps the poster, with a transparent rectangular center where the artwork shows through.
| Holiday | Variants | Schedule (default preset) |
|---|---|---|
| 🎆 New Year's Day |
v1 – v4
|
12/30 – 01/02 |
| 🧧 Lunar New Year |
v1 – v4
|
01/20 – 02/20 |
| 💘 Valentine's Day |
v1 – v5
|
02/05 – 02/15 |
| 🍀 St. Patrick's Day |
v1 – v4
|
03/14 – 03/18 |
| 🐣 Easter |
v1 – v4
|
03/31 – 04/02 |
| 🌸 Mother's Day |
v1 – v4
|
05/10 – 05/15 |
| 👨👧👦 Father's Day |
v1 – v4
|
06/15 – 06/20 |
| 🏳️🌈 Pride |
v1 – v5
|
06/01 – 06/30 |
| 🗽 Independence Day |
v1 – v4
|
07/01 – 07/05 |
| 🧹 Labor Day |
v1 – v4
|
09/01 – 09/07 |
| 🎃 Halloween |
v1 – v5
|
10/01 – 10/31 |
| 🦃 Thanksgiving |
v1 – v4
|
11/01 – 11/30 |
| 🎄 Christmas |
v1 – v5
|
12/01 – 12/31 |
Each variant within a holiday is a distinct composition (different shapes, motifs, palette), not a palette swap of a single template. Sample SVGs:
Browse the full set in backend/assets/borders/.
Drop your own PNGs into /config/borders/<holiday-folder>/ to extend or override the bundled set. Resolution order at runtime:
-
/config/borders/<holiday-folder>/<name>.png(user) — wins over -
/app/backend/assets/borders/<holiday-folder>/<name>.png(bundled)
So dropping a file named v1.png in /config/borders/christmas/ replaces the bundled Christmas v1 without rebuilding the image. Add brand-new names like custom-wreath.png to extend the rotation.
The <holiday-folder> is the holiday's preset name stripped of emojis, lowercased, and with all non-alphanumeric characters removed. Use these exact slugs:
| Preset name | Folder slug |
|---|---|
| 🎆 New Year's Day | newyear |
| 🧧 Lunar New Year | lunarnewyear |
| 💘 Valentine's Day | valentines |
| 🍀 St. Patrick's Day | stpatricks |
| 🐣 Easter | easter |
| 🌸 Mother's Day | mothersday |
| 👨👧👦 Father's Day | fathersday |
| 🏳️🌈 Pride | pride |
| 🗽 Independence Day | independence |
| 🧹 Labor Day | labor |
| 🎃 Halloween | halloween |
| 🦃 Thanksgiving | thanksgiving |
| 🎄 Christmas | christmas |
A custom holiday name (not in the preset list) gets a fallback slug auto-generated from the same rule (alphanumeric only, lowercased, no spaces). If you're adding a brand-new holiday with custom borders, pick a name whose slug is predictable — e.g. Anniversary → anniversary, Diwali 2026 → diwali2026.
| Requirement | Value |
|---|---|
| Dimensions | 1000 × 1500 (poster aspect ratio 2:3) |
| Format | PNG with alpha channel (RGBA) |
| Inner transparent area | Rectangle from (60, 60) to (940, 1440) must be fully transparent — this is where the poster shows through |
| Outer ring | The remaining 60-pixel band around all four edges should be opaque (or near-opaque) — this is your decoration zone |
| Corner ornaments | May extend inward up to ~120 px from each corner but should not cross into the transparent center |
| No text, no human faces, no copyrighted IP | Same rules as the bundled set — these will appear on every poster in the library, so keep them generic |
If a file doesn't have a transparent center, it'll still composite — but the poster artwork will be hidden behind the opaque area. If dimensions don't match, the compositor resizes to 1000×1500 on the fly (lossy — pre-resize to exact dimensions for best results).
The borders: list in config.yml is the rotation order — variants cycle in the order listed, one per asset, then wrap. Entries are filenames without the .png extension (it's added automatically):
holidays:
- name: 🎄 Christmas
schedule: "range(12/01-12/26)"
borders:
- v1 # → /config/borders/christmas/v1.png (or bundled v1.png)
- v2
- custom-wreath # → /config/borders/christmas/custom-wreath.png
- my-snowy-frame # → /config/borders/christmas/my-snowy-frame.pngBoth v1 and v1.png work in the list (the .png is stripped if present), but stick to one convention for readability. Resolution is case-sensitive — V1.png and v1.png are different files.
If a name resolves to nothing in either user or bundled location, that entry is skipped with a warning in the log; the remaining variants still cycle. So mistyping christamas-v1 won't break the run — it just leaves a gap in the rotation.
- Design your border in any tool that exports PNG with transparency (Photoshop, Affinity, Figma, Inkscape, GIMP) — start from one of the bundled SVGs if you want a template.
- Export at exactly 1000 × 1500 with the inner
(60, 60)to(940, 1440)area transparent. - Save into
/config/borders/<holiday-folder>/<your-name>.pngon your host (the mounted config volume). - Open the Border Replacerr page, expand the holiday card, and click the new file in the Custom thumbnail row to add it to the rotation. (Or edit
config.ymldirectly — both paths write the sameborders:list.) - Hit Save changes at the top of the Border Replacerr page. The next BorderReplacerr run picks it up — no image rebuild needed.
Custom borders override bundled ones when filenames collide, so dropping v1.png in the user folder shadows the bundled v1.png for that holiday. Use this to swap in your own version of a specific bundled variant without touching the rest.
border_replacerr:
dry_run: false
log_level: info
source_dirs:
- /posters
destination_dir: /posters
border_width: 26 # matches the TPDB white-border standard (color mode only)
skip: false # holiday-only mode: when true, only runs on active-holiday days
border_colors: # color mode fallback when no holiday active
- "#ff7300"
ignore_folders: [] # source folder names to skip
exclusion_list: null # media titles to leave alone
holidays:
# Color-mode holiday — cycles through colors
- name: 🎃 Halloween
schedule: "range(10/01-10/31)"
colors: ["#FF6600", "#000000"]
# Image-mode holiday — cycles through PNG variants instead
# When `borders` is set, image mode wins over `colors`
- name: 🎄 Christmas
schedule: "range(12/01-12/26)"
colors: ["#C8102E", "#00843D"] # kept as a fallback if a border PNG is missing
borders:
- v1
- v2
- v3
- v4
- v5
# Mixed example with a custom border
- name: 💘 Valentine's Day
schedule: "range(02/05-02/15)"
colors: ["#D41F3A", "#FFC0CB"]
borders:
- v1
- v2
- custom-roses # /config/borders/valentines/custom-roses.pngWhat it does. Two independent cleanup operations in one module:
-
Bloat-image cleanup — sweeps Plex's
Metadata/folder for poster variants Plex no longer references (left behind after item renames, deletes, or manual poster swaps). Tracks five Plex upload columns including the "new experience"user_clear_logo_urlanduser_square_art_urlso custom-uploaded clear logos and square art aren't wrongly flagged. Driven by the top-levelmodefield. -
Orphan-asset cleanup — walks
asset_dirsand acts on poster files whose title doesn't match any media in the configured instances (the asset has no parent media). Comparison set is read from CHUB'smedia_cache+collections_cache, populated byposter_renamerr's last sync — no live API calls. Catches assets placed by other tools or stranded when CHUB never tracked the source media. Driven byorphan_assets_enabled+orphan_assets_mode.
Not the same as the
unmatched_assetsmodule. Unmatched Assets reports media missing a poster (direction: media → asset). Orphan-asset cleanup acts on posters missing a media (direction: asset → media). Opposite directions, same library, easy to confuse.
Plex-side housekeeping (empty trash, clean bundles, optimize DB, clear PhotoTranscoder cache) lives in plex_maintenance on its own schedule.
Cancellable: yes.
Gotcha: plex_path must be a filesystem path (e.g. /plex-config/Library/Application Support/Plex Media Server/Metadata), not a URL.
Bloat-image modes (mode): report (dry run — lists bloat images), move (relocates to a Poster Cleanarr Restore folder), remove (deletes), restore (moves restore-folder items back), clear (deletes the restore folder), nothing (no-op). Start with report, then move, then remove.
Orphan-asset modes (orphan_assets_mode): report (log only), move (relocates each unmatched file to a hidden .chub_orphan_restore subdir inside its parent asset_dir — fully recoverable), remove (permanent delete). The first time you enable orphan-asset cleanup, leave it on report for one run and review the log before promoting.
Overlays-only mode. If you run Kometa overlays and sometimes upload your own custom posters, set overlays_only: true. With the flag on, every bloat candidate is opened and checked for Kometa's EXIF marker (0x04bc == "overlay"); files without the marker — i.e. anything you uploaded yourself — are skipped, not deleted. The summary report adds a "Skipped (non-overlay)" row so you can see how many of your customs were spared. There's a small CPU cost (PIL opens every bloat file), traded for not losing past hand-picked posters that have rolled out of Plex's current reference. Default is off. Applies only to the bloat-image pass.
poster_cleanarr:
log_level: info
mode: report # bloat-image: report | move | remove | restore | clear | nothing
plex_path: "/plex-config/Library/Application Support/Plex Media Server/Metadata"
local_db: false # clone Plex DB before scanning (safer on a running server)
use_existing_db: false # reuse the last cloned DB instead of re-cloning
ignore_running: false # skip when Plex is active
overlays_only: false # only sweep Kometa-tagged overlays; preserve custom uploads
sleep: 60 # seconds between Plex API calls
timeout: 600 # seconds to wait for Plex tasks
instances:
- plex_main # Plex needed for bloat; add Radarr/Sonarr for orphan comparison
# Orphan-asset cleanup (default off — opt in explicitly)
orphan_assets_enabled: false
orphan_assets_mode: report # report | move | remove
asset_dirs: [] # walked recursively; .chub_orphan_restore subdir is skipped
include_collections: true # treat Plex collection titles as part of the comparison setWhat it does. Mirrors tags in Radarr/Sonarr into Plex labels. If you tag an item favorite in Sonarr, it shows up with the favorite label in the Plex library you've mapped.
Cancellable: yes.
Gotcha: label updates are applied in batch — if you untag a large number of items in the ARR, expect the corresponding Plex labels to update on the next run, not instantly.
labelarr:
dry_run: false
log_level: info
mappings:
- app_instance: sonarr_main
labels: [watched, favorite]
plex_instances:
- instance: plex_main
library_names: ["TV Shows"]
- app_instance: radarr_main
labels: [favorite]
plex_instances:
- instance: plex_main
library_names: ["Movies"]What it does. Finds duplicate files across your media tree by content hash. Persists hashes to a database so repeat runs are incremental instead of re-hashing everything.
Cancellable: yes.
Gotcha: the first run on a large library takes hours. Subsequent runs are fast because only new/changed files are rehashed. hash_database can't contain null bytes or start with - (a safety check — see Troubleshooting if you hit it).
jduparr:
dry_run: false
log_level: info
hash_database: /config/jduparr.db
source_dirs:
- /media/movies
- /media/tvWhat it does. Finds media files that aren't hardlinked to your downloader's completed directory, which typically means a broken rename or a file that was re-imported without a hardlink. Optionally re-queues an upgrade search in the ARR to fix them.
Cancellable: yes.
nohl:
dry_run: false
log_level: info
searches: 10 # how many re-searches to queue per run
print_files: false # log the full list of non-hardlinked files
source_dirs:
- path: /media/movies
mode: movie # movie | series
- path: /media/tv
mode: series
exclude_profiles: [] # ARR quality-profile names to skip
exclude_movies: [] # movie titles to skip
exclude_series: [] # series titles to skip
instances:
- radarr_main
- sonarr_mainWhat it does. Reports media items that don't have a matching poster in your renamed tree. Runs standalone or as a post-hook on poster_renamerr (set report_unmatched_assets: true on poster_renamerr to chain them).
Cancellable: yes.
unmatched_assets:
dry_run: false
log_level: info
ignore_folders: [] # folders to skip while scanning posters
ignore_profiles: [] # ARR quality profiles to ignore
ignore_titles: [] # media titles to ignore
ignore_tags: [] # ARR tags to ignore
ignore_collections: [] # Plex collection names to ignore
ignore_unmonitored: false # skip unmonitored items entirely
instances:
- radarr_main
- sonarr_main
- plex_mainWhat it does. Picks a fixed number of items per ARR instance that haven't been searched recently, and fires an upgrade search on them. Tags items after searching so it doesn't pick the same ones again right away. Lidarr is fully supported — album search, artist grouping, and all three search modes.
Cancellable: yes.
count_mode (Sonarr + Lidarr only). Controls what count actually meters:
| Mode | What count caps |
Tagging | Use when |
|---|---|---|---|
series_artist (default)
|
Number of series / artists processed per run. Every monitored season / album of each gets searched. | Parent tagged after one pass. | You want a steady rotation through your library and your indexer can handle large search bursts. |
season_album |
Total number of individual SeasonSearch / AlbumSearch calls per run. |
Parent tagged only when all its monitored children have been searched across however many runs that takes. | You want a hard ceiling on per-run tracker / indexer load. |
In season_album mode, progress is persisted to a small SQLite table so a long-running series doesn't restart at season 1 every run. Once every monitored season of a series (or album of an artist) has been searched, the parent gets the marker tag and rotates out. Switching modes is safe — leftover progress rows are cleared automatically when a parent next gets tagged.
upgradinatorr:
dry_run: false
log_level: info
instances_list:
- instance: radarr_main
count: 10 # items to search per run
tag_name: chub-upgradinatorr # tag applied after search
ignore_tag: ignore # skip items carrying this tag
unattended: false
search_mode: upgrade # upgrade | missing | cutoff
- instance: sonarr_main
count: 5 # interpreted by count_mode
count_mode: season_album # series_artist (default) | season_album
tag_name: chub-upgradinatorr
ignore_tag: ignore
season_monitored_threshold: 0.5 # Sonarr: require ≥ this fraction of monitored seasons
search_mode: upgrade
- instance: lidarr_main
count: 5
count_mode: season_album # 5 album searches per run, resumes mid-artist
tag_name: chub-upgradinatorr
ignore_tag: ignore
search_mode: upgradeWhat it does. Walks Radarr/Sonarr and applies the ARR's own naming scheme to existing files — useful after you change your naming template and don't want to re-import everything.
Cancellable: yes.
renameinatorr:
dry_run: false
log_level: info
rename_folders: true
count: 100 # total items per run (used when radarr_count/sonarr_count = 0)
radarr_count: 0 # override per type
sonarr_count: 0
tag_name: chub-renameinatorr
ignore_tags: "" # comma-separated list of tags to skip
enable_batching: false # batch API calls for speed
instances:
- radarr_main
- sonarr_mainWhat it does. Polls each ARR's built-in health / queue / missing lists and surfaces problems via notification. report_only: true turns it into a pure notifier (no remediation).
Cancellable: yes.
health_checkarr:
dry_run: false
log_level: info
report_only: false
instances:
- radarr_main
- sonarr_main
- lidarr_mainWhat it does. Scans for two kinds of library problems and reports them — it never moves or deletes anything. First, it compares your ARR (Radarr / Sonarr / Lidarr) cache against Plex and flags mismatches (items in an ARR that haven't landed in Plex, and items in Plex that aren't tracked by any ARR). Second, it detects nested paths — tracked media whose folder sits inside another tracked item's folder, which usually means a botched import. You get the list; you decide what to fix.
Cancellable: yes.
nestarr:
log_level: info
library_mappings: # scope to specific ARR↔Plex library pairs
- arr_instance: radarr_main
plex_instances:
- instance: plex_main
library_names: ["Movies"]
- arr_instance: sonarr_main
plex_instances:
- instance: plex_main
library_names: ["TV Shows"]
path_mapping: # translate ARR paths → Plex paths when volumes differ
- arr_prefix: /media/movies
plex_prefix: /data/moviesWhat it does. Pulls poster assets from Google Drive folders into a local directory, using rclone under the hood. Supports OAuth tokens or a service-account JSON file.
Cancellable: yes.
Gotcha: sync_location, gdrive_sa_location, and folder IDs can't contain null bytes or start with - (a safety check to keep user input from being interpreted as rclone flags).
For setup — Google service account creation, rclone OAuth flow, headless token generation — see the DAPS wiki's rclone configuration guide. CHUB uses the same rclone backend, so the steps apply unchanged.
sync_gdrive:
dry_run: false
log_level: info
gdrive_sa_location: /config/gdrive-sa.json # preferred — service account JSON
# OR the OAuth client triple (alternative to gdrive_sa_location):
# client_id: "<oauth-client-id>"
# client_secret: "<oauth-client-secret>"
# token: "<rclone-token-json>"
gdrive_list:
- id: "<google-drive-folder-id>"
location: /posters/gdrive-pull
name: "Community poster mirror"What it does. Runs Plex-side housekeeping tasks on their own schedule, split out from poster_cleanarr so you can run heavier server chores (database VACUUM, bundle cleanup) less often than the fast poster sweep. Four independent toggles:
-
empty_trash— callslibrary.emptyTrash()to permanently remove items Plex has marked for deletion. -
clean_bundles— callslibrary.cleanBundles()to drop orphaned.bundledirectories from disk. -
optimize_db— callslibrary.optimize()to VACUUM Plex's SQLite metadata DB. Longest-running of the four; run it monthly, not daily. -
photo_transcoder— directly deletes$PLEX/Cache/PhotoTranscoder/files on disk. Doesn't need a Plex API connection, so runs even if the Plex server is unreachable.
Cancellable: partial. The photo_transcoder cleanup loop checks the cancel flag per file and stops cleanly. The three Plex-API tasks (empty_trash, clean_bundles, optimize_db) run to completion — Plex's API has no interrupt, so once we've told the server to VACUUM, we have to let it finish. Restart the container if you need to kill one of those mid-run.
Gotcha: photo_transcoder requires plex_path to be set and pointed at the Plex server's application support directory (the one containing Cache/PhotoTranscoder/). If plex_path is empty, photo transcoder cleanup is silently skipped.
plex_maintenance:
log_level: info
empty_trash: true
clean_bundles: true
optimize_db: false # heavier — run on a slower cron
photo_transcoder: true
plex_path: /plex # Plex application-support dir (for photo cache cleanup)
instances:
- plex_main