Skip to content

Modules

chodeus edited this page Jun 11, 2026 · 26 revisions

Modules

CHUB ships fifteen 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:


What every module supports

  • 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. (The two report-only modules, unmatched_assets and nestarr, never change anything, so they have no dry_run flag.)
  • Log leveldebug / info / warning / error, per module. Default is info. Flip to debug while 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_replacerr is the lone full exception — it runs to completion. plex_maintenance is 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.

🖼️ poster_renamerr

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
  apply_method: kometa                  # kometa (write to destination_dir) | plex (upload to Plex)
  action_type: copy                     # copy | move | hardlink (kometa path)
  asset_folders: true                   # expect Kometa-style per-item folders (kometa path)
  sync_posters: false
  print_only_renames: false
  run_border_replacerr: false           # chain border_replacerr after rename
  run_asset_renamerr: false             # chain asset_renamerr (logos/art) after rename
  clean_orphan_assets: false            # chain orphan-asset cleanup after rename
                                        # (mode comes from poster_cleanarr's orphan_assets_mode)
  report_unmatched_assets: false        # chain unmatched_assets report
  upload_delay_ms: 0                    # optional pause (ms) after each Plex upload
  source_dirs:
    - /kometa
  destination_dir: /posters
  instances:
    - radarr_main
    - sonarr_main
    - plex_main:
        library_names: ["Movies", "TV Shows"]
        add_posters: true               # plex apply_method only: upload to this instance

Apply method (plex or kometa). poster_renamerr does one or the other, not both. With kometa (the default) it renames/copies each matched poster into destination_dir using Kometa's naming for Kometa to apply — action_type, asset_folders, and destination_dir govern this path, and nothing is uploaded to Plex. With plex it uploads the poster straight to Plex via the API for the instances whose per-instance add_posters: true is set, and nothing is written to destination_dir. Upgrading: the default is kometa, so if you previously relied on add_posters to push to Plex, set apply_method: plex.

Source priority (when listing multiple source_dirs). Directories are processed top → bottom and later entries overwrite earlier ones for the same item. The bottom of the list wins — put your highest-priority source last. So if you want a Google Drive sync to override a local/manual folder, list the local folder first and the Drive folder below it. (The drag-to-reorder list in the UI uses the same convention: bottom takes precedence.)

