Turn an IPTV-style M3U/M3U8 playlist into a media-server-friendly folder of .strm files.
Reads a playlist, categorises entries into Movies, TV Shows, Sports VOD, and optionally Live, then writes a clean directory structure that Emby / Jellyfin / Plex / Kodi can scan.
A .strm file is just a text file containing a stream URL — media servers index it like a local video file, so remote streams appear in your library as if they were on disk.
- Go 1.23+
go build -o m3uhandler .| Subcommand | Description |
|---|---|
daemon |
Fetch playlist(s) and write .strm files, optionally on a schedule |
radarr-csv |
Generate a Radarr TMDb List CSV from a movies playlist |
radarr-adopt |
Bulk-import an existing movie library into Radarr via API |
sonarr-adopt |
Bulk-import an existing TV library into Sonarr via API |
tui |
Interactive launcher and settings editor for all of the above |
Fetches one or more playlist URLs and writes .strm files. Runs once or on a repeating interval.
| Type | Layout |
|---|---|
| Movies (default) | Movies/<Year>/<Title>.strm |
--movies-flat |
Movies/<Title>.strm |
--movies-by-folder |
Movies/<Title>/<Title>.strm |
--movies-by-year-folder |
Movies/<Year>/<Title>/<Title>.strm |
| TV Shows | TV Shows/<Show>/Season 01/<Show> S01E01.strm |
| Live | Live/<Group>/<Channel>.strm |
| Sports VOD | Sports VOD/<Group>/<Event>.strm |
./m3uhandler daemon [options]| Flag | Description | Default |
|---|---|---|
--url <url> |
Generic playlist URL | — |
--url-tv <url> |
TV shows URL; if URL ends with a number it auto-paginates until a 4xx | — |
--url-movies <url> |
Movies URL (single, no pagination) | — |
--url-events <url> |
Sports VOD / events URL (single, no pagination) | — |
--use-config |
Load URLs + settings from ~/.config/m3uHandler/config.json |
false |
-o, --out <dir> |
Output directory | output |
--include-live |
Also write live .strm entries |
false |
--movies-flat |
Put movies directly under Movies/ |
false |
--movies-by-folder |
Movies/<Title>/<Title>.strm |
false |
--movies-by-year-folder |
Movies/<Year>/<Title>/<Title>.strm |
false |
--no-delete-missing |
Do not delete .strm files absent from latest playlist |
false |
--no-overwrite |
Skip writing a .strm that already exists on disk |
false |
--skip-if-media-exists |
Skip if a local media file with the same base name exists | false |
--write-concurrency <n> |
Parallel .strm writers |
8 |
--progress-every <n> |
Log progress every N entries | 1000 |
--interval-hours <n> |
Poll interval in hours | 24 |
--interval-seconds <n> |
Poll interval in seconds (overrides hours) | — |
--once |
Run one update and exit | false |
-h, --help |
Show help | — |
Environment variables:
FETCH_TIMEOUT_MS— HTTP fetch timeout in milliseconds (default:300000/ 5 min)M3UHANDLER_LOG_PATH— override log file path (default:output/m3uHandler.log)
Movies only (year+folder layout, skip already-downloaded):
./m3uhandler daemon \
--url-movies "https://provider/api/list/USER/PASS/m3u8/movies" \
--once -o /mnt/media/ \
--movies-by-year-folder \
--skip-if-media-exists \
--no-delete-missingTV shows (auto-paginates from page 1 until 4xx):
./m3uhandler daemon \
--url-tv "https://provider/api/list/USER/PASS/m3u8/tvshows/1" \
--once -o /mnt/media/ \
--no-delete-missingSports VOD:
./m3uhandler daemon \
--url-events "https://provider/api/list/USER/PASS/m3u8/events" \
--once -o /mnt/media/ \
--no-delete-missingAll sources, daemon mode (refresh every 24h):
./m3uhandler daemon \
--url-tv "https://provider/api/list/USER/PASS/m3u8/tvshows/1" \
--url-movies "https://provider/api/list/USER/PASS/m3u8/movies" \
--url-events "https://provider/api/list/USER/PASS/m3u8/events" \
-o /mnt/media/ \
--movies-by-year-folder \
--skip-if-media-exists \
--no-overwrite \
--no-delete-missing \
--interval-hours 24Generate a Radarr-compatible TMDb List CSV from a movies playlist.
./m3uhandler radarr-csv --input /path/to/playlist.m3u8 --output output/radarr.csvBulk-import an existing movie library into Radarr via API, bypassing the GUI importer.
Expects folders named Title (Year) directly under --location, or one level deeper (e.g. year sub-folders) when --recursive is set.
Environment variables:
RADARR_URL— e.g.http://192.168.1.10:7878RADARR_API_KEY— Radarr → Settings → General → Security
You can also store them as radarrUrl and radarrApiKey in the config file. Environment variables take precedence.
RADARR_URL="http://..." RADARR_API_KEY="..." \
./m3uhandler radarr-adopt --location "/mnt/media/Movies" [options]| Flag | Description | Default |
|---|---|---|
--location <path> |
Library root (scan path + Radarr rootFolderPath) | required |
--library-path <path> |
Override scan location only | — |
--root-folder <path> |
Override Radarr rootFolderPath only | — |
--recursive |
Also scan one level of subdirectories (e.g. year folders) | false |
--monitored <true|false> |
Mark movies as monitored | true |
--search |
Trigger search for missing movies after add | false |
--dry-run |
Scan and prepare only; no API calls | false |
--lookup-concurrency <n> |
Parallel TMDb lookup requests | 10 |
--add-concurrency <n> |
Parallel add requests | 2 |
--state <path> |
Resume state file | output/radarr-adopt-state.json |
--cache <path> |
Lookup cache file | output/radarr-lookup-cache.json |
RADARR_URL="http://192.168.1.10:7878" RADARR_API_KEY="..." \
./m3uhandler radarr-adopt --location "/mnt/media/Movies" --recursiveNotes:
- Progress logged every 200 TMDb lookups.
- Lookup cache saved every 200 lookups — run is resumable.
- The state file records already-added paths per instance. An instance is identified by the URL's host, port and base path, for example
192.168.1.10:7878. Re-running skips recorded paths, and pointing at a different Radarr starts fresh. - State files from before this change have no instance information. The tool ignores their paths but keeps them, and still skips items already in Radarr by checking its API.
- The lookup cache is shared across instances. TMDb ids are the same whichever Radarr asks, so a new instance reuses it.
--dry-runmakes no Radarr calls. It scans the folders and reads the lookup cache, so it cannot tell you what the instance contains or whether the URL and key work. It never writes the state file.
Bulk-import an existing TV library into Sonarr via API.
Expects folders named Title (Year) or Title directly under --location.
Environment variables:
SONARR_URL— e.g.http://192.168.1.10:8989SONARR_API_KEY— Sonarr → Settings → General → Security
You can also store them as sonarrUrl and sonarrApiKey in the config file. Environment variables take precedence.
SONARR_URL="http://..." SONARR_API_KEY="..." \
./m3uhandler sonarr-adopt --location "/mnt/media/TV Shows" [options]| Flag | Description | Default |
|---|---|---|
--location <path> |
Library root (scan path + Sonarr rootFolderPath) | required |
--library-path <path> |
Override scan location only | — |
--root-folder <path> |
Override Sonarr rootFolderPath only | — |
--monitored <true|false> |
Mark series as monitored | true |
--search |
Trigger search for missing episodes after add | false |
--season-folder <true|false> |
Organise episodes into season sub-folders | true |
--series-type <type> |
standard, daily, or anime |
standard |
--quality-profile <n> |
Quality profile ID | 1 |
--language-profile <n> |
Language profile ID | 1 |
--dry-run |
Scan and prepare only; no API calls | false |
--lookup-concurrency <n> |
Parallel TVDB lookup requests | 10 |
--add-concurrency <n> |
Parallel add requests | 2 |
--state <path> |
Resume state file | output/sonarr-adopt-state.json |
--cache <path> |
Lookup cache file | output/sonarr-lookup-cache.json |
Notes:
- Progress logged every 200 TVDB lookups.
- Lookup cache saved every 200 lookups — run is resumable.
- The state file records already-added paths per instance. An instance is identified by the URL's host, port and base path, for example
192.168.1.10:8989. Re-running skips recorded paths, and pointing at a different Sonarr starts fresh. - State files from before this change have no instance information. The tool ignores their paths but keeps them, and still skips items already in Sonarr by checking its API.
- The lookup cache is shared across instances. TVDB ids are the same whichever Sonarr asks, so a new instance reuses it.
--dry-runmakes no Sonarr calls. It scans the folders and reads the lookup cache, so it cannot tell you what the instance contains or whether the URL and key work. It never writes the state file.
Interactive launcher and settings editor.
./m3uhandler tui- Pick a command, fill in its options in a form, review the equivalent command line (copy it into cron or a systemd unit), then run it.
- Adopt forms default to
--dry-runand the daemon form defaults to--once. - Commands run as a normal subprocess. Ctrl+C stops the command and returns to the menu.
- Settings edits the config file. It covers provider URLs, output folder, movie layout, refresh interval, and the Radarr and Sonarr URL and API key. Secret fields stay masked unless you choose to show them.
- It needs an interactive terminal. Ctrl+C backs out of a form, and at the main menu it quits.
~/.config/m3uHandler/config.json on Linux, and under os.UserConfigDir() elsewhere. Create it in tui under Settings, or write it by hand. daemon --use-config reads it, and radarr-adopt and sonarr-adopt read the Radarr and Sonarr values.
{
"urlTv": "https://provider/api/list/USER/PASS/m3u8/tvshows/1",
"urlMovies": "https://provider/api/list/USER/PASS/m3u8/movies",
"urlEvents": "https://provider/api/list/USER/PASS/m3u8/events",
"out": "/mnt/media",
"includeLive": false,
"moviesByYearFolder": true,
"intervalHours": 24,
"radarrUrl": "http://192.168.1.10:7878",
"radarrApiKey": "...",
"sonarrUrl": "http://192.168.1.10:8989",
"sonarrApiKey": "..."
}The file holds secrets in plain text. Provider URLs usually embed your account, and it can hold the Radarr and Sonarr API keys.
- Saves write the file with mode
0600in a0700directory. Each save replaces the file atomically and tightens any looser mode. - If group or others can read the file when the tool uses its secrets, the tool logs a warning with the
chmod 600command. - The
RADARR_URL,RADARR_API_KEY,SONARR_URLandSONARR_API_KEYenvironment variables override the file field by field. - The tool never writes keys to logs. If one leaks, regenerate it in the app under Settings → General → Security.
- Keep the file out of backups and dotfile repos you share.
All subcommands write logs to both stdout and a file.
- Default log path:
output/m3uHandler.log - Override:
M3UHANDLER_LOG_PATH=<path>