A simple tool to download videos from VRT MAX using yt-dlp. It wraps yt-dlp with the right settings and credentials so you can start downloading with minimal setup.
This is a proof of concept. It works for basic use cases but comes with no guarantees.
- Python 3.8 or newer
- git
- A VRT MAX account (free or paid)
Open a terminal and run these commands:
# Clone the repository
git clone <repository-url> thuis
cd thuis
# Create a virtual environment with uv (recommended, uses hardlink mode for disk efficiency)
uv venv --link-mode=hardlink
# Install dependencies with uv
uv pip install -r requirements.txt --python .venv/bin/pythonThis installs a patched version of yt-dlp that can handle VRT MAX's login flow.
Linux:
./thuis.sh https://www.vrt.be/vrtmax/a/show/...Windows:
thuis.bat https://www.vrt.be/vrtmax/a/show/...If you prefer calling the Python module directly (or the wrapper scripts are not working):
python -m thuis.main https://www.vrt.be/vrtmax/a/show/...Or from the project root:
python src/thuis/main.py https://www.vrt.be/vrtmax/a/show/...Process multiple URLs from text files with scheduling support. Each series has its own watchlist file.
# Process a single series watchlist (dry run to see what would be downloaded)
./thuis.sh --watchlist watchlists/series_a.txt --dry-run
# Process manual entries for a series (requires --now)
./thuis.sh --watchlist watchlists/series_a.txt --now
# Process multiple series at once (all require --now for manual entries)
./thuis.sh --watchlist watchlists/series_a.txt \
--watchlist watchlists/series_b.txt \
--watchlist watchlists/series_c.txt \
--watchlist watchlists/series_d.txt \
--now --dry-run
# Process podcasts (scheduled entries run automatically, manual need --now)
./thuis.sh --watchlist watchlists/podcasts.txt --now --dry-run-
First non-comment line: Output directory (where files will be saved)
- Supports absolute paths, relative paths, and
~/home expansion - TV series example:
/path/to/tv/shows/ - Podcasts example:
/path/to/podcasts/
- Supports absolute paths, relative paths, and
-
Subsequent lines: URL entries
- No schedule = manual entries requiring
--nowflag to run - Scheduled entries:
[daily],[weekly],[weekdays 10:00], etc.
- No schedule = manual entries requiring
Example watchlist files are provided in the watchlists/ directory for different types of content (TV shows, podcasts, etc.).
TV series watchlists typically point to a TV shows directory. Podcast watchlist points to a podcasts directory.
./thuis.sh https://www.vrt.be/vrtmax/a/show/..../thuis.sh https://www.vrt.be/vrtmax/a/show/1/ https://www.vrt.be/vrtmax/a/show/2/ https://www.vrt.be/vrtmax/a/show/3/Create a text file with one URL per line (blank lines and lines starting with # are ignored):
# my-list.txt
https://www.vrt.be/vrtmax/a/show/1/
https://www.vrt.be/vrtmax/a/show/2/
Then run:
./thuis.sh --file my-list.txt./thuis.sh --dry-run https://www.vrt.be/vrtmax/a/show/..../thuis.sh --output-dir ~/Videos https://www.vrt.be/vrtmax/a/show/..../thuis.sh --profile 720 https://www.vrt.be/vrtmax/a/show/...Limits the video resolution to a specific value (e.g. 720, 1080). The tool selects the best available stream at or below that resolution.
./thuis.sh -p 1080 https://www.vrt.be/vrtmax/a/show/...The short form -p works the same way.
./thuis.sh --retry https://www.vrt.be/vrtmax/a/show/...Skips URLs whose output file already exists. Useful for resuming an interrupted batch without re-downloading files that already finished.
Renames downloaded video files to a scene-compatible format and optionally cleans up leftover files.
python -m thuis.main normalize /path/to/media --dry-run
python -m thuis.main normalize /path/to/media --cleanupOptions:
--dry-run-- Show what would be renamed without making changes.--cleanup-- Remove duplicate files (with_1suffixes) and stale.partfiles.
The normalize subcommand runs separately from downloads. Point it at a directory of already-downloaded files.
Pass a season URL to download every episode in that season:
# By season number in path
./thuis.sh https://www.vrt.be/vrtmax/a-z/your-show/2/
# By query parameter
./thuis.sh 'https://www.vrt.be/vrtmax/a-z/your-show/?seizoen=seizoen-2'The tool expands the season URL to individual episode URLs by querying the VRT MAX GraphQL API, falling back to HEAD-request guessing if the API returns no results. Combine with --dry-run to preview what would be downloaded:
./thuis.sh --dry-run 'https://www.vrt.be/vrtmax/a-z/your-show/?seizoen=seizoen-2'Limit the number of episodes processed per season with --max-episodes:
# Download only the first 5 episodes of a season
./thuis.sh --max-episodes 5 https://www.vrt.be/vrtmax/a-z/your-show/2/Pass a bare show URL (without a season number) to automatically discover and download every season:
./thuis.sh https://www.vrt.be/vrtmax/a-z/your-showThe tool queries the show page, detects all available seasons, and expands each into its episodes. Combine with --dry-run to preview:
./thuis.sh --dry-run https://www.vrt.be/vrtmax/a-z/your-showLimit episodes per season with --max-episodes:
# Download at most 10 episodes per season, across all seasons
./thuis.sh --max-episodes 10 https://www.vrt.be/vrtmax/a-z/your-showThe tool uses default credentials out of the box. You do not need to set up anything to get started.
If you want to use your own VRT MAX account, set these environment variables:
export VRT_EMAIL="your-email@example.com"
export VRT_PASSWORD="your-password"You can also add them to a .env file in the project root:
VRT_EMAIL=your-email@example.com
VRT_PASSWORD=your-password
The tool checks environment variables first, then the .env file (if python-dotenv is installed), and falls back to the built-in defaults.
Videos are saved in the media/ directory by default. Each file is named after the video title. You can change this with --output-dir.
Logs are written to logs/ in date-based files (e.g. logs/2026-07-07.log). Use --log-level to control verbosity (e.g. --log-level DEBUG for detailed output).
To tail the current log in real-time:
./thuis.sh --follow
# or
./thuis.sh -fAfter downloading, you can transcode videos to a target resolution (e.g. 720p). This is useful for:
- Reducing file size for storage or streaming
- Ensuring consistent resolution across your library
- Upscaling lower-resolution downloads
Use --transcode to automatically transcode files after download:
./thuis.sh --transcode 720p https://www.vrt.be/vrtmax/a/z/your-show/1/Options:
--transcode TARGET- Target resolution (e.g.,720p,1080p). Files already at this resolution are skipped.--allow-upscale- Allow upscaling lower resolutions (e.g., 540p → 720p).--keep-original- Keep both original and transcoded files.--transcode-preset PRESET- FFmpeg preset:fast(default),medium,slow, etc.--transcode-crf CRF- Quality setting (0-51, lower = better quality, default: 23).
Smart source selection: If multiple resolutions exist (e.g., 1080p and 540p), the highest available is used for transcoding (1080p → 720p is preferred over 540p → 720p).
Use --input-dir to transcode files without downloading:
# Transcode your-show files to 720p
python -m thuis.main --input-dir /path/to/media/tv/seed/ \
--transcode 720p \
--filter "your-show" \
--allow-upscale \
--keep-original \
--recursive \
--parallel 2
# Preview without transcoding
python -m thuis.main --input-dir /path/to/media/tv/seed/ \
--transcode 720p \
--filter "your-show" \
--recursive \
--dry-runOptions:
--input-dir DIR- Directory containing video files to transcode.--filter PATTERN- Filter files by name (substring match, case-insensitive). Can be used multiple times.--recursive- Scan subdirectories recursively.--parallel N- Number of concurrent transcoding jobs (default: 2).
# Transcode all downloaded your-show episodes to 720p, keep originals
python -m thuis.main --input-dir ~/media/tv/seed/ \
--transcode 720p \
--filter "your-show" \
--allow-upscale \
--keep-original \
--recursive
# Transcode a single show with higher quality (CRF 18 = larger file, better quality)
python -m thuis.main --input-dir ~/media/tv/ \
--transcode 720p \
--filter "your-show" \
--transcode-crf 18 \
--transcode-preset medium
# Batch transcode with dry-run first
python -m thuis.main --input-dir ~/media/ \
--transcode 720p \
--recursive \
--dry-run- Pressing Ctrl + C now exits cleanly with "Interrupted by user" and no traceback.
The tool automatically handles interrupted or failed downloads — just re-run the same command. No manual cleanup required.
flowchart TD
A[Start download] --> B{URL in DB<br/>downloaded_files?}
B -->|Yes| C[Skip - already downloaded]
B -->|No| D{Final .mp4<br/>exists on disk?}
D -->|Yes| C
D -->|No| E{.part file<br/>exists?}
E -->|Yes| F[Delete stale .part]
E -->|No| G[Start fresh download]
F --> G
G --> H{yt-dlp exit code}
H -->|0 success| I[Record in DB<br/>downloaded_files]
H -->|DRM error| J[Record in DB<br/>last_run = 'drm']
H -->|Other error| K[No DB record]
I --> L[Done]
J --> L
K --> L
| Scenario | On re-run |
|---|---|
| Network error / Ctrl+C | Stale .part auto-deleted, download restarts |
| DRM protected | Marked drm in DB, skipped unless --now |
| Complete file exists | Skipped (DB or filesystem check) |
Partial .part only |
Auto-deleted, download restarts |
Key points:
- Only successful downloads (
returncode == 0) are recorded indownloaded_files - Failed/partial downloads are not recorded, so re-running picks them up
check_file_exists()inwatchlist.pyremoves orphaned.partfiles automatically- DRM failures persist in
last_runtable; use--nowto retry
Website documentation: website/docs/
The tool uses two main optimizations to process large watchlists efficiently:
When expanding a show URL (e.g., all seasons of "Thuis"), the episode list for each season is fetched from the VRT MAX API and cached in SQLite for 24 hours. Subsequent runs retrieve the list from cache instead of making API calls.
flowchart TD
A[Show URL: /a-z/thuis] --> B[Fetch season list<br/>GraphQL API call]
B --> C{Season list<br/>cached? <br/>< 24h old}
C -->|Yes| D[Use cached list<br/>0 API calls]
C -->|No| E[Fetch from API<br/>~33 calls for 33 seasons]
E --> F[Cache result<br/>for 24 hours]
D --> G[Process episodes]
F --> G
For each episode URL, the tool checks if it was already downloaded using the downloaded_files database table (O(1) lookup).
flowchart TD
A[Episode URL] --> B{URL in<br/>downloaded_files?}
B -->|Yes| C[Skip - already downloaded]
B -->|No| D{Episode number<br/>> last_seen?}
D -->|No| E[Skip - older episode]
D -->|Yes| F[Fetch metadata<br/>(API call)]
F --> G{File exists<br/>on disk?}
G -->|Yes| C
G -->|No| H[Download with yt-dlp]
H --> I[Record in DB]
I --> J[Done]
| Scenario | Before | After |
|---|---|---|
| 6050 episode show scan | ~33 API calls + 6050 metadata calls | 33 API calls once/day + only new episodes |
| Re-run existing watchlist | 6050 metadata fetches (~7s each) | Instant skip (DB lookup only) |
| First run (cache miss) | N/A | ~33 API calls to build cache |
Key points:
- Episode lists cached for 24 hours in
episode_cachetable episode_progresstable tracks highest episode per show+season- Only new episodes (higher than last_seen) trigger metadata fetching
- Cache expires after 24h or when manually cleared
flowchart LR
subgraph Cache["Episode List Cache (24h)"]
C1["Season 32: 5 eps"]
C2["Season 31: 215 eps"]
C3["Season 30: 215 eps"]
end
subgraph Progress["Episode Progress"]
P1["S32: last=6114"]
P2["S31: last=5899"]
end
subgraph DB["Downloaded Files"]
D1["6114: downloaded"]
D2["6113: downloaded"]
end
URL["Show URL /thuis"] -->|1st run| API["VRT MAX API"]
API --> Cache
Cache --> Process["Process episodes"]
Process -->|S32E6114| D1
Process -->|S32E6113| D2
Process -->|S32E6115| New["New episode → download"]
New --> P1
URL -->|2nd run| Cache
Website documentation: website/docs/
Some VRT MAX content uses Widevine DRM. To enable decryption:
- Install a decryption engine — one of:
N_m3u8DL-RE(recommended),mp4decrypt(Bento4), orshaka-packager - Provide a
.wvdfile — place your Widevine CDM file at~/.thuis/wvd/device.wvd(or setWVD_CDM_PATHin.env) - Enable in
.env—DECRYPT_DRM=yes(default)
Extract your CDM: python scripts/extract_cdm.py
Full details: see docs/REQUIREMENTS.md
Diagnose DRM pipeline readiness and auto-fix issues:
# Check status
./thuis.sh doctor
# Auto-fix installable issues (apt/brew/scoop, .env)
./thuis.sh doctor --fix
# Show help
./thuis.sh doctor --helpThe doctor command checks:
- Python dependencies (yt-dlp, pywidevine, pymp4)
- Decryption engines (mp4decrypt, shaka-packager, ffmpeg)
- N_m3u8DL-RE binary
- Widevine CDM (.wvd file)
- Environment variables (DECRYPT_DRM, WVD_CDM_PATH)
- .env file configuration
Run ./thuis.sh doctor whenever DRM downloads fail to see what's missing.
This is a proof of concept. It may break if VRT MAX changes their website or login flow. The default credentials are shared demo credentials. Respect VRT's terms of service.