How matching works (and why your media folder names don't affect it)

poster_renamerr matches each poster file against the *metadata your arr already holds — pulled live from the Radarr/Sonarr API: title, year, TVDb/TMDb/IMDb id, and (for shows) season number. It does not parse your media folder or file names to decide a match.

So how you name your library has no effect on match quality. Whether your show folder is The Series Title (2010) {tvdb-1520211} or just The Series Title, and whether your season folder is Season 1 or Season 01, Sonarr/Radarr hand CHUB the correct id and season number either way. Follow the TRaSH naming schemes — they're the right choice for your media server and for where the applied poster lands — but they are not a lever for matching.

What it actually matches on, in order:

  1. ID (TVDb → TMDb → IMDb). The most reliable signal. It fires only when the poster file also carries the id (e.g. Show (2020) {tvdb-1520211} - Season 1.jpg). Your *arr always has the id; the poster set often does not.
  2. Title + year + season, when there's no shared id. The poster's title/year/season are parsed from its filename, then compared (normalized) against the *arr's title, alternate/AKA titles, and the title parsed from the media folder. A missing year on either side no longer blocks the match; a year present on both sides that disagrees does.

What actually drives your match rate is therefore on two things you can control:

  • *Clean arr metadata — correct id and title for each item (Sonarr=TVDb, Radarr=TMDb by design, so this is usually a given).
  • A well-named poster setTitle (Year) - Season N.jpg for season art, ideally with {tmdb-…}/{tvdb-…} id tags so it matches by id. This is the GDrive/Kometa poster set, not your library folders.

Where folder naming does matter: the destination. When a poster is applied, poster_renamerr writes it into a folder named after the media's *arr folder (and Season01.jpg for seasons) so Kometa/Plex pick it up next to the media. Consistent library naming keeps that tidy — but it happens after the match, not as part of it.

TL;DR — Poster matching reads your *arr's API, not your disk. Name your library however TRaSH recommends; to improve matching, focus on the poster set's filenames (and id tags), not your media folders.


🪧 asset_renamerr

What it does. Applies the non-poster artwork types — logo, square art, and background/fanart — to your matched media. It reuses poster_renamerr's matching engine (same ARR/Plex identity matching, same bottom-wins source priority) but for these extra image types instead of posters.

Cancellable: yes.

Where the images come from (sources, in priority order)

sources is an ordered list — the first source that has an image for a given item wins:

  • local — files scanned from source_dirs, named like the poster convention plus a type tag: Title (Year) {tmdb-123} - Logo.png, … - Background.png, … - SquareArt.png. These come from your Google Drive / Kometa asset sets.

  • fanart — fetched live from fanart.tv (movies by tmdb_id, TV by tvdb_id). fanart.tv is the curated, community-ranked source for logos and backgrounds, so CHUB picks the most-liked image in your preferred language (logos prefer your language then textless; backgrounds prefer textless; ties broken by likes). For TV, season backgrounds are matched to the season. fanart.tv supplies logo and background only — there is no square art, so square art always comes from local.

    Requires your personal fanart.tv API key under Settings → fanart.tv — a personal key authenticates on its own, so it's all CHUB needs (no project key to obtain or share). It's free at fanart.tv/get-an-api-key and cuts the delay for newly-added artwork from 7 days to 2 (immediate for VIP). Without a personal key the fanart source is skipped and CHUB falls back to local.

["local", "fanart"] (the default) prefers your curated g-drive art and falls back to fanart.tv. Flip the order to prefer fanart.tv. (Existing configs that listed tmdb are migrated to fanart automatically.)

Naming convention for local files (what the matcher expects)

local files use the same convention as postersTitle (Year) {tmdb-123} (the year and {tmdb-…}/{tvdb-…}/{imdb-…} id tags are matched exactly as for posters) — plus a type tag that tells CHUB which artwork slot the file fills:

File ends with… Detected as Applied?
… - Logo.png logo
… - Background.png background
… - SquareArt.png square art ✅ Plex only
… - Banner.png banner ❌ recognised but never applied (see below)
(no tag)Title (Year).png poster handled by poster_renamerr, not here

Matching rules CHUB enforces on the tag:

  • The delimiter is mandatory. The tag must be separated from the title by either - (a hyphen, optionally padded with spaces) or an underscore _. Movie (1999) - SquareArt.png and Movie (1999)_SquareArt.png both work; Movie (1999) SquareArt.png (no delimiter) does not — it's treated as a poster. This requirement is what keeps a film literally named "Logo" or "Open Background" from being mistaken for an asset tag.
  • Case doesn't matter. SquareArt, squareart, and SQUAREART are all equivalent (likewise Logo/logo, Background/background).
  • The tag is stripped before the title is matched, so an asset file matches the same media item as that item's poster.

How it applies them (apply_method)

Plex-vs-Kometa capability matrix — which asset types can actually be applied on each path:

apply_method logo background squareart
plex — upload straight to Plex via the API (legacy value direct is still accepted and treated as plex)
kometa — rename/copy into destination_dir for Kometa to apply logo.ext background.ext ❌ Kometa asset dirs don't read square art

Banner is not supported at all — Plex has no banner-upload API and Kometa doesn't read banners from asset directories, so it isn't an option. Unsupported combinations (square art on the kometa path) are skipped with a warning. The default asset_types is therefore ["logo", "background"] — the two that work on both methods. Add squareart explicitly if you upload directly.

On the kometa path, season art uses Kometa's Season##_logo.ext / Season##_background.ext naming, matching Kometa PR #2681.

Running it: standalone or chained

The cheap way to run it is chained from poster_renamerr: set poster_renamerr.run_asset_renamerr: true and the asset pass runs at the end of every poster run, reusing that run's Google Drive sync, source-dir scan, and Plex/media snapshot — nothing is fetched twice. Asset files (- Logo, - Background, …) are only scanned into the cache when this is enabled, so users who don't use the feature carry zero overhead.

It also runs standalone (own schedule / Run now), doing its own optional sync_assets + scan.

Idempotent: an asset that was already applied and hasn't changed (local file mtime unchanged, or same fanart.tv URL) is skipped — a scheduled run won't re-push everything to Plex or re-hit fanart.tv each time.

asset_renamerr:
  dry_run: false
  log_level: info
  print_only_renames: false             # true = only list applied assets in the output log
  sources: [local, fanart]              # ordered; first hit wins (local | fanart)
  asset_types: [logo, background]       # + squareart (plex only); banner unsupported
  apply_method: kometa                  # kometa | plex (legacy "direct" = plex)
  action_type: copy                     # copy | move | hardlink | symlink (kometa path)
  asset_folders: false                  # match your Kometa asset_folders setting
  destination_dir: /posters             # kometa path: where renamed art lands
  source_dirs:
    - /kometa                           # local source (same convention as posters)
  sync_assets: false                    # run sync_gdrive first (standalone only)
  tmdb_language: [en]                   # preferred image languages (used for fanart.tv selection)
  instances:
    - radarr_main
    - sonarr_main
    - plex_main:
        library_names: ["Movies", "TV Shows"]
        add_posters: true               # plex apply_method only: upload to this instance

Gotcha: for the plex path (formerly direct), art is uploaded only to Plex instances with add_posters: true, and within those to every library the item lives in (e.g. a 1080p and a 4K library both get it). For the kometa path, asset_folders must match your Kometa config (<Title (Year)>/logo.png vs <Title (Year)>_logo.png).


🎨 border_replacerr

What it does. Re-applies a brand or holiday border to every matched poster. Two modes:

  • Color mode (default): crops border_width pixels off all four edges and re-paints a flat colored border using a cycling color from border_colors. Holiday windows substitute that holiday's colors list.
  • 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_width is 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.

Where each setting lives in the UI

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, 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.

If two holidays overlap, whichever is listed first wins.

Bundled themed borders

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 v1v4 12/30 – 01/02
🧧 Lunar New Year v1v4 01/20 – 02/20
💘 Valentine's Day v1v5 02/05 – 02/15
🍀 St. Patrick's Day v1v4 03/14 – 03/18
🐣 Easter v1v4 03/31 – 04/02
🌸 Mother's Day v1v4 05/10 – 05/15
👨‍👧‍👦 Father's Day v1v4 06/15 – 06/20
🏳️‍🌈 Pride v1v5 06/01 – 06/30
🗽 Independence Day v1v4 07/01 – 07/05
🧹 Labor Day v1v4 09/01 – 09/07
🎃 Halloween v1v5 10/01 – 10/31
🦃 Thanksgiving v1v4 11/01 – 11/30
🎄 Christmas v1v5 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:

Christmas v1 Halloween v1 Valentine's v1 Pride v1

Browse the full set in backend/assets/borders/.

Custom borders

Drop your own PNGs into /config/borders/<holiday-folder>/ to extend or override the bundled set. Resolution order at runtime:

  1. /config/borders/<holiday-folder>/<name>.png (user) — wins over
  2. /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.

Holiday folder slugs

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. Anniversaryanniversary, Diwali 2026diwali2026.

PNG technical requirements

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).

