Sonarr for Sports – automated sports file matching, naming, and organization for Plex.
Playbook watches your downloads, identifies what each file actually is (race, match, event), then creates clean TV-style files and folders in your media library.
Sports releases are messy:
- Inconsistent naming (
F1,Formula.1,R05,Round 5, etc.) - Different season models by sport (events, rounds, weeks)
- Manual renaming and moving every day
Playbook fixes that by combining:
- Metadata-driven matching via TVSportsDB API (show/season/episode model)
- Built-in sport pattern packs (F1, Formula E, MotoGP, UFC, NFL, NBA, NHL, Premier League, Champions League, IndyCar, WSBK, WTA, Figure Skating, and more)
- Smart linking (
hardlink,copy,symlink) into your Plex structure - Quality-based upgrade scoring (resolution, framerate, codec, source, release group)
- Optional notifications (Discord, Slack, Webhook, Email) and Plex/Autoscan integrations
This is the easiest first run. Copy, edit two paths, and start.
services:
playbook:
image: ghcr.io/s0len/playbook:latest
container_name: playbook
restart: unless-stopped
environment:
TZ: UTC
GUI_PORT: 8765
CONFIG_PATH: /config/config.yaml
ports:
- "8765:8765"
volumes:
- ./config:/config # config + persistent state db
- /path/to/downloads:/data/source
- /path/to/library:/data/destination
- ./cache:/data/cache # metadata cache (can be tmpfs if desired)Start it:
docker compose up -dOpen GUI:
http://<host-ip>:8765
On first run, Playbook auto-creates /config/config.yaml if missing. You can then configure everything in the GUI and click Save.
docker run -d \
--name playbook \
-p 8765:8765 \
-e TZ=UTC \
-e GUI_PORT=8765 \
-e CONFIG_PATH=/config/config.yaml \
-v ./config:/config \
-v /path/to/downloads:/data/source \
-v /path/to/library:/data/destination \
-v ./cache:/data/cache \
ghcr.io/s0len/playbook:latest- Open
Settingsin the GUI. - Verify
Source DirectoryandDestination Directory. - Keep default
State Directorypersistent (/config/state). - Enable the sports you want.
- Click
Save. - Trigger processing from dashboard or wait for watcher mode.
Playbook separates persistent state from cache:
state_dir(default:/config/state): SQLite databases and durable app state. Must persist across restarts.cache_dir(default:/data/cache): Metadata cache and trace artifacts. Disposable/temporary.
This means you can keep cache_dir disposable/temporary while preserving match history and manual overrides in state_dir.
- Playbook
- What Playbook Solves
- TL;DR Quick Start (Docker Compose)
- Minimal
docker runAlternative - First-Run Checklist
- Storage Model (Important)
- Architecture at a Glance
- Configuration Deep Dive
- Run Modes & CLI
- Plex Library Setup
- Adding New Sports
- Troubleshooting & FAQ
Use the bjw-s/app-template chart with Flux to keep a cluster deployment reconciled. The example below mirrors the Docker settings and keeps persistent state under /settings:
# yaml-language-server: $schema=https://raw.githubusercontent.com/bjw-s-labs/helm-charts/main/charts/other/app-template/schemas/helmrelease-helm-v2.schema.json
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: &app playbook
spec:
interval: 30m
chartRef:
kind: OCIRepository
name: app-template
install:
remediation:
retries: 3
upgrade:
cleanupOnFail: true
remediation:
strategy: rollback
retries: 3
values:
serviceAccount:
playbook: {}
controllers:
playbook:
serviceAccount:
identifier: playbook
type: deployment
annotations:
reloader.stakater.com/auto: "true"
containers:
app:
image:
repository: ghcr.io/s0len/playbook
tag: develop # Pin to a digest for production: tag: develop@sha256:...
pullPolicy: Always
env:
CONFIG_PATH: /settings/config.yaml
SOURCE_DIR: /data/torrents/sport
DESTINATION_DIR: /data/media/sport
CACHE_DIR: /settings/cache
STATE_DIR: /settings/state
LOG_DIR: /tmp
LOG_LEVEL: INFO
GUI_PORT: &port 8765
PLEX_URL: http://plex:32400
PLEX_LIBRARY_NAME: Sport
envFrom:
- secretRef:
name: playbook-secret # DISCORD_WEBHOOK_URL, PLEX_TOKEN, etc.
probes:
startup:
enabled: true
custom: true
spec:
httpGet:
path: /healthz
port: *port
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 30
liveness:
enabled: true
custom: true
spec:
httpGet:
path: /healthz
port: *port
periodSeconds: 15
failureThreshold: 5
readiness:
enabled: true
custom: true
spec:
httpGet:
path: /healthz
port: *port
periodSeconds: 10
failureThreshold: 3
service:
app:
controller: *app
ports:
http:
port: *port
route:
app:
hostnames:
- "{{ .Release.Name }}.${SECRET_DOMAIN}"
parentRefs:
- name: envoy-internal
namespace: network
sectionName: https
defaultPodOptions:
automountServiceAccountToken: true
enableServiceLinks: false
securityContext:
runAsUser: 568
runAsGroup: 568
runAsNonRoot: true
fsGroup: 568
fsGroupChangePolicy: OnRootMismatch
persistence:
settings:
existingClaim: playbook-settings # 1Gi ceph-block PVC
globalMounts:
- path: /settings
tmp:
type: emptyDir
medium: Memory
globalMounts:
- path: /tmp
data:
type: nfs
server: "${TRUENAS_IP}"
path: /mnt/rust/data
globalMounts:
- path: /dataQuick checklist:
- Create a
playbook-secretwith sensitive values (kubectl create secret generic playbook-secret --from-literal=DISCORD_WEBHOOK_URL=... --from-literal=PLEX_TOKEN=...), or use an ExternalSecret backed by 1Password/Vault/etc. - Persist
/settings(PVC) for the SQLite state databases. Cache lives under the same PVC in this example. - Enable
file_watcher.enabled(or setWATCH_MODE=true) to keep Playbook running continuously; leave it disabled for ad-hoc batch runs. - Add
reloader.stakater.com/auto: "true"to hot-reload when the config map or secrets change. - Set
fsGroupChangePolicy: OnRootMismatchfor Ceph RBD PVCs to avoid slow recursive permission changes.
Under the hood, Playbook follows this flow:
┌─────────────────┐ fetch + cache ┌─────────────────────┐
│ TVSportsDB API │ ──────────────────▶ │ Metadata Normalizer │
└─────────────────┘ └────────┬────────────┘
│ normalized Show/Season/Episode
┌───────▼────────┐
source files + globs + aliases │ Matching Engine │
────────────────────────────────────────▶│ (regex + fuzzy)│
└───────┬────────┘
│ context (season, episode, templates)
┌───────▼────────┐
│ Templating │
│ & Sanitization │
└───────┬────────┘
│ destination path
┌───────▼────────┐
│ Link/Copy/Sym │
└────────────────┘
- Metadata fetch & cache: Playbook fetches show/season/episode data from the TVSportsDB REST API via
httpx, caches responses in a SQLite store with TTL and ETag support. Legacy YAML URL metadata is still supported for backwards compatibility. - Normalization: structured dataclasses infer round numbers, preserve summaries, and attach aliases (including team aliases for sports like NHL and Premier League).
- Matching: regex capture groups, alias tables, and fuzzy matching (rapidfuzz) link filenames to metadata episodes.
- Templating: rich context feeds customizable templates for root folders, season directories, and filenames.
- Action: files are hardlinked (default), copied, or symlinked into the library, with quality-based upgrade rules.
Start with config/config.sample.yaml. The schema mirrors playbook.config dataclasses.
| Field | Description | Default |
|---|---|---|
source_dir |
Root directory containing downloads to normalize. | /data/source |
destination_dir |
Library root where organized folders/files are created. | /data/destination |
cache_dir |
Metadata cache directory. Safe to delete to force refetch. | /var/cache/playbook |
state_dir |
Persistent state directory for SQLite DBs and durable runtime state. | /config/state |
theme |
GUI color theme. | swizzin |
dry_run |
When true, logs intent but skips filesystem writes. |
false |
link_mode |
Default link behavior: hardlink, copy, or symlink. |
hardlink |
use_default_sports |
Auto-load all built-in sports from pattern templates. | true |
disabled_sports |
List of sport IDs to exclude from defaults (e.g. ["formula_e", "moto2"]). |
[] |
force_reprocess |
Bypass processed-file database and reprocess all files. | false |
include_patterns |
Only process files matching these globs (empty = all). E.g. ["**/*.mkv", "**/*.mp4"]. |
[] |
ignore_patterns |
Skip files matching these globs. E.g. ["*sample*", "*.part"]. |
[] |
file_watcher.enabled |
When true, Playbook keeps running and reacts to filesystem events; when false, a single pass and exit. |
false |
file_watcher.paths |
Directories to observe; defaults to source_dir when empty. |
[] |
file_watcher.debounce_seconds |
Minimum seconds between watcher-triggered runs. | 5 |
file_watcher.reconcile_interval |
Forces a full scan every N seconds even if no events arrive. | 900 |
destination.* |
Default templates for root folder, season folder, and filename. | See sample |
The integrations section configures direct connections to media servers. These are separate from notifications — integrations interact with APIs, while notifications alert users.
settings:
integrations:
plex:
url: ${PLEX_URL:-http://plex:32400}
token: ${PLEX_TOKEN:-}
library_name: ${PLEX_LIBRARY_NAME:-Sports}
# Sync metadata (titles, summaries, posters) from TVSportsDB to Plex
metadata_sync:
enabled: false
timeout: 15
scan_wait: 5 # Seconds to wait after triggering library scan
lock_poster_fields: false # Prevent Plex from overwriting custom posters
# Trigger Plex partial library scan when files are linked
scan_on_activity:
enabled: false
rewrite: [] # Path mapping if Plex sees different mount paths
# - from: /data/destination
# to: /mnt/plex/mediaMetadata Sync pushes titles, sort titles, summaries, posters, and backgrounds from TVSportsDB to Plex. It uses fingerprint-based change detection to only update what has changed, and preserves Plex title casing.
Scan on Activity triggers partial Plex library scans via the Plex API whenever files are linked, so Plex picks up new files immediately without waiting for scheduled scans.
settings:
integrations:
autoscan:
enabled: false
url: ${AUTOSCAN_URL:-http://autoscan:3030}
trigger: manual
username: ${AUTOSCAN_USERNAME:-}
password: ${AUTOSCAN_PASSWORD:-}
rewrite: []Autoscan support mirrors the manual trigger endpoint: Playbook issues a POST /triggers/<name>?dir=... call with the directory that just received a processed file.
settings:
notifications:
scan_summary: true # Send a summary after each scan with activity
targets:
- type: discord
webhook_env: DISCORD_WEBHOOK_URL
# mentions:
# formula1: "<@&123456789>"
# - type: slack
# webhook_url: https://hooks.slack.com/...
# - type: webhook
# url: https://example.com/webhook
# - type: email
# smtp_host: smtp.gmail.com
# - type: plex_scan # Alternative way to configure Plex scans
# - type: autoscan # Alternative way to configure AutoscanSupported type values:
discord— Rich embeds with optional per-sport mentionsslack— Simple text payload, optional templatewebhook— Generic JSON payload, fully templatableemail— SMTP with configurable subject/body templatesautoscan— Ping Autoscan to rescan a directory (alternative tointegrations.autoscan)plex_scan(orplex) — Trigger Plex partial library scans (alternative tointegrations.plex.scan_on_activity)
Note: If you configure Plex or Autoscan under
integrations, Playbook auto-creates the corresponding notification targets. You only need entries undernotifications.targetsif you want to override settings or use multiple targets.
Use notifications.mentions to opt specific Discord roles or users into certain sports. Entries are keyed by the sport's ID (plus an optional default fallback) and the value is any mentionable string (<@&ROLE_ID>, @here, etc.). Keys can include shell-style wildcards (e.g. formula1_*), and Playbook automatically falls back to the base ID before any variant suffix (premier_league also covers premier_league_2025_26). Mentions are prepended to notification messages, so subscribers only get pinged for the sports they care about:
notifications:
mentions:
premier_league: "<@&123456789012345678>"
formula1: "<@&222333444555666777>" # Automatically applies to formula1_2025, formula1_2026, etc.
default: "@everyone" # optional fallback when no explicit entry existswebhook_env tells Playbook to read the runtime environment for the URL, so you can mount a Kubernetes/Docker secret as env vars without ever writing the secret into the ConfigMap. If you already have your own templating flow you can continue to use webhook_url with ${VAR} substitution; both options are supported.
Playbook can score files and automatically upgrade to higher-quality releases when better versions appear.
settings:
quality_profile:
enabled: false
scoring:
resolution:
2160p: 400
1080p: 300
720p: 200
source:
webdl: 90
webrip: 70
hdtv: 50
frame_rate:
"60": 100
"50": 75
codec:
x265: 25
x264: 0
bit_depth:
"10": 25
audio:
atmos: 40
ddp51: 25
aac: 0
broadcaster:
f1tv: 50
sky: 30
release_group: {}
# mwr: 50
# verum: 40
proper_bonus: 50
repack_bonus: 50
hdr_bonus: 50
# cutoff: 350 # Stop upgrading at this score
# min_score: 100 # Reject files below this scoreScoring dimensions: resolution, source, frame_rate, codec, bit_depth, audio, broadcaster, release_group, plus bonuses for proper, repack, and hdr. Quality profiles can be set globally or overridden per-sport.
Playbook fetches metadata from the TVSportsDB REST API. Configuration is optional — defaults work for most users:
settings:
tvsportsdb:
base_url: http://localhost:8000 # Default API endpoint
ttl_hours: 2 # Cache TTL
timeout: 30 # Request timeout in secondsSports reference metadata via show_slug or show_slug_template instead of raw YAML URLs:
sports:
- id: formula1
show_slug_template: "formula-1-{year}" # Dynamic: year captured from filename
- id: nba
show_slug: "nba-2025-2026" # Static: single seasonLegacy: The
metadata.url/metadata.show_keyformat still works for backwards compatibility but is deprecated.
All built-in sports are enabled by default — most users only need to configure directories and integrations. To customize, add entries to the sports list:
sports:
- id: formula1
quality_profile:
enabled: true
scoring:
release_group:
mwr: 100
smcgill1969: 80
- id: my_custom_sport
name: My Sport
show_slug: "my-sport-2025"
source_globs: ["MySport.*"]
pattern_sets: [my_sport]Key fields:
enabled: toggle sports on/off without deleting them.show_slug/show_slug_template: TVSportsDB metadata reference.source_globs/extra_source_globs/disabled_source_globs: control which files are considered for this sport.source_extensions: file extensions to match (default:.mkv,.mp4,.ts,.m4v,.avi).allow_unmatched: downgrade pattern failures to informational logs (no warnings).link_mode: override global link behavior for a specific sport.quality_profile: per-sport quality scoring override.team_alias_map: TVSportsDB team alias map for team-based sports (e.g. NHL, Premier League).season_overrides: force season numbers for exhibitions/pre-season events.
regex– Must supply the capture groups consumed by selectors and templates (e.g.,round,session,location).season_selector– Maps captures to a season. Supported modes:round,key,title,sequential,date. Addoffsetormappingfor fine-grained control.episode_selector– Chooses which capture identifies an episode.allow_fallback_to_titlelets the matcher fall back to fuzzy title comparisons, anddefault_valueforces a canonical session when the regex doesn't capture one (useful for release groups that omitPrelims/Main Cardtags).session_aliases– Augment metadata aliases with release-specific tokens (case-insensitive, normalized).priority– Lower numbers win when multiple patterns match the same file (defaults to100).destination_*overrides – Apply sport- or pattern-specific templates without touching global settings.
Built-in pattern sets: the project ships curated pattern packs for Formula 1, Formula E, IndyCar, MotoGP, Moto2, Moto3, World Superbike, World Supersport, World Supersport 300, Isle of Man TT, UFC, NFL, NBA, NHL, Premier League, UEFA Champions League, ISU Figure Skating, Figure Skating Grand Prix, and WTA. Reference them from a sport entry via:
pattern_sets:
- formula1You can still inline file_patterns (alone or in addition to templates) for overrides or experiments. Review src/playbook/pattern_templates.yaml for the complete list and structure.
Templates accept rich context built from the match:
| Key | Meaning |
|---|---|
sport_id, sport_name |
Sport metadata from the config. |
show_title, show_key |
Raw and display titles from the metadata feed. |
season_title, season_number, season_round, season_year |
Season fields with overrides applied. |
episode_title, episode_number, episode_summary, episode_originally_available |
Episode details and optional air date (YYYY-MM-DD). |
location, session, round, … |
Any capture group from the regex. |
source_filename, source_stem, extension, suffix, relative_source |
Safe access to the original file name and path components. |
Filename components are sanitized automatically (lowercasing dangerous characters, trimming whitespace, removing forbidden characters).
Reuse a base sport definition across seasons or release groups using variants:
- id: indycar
name: IndyCar
metadata:
url: https://example.com/indycar/base.yaml
variants:
- year: 2024
metadata:
url: https://example.com/indycar-2024.yaml
- year: 2025
metadata:
url: https://example.com/indycar-2025.yamlEach variant inherits the base config, tweaks fields from the variant block, and receives an auto-generated id/name when not explicitly set.
python -m playbook.cli powers both the Docker entrypoint and local runs.
| CLI Flag | Environment | Default | Notes |
|---|---|---|---|
--config PATH |
CONFIG_PATH |
/config/config.yaml |
Path to the YAML config. |
--dry-run |
DRY_RUN |
Inherits settings.dry_run |
Force no-write mode. |
--verbose |
VERBOSE / DEBUG |
false |
Enables console DEBUG output. |
--log-level LEVEL |
LOG_LEVEL |
INFO (or DEBUG with --verbose) |
File log level. |
--console-level LEVEL |
CONSOLE_LEVEL |
matches file level | Console log level. |
--log-file PATH |
LOG_FILE / LOG_DIR |
./playbook.log |
Rotates to *.previous on start. |
--clear-processed-cache |
CLEAR_PROCESSED_CACHE |
false |
Reset processed file cache before processing. |
--force-reprocess |
FORCE_REPROCESS |
false |
Bypass processed-file database and reprocess all files. |
--trace-matches / --explain |
— | false |
Capture detailed match traces as JSON artifacts. |
--trace-output PATH |
— | cache_dir/traces |
Directory for trace JSON files (implies --trace-matches). |
--watch |
WATCH_MODE=true |
settings.file_watcher.enabled |
Force filesystem watcher mode. |
--no-watch |
WATCH_MODE=false |
false |
Disable watcher mode even if the config enables it. |
--gui |
GUI_ENABLED=true |
true |
Enable web GUI mode (enabled by default). |
--no-gui |
GUI_ENABLED=false |
false |
Disable GUI and run CLI-only processing. |
--gui-port PORT |
GUI_PORT |
8765 |
GUI web port. |
--gui-host HOST |
GUI_HOST |
0.0.0.0 |
Host to bind GUI to. |
--examples |
— | — | Show cookbook-style examples for any subcommand and exit. |
Environment variables always win over config defaults, and CLI flags win over environment variables.
Additional environment variables:
| Variable | Description |
|---|---|
SOURCE_DIR |
Override settings.source_dir |
DESTINATION_DIR |
Override settings.destination_dir |
CACHE_DIR |
Override settings.cache_dir |
STATE_DIR |
Override settings.state_dir |
GUI_THEME / PLAYBOOK_THEME |
Override settings.theme |
PLEX_URL |
Plex server URL (fallback for integrations config) |
PLEX_TOKEN |
Plex authentication token |
PLEX_LIBRARY_NAME |
Plex library name |
PLEX_LIBRARY_ID |
Plex library section ID |
DISCORD_WEBHOOK_URL |
Discord webhook (used by webhook_env default) |
PLAIN_CONSOLE_LOGS |
Force plain-text console output (no Rich formatting) |
RICH_CONSOLE_LOGS |
Force Rich console output even in non-interactive terminals |
Playbook features rich, color-formatted help with practical examples for every command. Use --help for quick reference or --examples for a comprehensive cookbook-style guide:
# Main help with all available commands
playbook --help
# Command-specific help with brief examples
playbook run --help
playbook validate-config --help
# Extended examples and usage patterns
playbook run --examples
playbook validate-config --examplesThe help output includes:
- Usage examples – Real-world commands you can copy-paste
- Environment variables – Alternative ways to configure options
- Tips & best practices – Common workflows and gotchas
- Docker variants – How to run the same command in containers
All help content is formatted with colors and icons for easy scanning. On non-interactive terminals (CI/CD, redirected output), Playbook automatically falls back to plain text.
Preflight your YAML before running the processor:
python -m playbook.cli validate-config --config /config/config.yaml --diff-sampleThe validator enforces the JSON schema, confirms referenced pattern sets exist, and then calls the same loader used by the runtime. Add --show-trace to surface Python tracebacks for deeper debugging. --diff-sample compares your file to config/config.sample.yaml to highlight customizations. Use --no-suggestions for cleaner output without fix suggestions.
Continuous mode example:
docker run -d \
-e WATCH_MODE=true \
-p 8765:8765 \
ghcr.io/s0len/playbook:latestPlaybook stays alive and reruns automatically whenever the watcher observes filesystem changes (or when the reconcile timer forces a full scan). Use --no-watch (or WATCH_MODE=false) for single-pass batch runs.
- Log entries use a multi-line block layout (timestamp + header + aligned key/value pairs) so dense sections breathe.
- INFO-level runs show grouped counts per sport/source; add
--verbose/LOG_LEVEL=DEBUGto expand into per-file diagnostics. - Each pass ends with a
Run Recapblock (duration, totals, destination samples) for quick scanning. - On each run, the previous log rotates to
playbook.log.previous, andLOG_DIR=/var/log/playbookkeeps files persistent.
A typical library after one Formula 1 weekend might look like:
Formula 1 2025/
└── 01 Bahrain Grand Prix/
├── Formula 1 - S01E01 - Free Practice 1.mkv
├── Formula 1 - S01E02 - Qualifying.mkv
├── Formula 1 - S01E03 - Sprint.mkv
└── Formula 1 - S01E04 - Race.mkv
Hardlinks preserve disk space; switch to copy or symlink when cross-filesystem moves are required.
Playbook does not download anything itself – it expects files to appear in SOURCE_DIR from a downloader (qBittorrent, Deluge, etc.). One way to automate this is with Autobrr.
Below is one approach using Autobrr filters and regexes targeted at specific sports and release groups.
For each sport you care about:
- Create a filter in Autobrr (e.g.
F1 1080p MWR,EPL 1080p NiGHTNiNJAS, etc.). - Select the trackers where your sports are available.
- Under Advanced → Release names → Match releases, paste a regex that:
- matches the sport name and year
- restricts to the resolution you want (e.g.
1080p) - optionally restricts to specific release groups (e.g.
MWR,NiGHTNiNJAS,DNU,GAMETiME,VERUM).
These are examples that pair well with the built-in pattern packs and metadata feeds:
# Premier League (EPL) 1080p releases by NiGHTNiNJAS
epl.*1080p.*nightninjas
# Formula 1 multi-session weekends by MWR
(F1|Formula.*1).*\d{4}.Round\d+.*[^.]+\.*?(Drivers.*Press.*Conference|Weekend.*Warm.*Up|FP\d?|Practice|Sprint.Qualifying|Sprint|Qualifying|Pre.Qualifying|Post.Qualifying|Race|Pre.Race|Post.Race|Sprint.Race|Feature.*Race).*1080p.*MWR
# Formula E by MWR
formulae\.\d{4}\.round\d+\.(?:[A-Za-z]+(?:\.[A-Za-z]+)?)\.(?:preview.show|qualifying|race)\..*h264.*-mwr
# IndyCar by MWR
indycar.*\d{4}\.round\d+\.(?:[A-Za-z]+(?:\.[A-Za-z]+)?)\.(?:qualifying|race)\..*h264.*-MWR
# Isle of Man TT by DNU
isle.of.man.tt.*DNU
# MotoGP by DNU
motogp.*\d{4}.*round\d.*((fp\d?|practice|sprint|qualifying|q1|q2|race)).*DNU
# NBA 1080p by GAMETiME
nba.*1080p.*gametime
# NHL RS 60fps feeds
nhl.*rs.*(720p|1080p).*en60fps
# NFL by NiGHTNiNJAS
nfl.*nightninjas
# UFC by VERUM
ufc[ ._-].*?\d{3}.*verum
# WorldSBK / WorldSSP / WorldSSP300 by MWR
(wsbk|wssp|wssp300).*\d{4}.round\d+.[^.]+.(fp\d?|season.preview|superpole|race.one|race.two|war.up(one|two)?|weekend.highlights).*h264.*mwr
UFC releases must now include the matchup slug (e.g., UFC 322 Della Maddalena vs Makhachev) so Playbook can align each file with the correct metadata season. Event numbers alone are ignored by the new title-based matching.
Playbook handles file and folder layout. To get rich metadata (titles, summaries, posters, artwork) in Plex, use the TVSportsDB metadata agent. This is a custom Plex metadata provider that pulls everything directly from TVSportsDB — no extra tools needed.
Requires Plex Media Server 1.43.0+ (2024 or newer). No plugin installation required.
- In Plex, go to Settings → Metadata Agents.
- Click the add provider button and enter:
https://api.tvsportsdb.com/plex - Click the add agent button to confirm.
- Restart Plex for the changes to take effect.
For the full setup guide with screenshots, see tvsportsdb.com/setup/plex.
- In the Plex web UI, go to Libraries → Add Library.
- Choose:
- Library type:
TV Shows - Name: e.g.
Sport
- Library type:
- Click Next and under Add folders, select your
DESTINATION_DIR. - Click Advanced and set:
- Scanner:
Plex Series Scanner - Agent:
TVSportsDB - Episode sorting:
Newest first
- Scanner:
- Save the library.
Once Playbook populates the destination folder, Plex will automatically pick up shows with titles, summaries, posters, and backgrounds from TVSportsDB.
- Missing metadata: Verify your filenames match the expected format (
Show - S01E01 - Title.ext). Use Plex's Fix Match feature to manually search for the correct show. - Incorrect matches: Right-click the item → Fix Match → search for the correct show name, then refresh metadata.
- Show not found: Confirm the show exists in the TVSportsDB database. If it's missing, request it via the site.
Playbook also supports pushing metadata directly via the Plex API using integrations.plex.metadata_sync — see the Configuration Deep Dive for details.
Adding a new sport is a two-step process: first add the metadata to TVSportsDB, then configure Playbook to match files against it.
All show/season/episode metadata lives in TVSportsDB. If the sport you want isn't there yet:
- Go to tvsportsdb.com and request the sport or add it yourself.
- Make sure the show has seasons and episodes populated with titles and aliases that match how release groups name their files.
Once the sport exists in TVSportsDB:
- Add a sport entry to your
config.yamlwith ashow_slugorshow_slug_templatepointing to the TVSportsDB show. - Either reference an existing
pattern_setspack or write customfile_patternswith regex that captures the groups your release files use. - Run
--dry-run --verboseto test matching without writing files. - Iterate on patterns and aliases until every file links where you expect.
If you build a pattern pack that works well, consider opening a PR to add it to pattern_templates.yaml so others can use it too.
- Nothing gets processed: Confirm
source_diris mounted and readable. Check thatsource_globsandinclude_patternsmatch your files. Run withLOG_LEVEL=DEBUGto see why files are being skipped. - Files processed but not appearing in Plex: Verify
destination_diris the same path Plex is scanning. If using Docker/NFS, check mount paths match. Enableintegrations.plex.scan_on_activityor trigger a manual library scan. - Metadata looks stale: Lower
ttl_hoursinsettings.tvsportsdbor deletecache_dircontents to force a refetch from the TVSportsDB API. - Hardlinks fail: Set
link_mode: copy(globally or per sport) when source and destination are on different filesystems, or when writing to SMB/NFS shares. - Pattern matches but wrong season: Adjust
season_selectormappings or useseason_overridesto force season numbers for exhibitions/pre-season events. - File was already processed and won't re-run: Playbook tracks processed files in a SQLite database under
state_dir. Use--force-reprocess(orFORCE_REPROCESS=true) to bypass the database check, or--clear-processed-cacheto wipe the database entirely. - Quality upgrade not happening: Ensure
quality_profile.enabled: trueis set globally or on the sport. Check that the new file actually scores higher — run with--trace-matchesto see scoring details. - Need to re-run immediately: Use
--no-watch(orWATCH_MODE=false) to force a single processing pass even if your watcher deployment is already running.
git clone https://github.com/s0len/playbook.git
cd playbook
python3 -m venv .venv
source .venv/bin/activate
pip install -e .[dev,gui]
pre-commit install- Run the CLI locally:
python -m playbook.cli --config config/config.sample.yaml --dry-run --verbose. - Build the container image:
docker build -t playbook:dev .. - Lint and format:
ruff check ./ruff format .. - Run the automated tests:
pytest. - Bootstrap a brand-new sandbox and run the full test suite:
bash scripts/bootstrap_and_test.sh. - Validate filename samples: edit
tests/data/pattern_samples.yamland runpytest tests/test_pattern_samples.pyto confirm new or modified patterns resolve correctly. - Open a draft pull request early—sample configs and matching logic benefit from collaborative review.
Distributed under the GNU GPLv3.
Questions, feature ideas, or metadata feed requests? Open an issue or start a discussion. For bespoke integrations, reach out via the issue tracker and we can coordinate.
Bundle the nhl pattern set with the NHL 2025-2026 metadata feed to normalize releases such as:
NHL RS 2025 New Jersey Devils vs Buffalo Sabres 28 11 720pEN60fps MSG.mkvNHL 18-10-2025 RS Edmonton Oilers vs New Jersey Devils 1080p60_EN_MSGSN.mkvNHL RS 2025 New Jersey Devils vs Washington Capitals 15 11 720pEN60fps MonumentalS.mkvNHL.2025.RS.Blue.Jackets.vs.Devils.1080pEN60fps.mkv
Bundle the figure_skating_grand_prix pattern set with the Figure Skating Grand Prix 2025 metadata feed to normalize releases such as:
Figure Skating Grand Prix France 2025 Pairs Short Program 17 10 720pEN50fps ESFigure Skating Grand Prix France 2025 Ice Dancing Rhythm Dance 18 10 720pEN50fps ESFigure Skating Grand Prix China 2025 Mixed Pairs Short Program 24 10 720pEN50fps ESFigure Skating Grand Prix China 2025 Exhibition Gala 26 10 720pEN50fps ESFigure Skating Grand Prix Canada 2025 Ice Dancing Free Program 02 11 720pEN50fps ESFigure Skating Grand Prix Canada 2025 Men Free Program 02 11 720pEN50fps ESFigure Skating Grand Prix Japan 2025 Ice Dancing Free Program 08 11 720pEN50fps ESFigure Skating USA Grand Prix 2025 Pairs Short Program 15 11 720pEN50fps ESFigure Skating Grand Prix Espoo 2025 Exhibition Gala 23 11 720pEN50fps ESFigure Skating Grand Prix Final 2025 Women Free Program 06 12 1080pEN50fps.mkv