Naming convention for the borders: config

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.png

Both 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.

Quick workflow for adding a custom border

  1. 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.
  2. Export at exactly 1000 × 1500 with the inner (60, 60) to (940, 1440) area transparent.
  3. Save into /config/borders/<holiday-folder>/<your-name>.png on your host (the mounted config volume).
  4. 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.yml directly — both paths write the same borders: list.)
  5. 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.

Config

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)
  border_workers: null                  # advanced: thread pool size for the re-encode pass; null = min(8, CPU cores)
  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.png

🎬 cl2k_maker

What it does. Generates CL2K-style posters (contain-fit backdrop, black-fill bottom, template gradient, logo or typeset wordmark) from TMDB/fanart.tv art, saves them into output_dir, and records them in the poster cache so poster_renamerr can match and apply them. Has an interactive editor page (Posters → CL2K Maker) for search, art picking, framing, preview, season batches, PSD export, and an optional AI pass that erases baked-in text from backdrops.

Batch runs cover movies and shows only. Collections and seasons are fully supported, but only on demand from the CL2K Maker page or API — they don't participate in scheduled batch generation.

Cancellable: yes (batch runs).

cl2k_maker:
  log_level: info
  enabled: false
  output_dir: /posters/cl2k             # generated posters land here — add it to poster_renamerr.source_dirs
  language: en                          # preferred logo/backdrop language
  logo_max_width: 600                   # px; CL2K guide standard 600, max 800
  whiten_logo: true                     # recolor dark logos to white
  text_logo_fallback: true              # synth a typeset wordmark when no real logo exists
  skip_existing: true                   # batch runs skip items that already have a CL2K poster
  style: CL2K                           # poster_cache style tag
  priority: 0
  # Optional Google Drive upload of finished posters (rclone copy).
  # Uses the sync_gdrive OAuth token — service accounts can't own files
  # in a personal Drive, so there is no per-module SA option.
  upload_to_gdrive: false
  gdrive_folder_id: ""
  # Optional AI text removal for backdrops with baked-in titles.
  ai_provider: none                     # none | lama_sidecar | openai | huggingface
  ai_endpoint: ""                       # lama sidecar URL, or HF inference URL
  ai_api_key: ""                        # openai / huggingface token
  ai_model: ""                          # openai model id (default gpt-image-1) / HF model id
  ai_timeout: 120

The API lives under /api/cl2k-maker/*search, resolve, images, season-images, fanart-images, external-ids, preview, generate, generate-seasons, generated, upload-generate (manual-cleanup handoff), upload-poster, retext, psd-export, and upload-status.


🧹 poster_cleanarr

What it does. Two independent cleanup operations in one module:

  1. 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_url and user_square_art_url so custom-uploaded logos and square art aren't wrongly flagged. Driven by the top-level mode field.

  2. Orphan-asset cleanup — walks asset_dirs and 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's media_cache + collections_cache, populated by poster_renamerr's last sync — no live API calls. Catches assets placed by other tools or stranded when CHUB never tracked the source media. Driven by orphan_assets_enabled + orphan_assets_mode.

Not the same as the unmatched_assets module. 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
  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
  orphan_instances: []                  # instances for the orphan comparison set; empty = use `instances`
  orphan_ignore_titles: []              # media titles to exclude from orphan matching
  asset_dirs: []                        # walked recursively; .chub_orphan_restore subdir is skipped
  include_collections: true             # treat Plex collection titles as part of the comparison set

🏷️ labelarr

What 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"]

🔍 jduparr

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/tv

🔗 nohl

What 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: resolve                     # scan | resolve (default: resolve)
    - path: /media/tv
      mode: resolve
  exclude_profiles: []                  # ARR quality-profile names to skip
  exclude_movies: []                    # movie titles to skip
  exclude_series: []                    # series titles to skip
  instances:
    - radarr_main
    - sonarr_main

Source-dir modes (mode): resolve (the default) matches each non-hardlinked file back to its ARR instance and queues the re-searches; scan only reports the non-hardlinked files for that path in the run output, without ARR processing. Use scan for directories you want visibility on but don't want CHUB touching.


unmatched_assets

What 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.

Filtering — what's actually flagged

A media item shows up as unmatched only when all of these hold:

  1. Cache says matched=0poster_renamerr hasn't linked a poster to it.
  2. Status is actionable. The *arr-reported status isn't in {announced, tba, upcoming, deleted}. This drops announced sequels and removed-from-TMDB rows that can never have a real poster yet. inCinemas, released, continuing, ended, and anything unknown all pass.
  3. Content exists (when known). For Radarr movies: hasFile is true. For Sonarr seasons: at least one episode is actually downloaded — i.e. Sonarr's episodeFileCount > 0. Not episodeCount, which would tick up the moment an episode airs even if you haven't grabbed it. A current-season show whose new season has aired episodes but no downloads stays out of the report.
  4. Passes the user ignore_* filters — the lists below. Notably, ignore_unmonitored: true honours the per-season monitored flag, so unmonitoring just the Specials season of a show correctly drops S0 from the report without affecting the show's other seasons.

The two-gate design (status and has_content) means a stale cache row whose has_content defaulted to True still gets filtered out if the *arr status says the item isn't ready, and a freshly-synced row whose *arr status is unknown still gets filtered if the file is genuinely missing.

Where to act on the report

Open Assets → Statistics (/poster/statistics). The page lists unmatched movies, series, and collections in collapsible tables. Each row has a Request column with a copy-to-clipboard button — clicking it puts a Discord-ready block on the clipboard:

Movie Title (2024)
https://www.themoviedb.org/movie/12345

For series the block also includes Missing main poster and/or Missing seasons: 1, 3, 5 lines when applicable. When the series has exactly one missing season (and the main poster is already covered), the TMDb link points directly at that season's page (https://www.themoviedb.org/tv/{id}/season/{n}) so the recipient lands on the right poster spread instead of the show's main page. If the row only has a TVDb id (legacy pre-sync rows), the link falls back to TVDb at the show level and a toast warns to add the TMDb link before posting. From there I search the usual G-drive sources and TPDB myself and pick the right forum tag in Discord.

Config

unmatched_assets:
  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_main:                        # dict form narrows a Plex instance to specific libraries
        library_names: ["Movies", "TV Shows"]

String-form instances are always fully included; the dict form (same pattern as poster_renamerr) limits which libraries of a Plex instance count toward the report.


⬆️ upgradinatorr

What 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: upgrade

✏️ renameinatorr

What 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 ARR tag names; items carrying any are skipped
  enable_batching: false                # batch API calls for speed
  instances:
    - radarr_main
    - sonarr_main

🩺 health_checkarr

What it does. Checks each ARR's health feed for items flagged as removed from TMDB/TVDB (Radarr's RemovedMovieCheck, Sonarr's RemovedSeriesCheck) and deletes those items from the ARR. Use dry_run: true to see what would be deleted without remediating. Radarr and Sonarr only — Lidarr is not supported.

Cancellable: yes.

health_checkarr:
  dry_run: false
  log_level: info
  instances:
    - radarr_main
    - sonarr_main

🪺 nestarr

What 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/movies

☁️ sync_gdrive

What 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
  verbose: false                        # log individual file actions (Copied/Deleted/Updated/Renamed)
  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"

🧼 plex_maintenance

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 — calls library.emptyTrash() to permanently remove items Plex has marked for deletion.
  • clean_bundles — calls library.cleanBundles() to drop orphaned .bundle directories from disk.
  • optimize_db — calls library.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
  dry_run: false                # log what each task would do without changing anything
  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)
  sleep: 60                     # seconds to pause between the Plex-API tasks
  timeout: 600                  # seconds to wait for the Plex connection
  instances:
    - plex_main

Clone this wiki locally