A little script to serve Youtube / Twitch / Crunchyroll videos without storage it. Uses yt-dlp HTTP data throught Flask and dynamic URLs. We can use this dynamic URLs to set STRM files.
493
stars
419
commits
Python
primary language
Jun 20, 2026
updated
PATH (ffmpeg -version from a terminal should work).PATH as a binary (yt-dlp --version must work from a terminal), because ytdlp2STRM calls it as an external process.
pip install -r requierments.txt installs the Python package, but on some systems the entry-point binary is not picked up on PATH. If that happens, install it as a binary instead:
winget install yt-dlp.yt-dlpsudo apt install yt-dlp or grab the release binary from the yt-dlp repo.brew install yt-dlp.cd /opt && git clone https://github.com/fe80Grau/ytdlp2STRM.git
cd /opt/ytdlp2STRM && pip install -r requierments.txt
sudo cp config/ytdlp2strm.service /etc/systemd/system/ytdlp2strm.service
sudo systemctl enable ytdlp2strm.service
sudo systemctl start ytdlp2strm.service
sudo systemctl status ytdlp2strm.service
http://localhost:5000/
cd C:\ProgramData; git clone https://github.com/fe80Grau/ytdlp2STRM.git;
cd C:\ProgramData\ytdlp2STRM; pip install -r requierments.txt
schtasks.exe /create /tn "ytdlp2STRM" /xml C:\ProgramData\ytdlp2STRM\config\MS-TASK-ytdlp2STRM.xml
schtasks.exe /run /tn "ytdlp2STRM"
(Get-ScheduledTask | Where TaskName -eq ytdlp2STRM ).State
http://localhost:5000/
To deploy this as a Docker container, follow these steps in the ytdlp2STRM root folder.
docker build . -t "ytdlp2strm"
docker create --name ytdlp2strm-data ytdlp2strm
docker run -p 5005:5000 --restart=always -d -v D:\media:/media --volumes-from ytdlp2strm-data --name ytdlp2STRM ytdlp2strm
http://localhost:5005/
Check the GUI in the browser
http://localhost:5001/
---
services:
ytdlp2strm:
image: fe80grau/ytdlp2strm
container_name: ytdlp2STRM
environment:
- AM_I_IN_A_DOCKER_CONTAINER=Yes
- DOCKER_PORT=5005
volumes:
- /local/path/to/media:/media
- /local/path/to/channel_list.json:/opt/ytdlp2STRM/plugins/youtube/channel_list.json
- /local/path/to/yt_config.json:/opt/ytdlp2STRM/plugins/youtube/config.json
- /local/path/to/config.json:/opt/ytdlp2STRM/config/config.json
- /local/path/to/crons.json:/opt/ytdlp2STRM/config/crons.json
- ytdlp2strm-data:/opt/ytdlp2STRM
ports:
- 5005:5000
restart: always
volumes:
ytdlp2strm-data:
Where:
/local/path/to/media is the local folder where the .strm file will be created/local/path/to/config.json is optional and will set your ytdlp2STRM general settings; formatted as config.example.json/local/path/to/crons.json is optional and will set your ytdlp2STRM cronjob settings; formatted as crons.example.json/local/path/to/channel_list.json is optional and will set your Youtube channel list; formatted as channel_list.example.json/local/path/to/yt_config.json is optional and will set your Youtube plugin settings; formatted as config.example.jsonjellyfin_preload was "False": the Crunchyroll plugin used bool(config['jellyfin_preload']), but bool("False") returns True in Python (any non-empty string is truthy). This caused the Jellyfin preload daemon to run and spam HTTP errors every minute even with the feature disabled. The check now uses str(value).lower() == 'true', matching the rest of the codebase./youtube/direct/<id>, /twitch/bridge/<id>, etc.) now validate the media ID against an allowlist ([A-Za-z0-9_.-], no leading -) before passing it to yt-dlp, returning HTTP 400 on invalid input.eval() in cli.py: plugin method resolution now uses getattr instead of eval() on user-supplied strings, preventing code injection.ytdlp2strm_host: main.py reads ytdlp2strm_host from config.json instead of hardcoding 0.0.0.0. The default config now ships with 127.0.0.1, reducing accidental exposure of the admin UI to the network.ytdlp2strm_web_username and ytdlp2strm_web_password in config.json protects the dashboard (/, /general, /plugin, /crons, /log, /restart_service) while leaving streaming endpoints open for Jellyfin/Emby/Kodi. Streaming routes (/youtube/*, /twitch/*, etc.) remain unauthenticated so players keep working..nfo, .png, .vtt and .srt files under a new episode number with no .strm next to them, while the real .strm lived at the original number. The existence check now runs before the new title/episode number is computed and reuses the path of the existing .strm for subtitle and metadata refreshes. The lookup also tolerates unreadable/locked files.video_quality change ignored until cache TTL expired: the direct-mode HLS cache (in memory and in <strm_output_folder>/.direct_cache) was keyed only by youtube_id + lang, so a previously-resolved low-quality variant kept being served after changing video_quality in plugins/youtube/config.json. The quality is now part of the cache key and filename, so changing the setting invalidates the previous caches immediately.scripts/clean_youtube_orphans.py removes companion files (.nfo, .png/.jpg/.webp, .vtt, .srt, .ass) that no longer have a matching .strm in the same folder. Runs in dry-run by default; pass --delete to actually remove files.
python scripts/clean_youtube_orphans.py "/media/Youtube" # dry-run
python scripts/clean_youtube_orphans.py "/media/Youtube" --delete # actually delete
version.py (__version__) and injected into every UI template as app_version, instead of being hardcoded in each HTML page and in cli.py. To cut a release just bump version.py.iframe mode: new generation method for the YouTube plugin. The .strm file is written with the public YouTube watch URL (https://www.youtube.com/watch?v=<id>) instead of a ytdlp2STRM endpoint, so players/apps that can resolve YouTube URLs natively don't need to go through this service. Use it from cron or CLI as --params iframe.Channel/Season {year}/S{year}E{XX} - video.strmChannel [ID]/Season 2025/S2025E01 - Video Title.strmSeason {year} folders (e.g., Season 2025, Season 2026)S{year}E{XX} format, resetting to E01 each yearlang parameter in config.jsonvideo_quality can limit playback quality. Use best for the current behavior or values like 1080, 720, 480 to avoid selecting higher resolutions.download_subtitles is enabled, YouTube .vtt subtitles are downloaded next to each .strm file. WebVTT files are post-processed to improve Jellyfin/Emby rendering:
/youtube/subtitles/<id>.vtt.convert_subtitles_to_srt can also create .srt sidecar files from the cleaned VTT subtitles for better compatibility with Android/TV clients.keep_vtt_subtitles keeps the original cleaned .vtt files when SRT conversion is enabled.<strm_output_folder>/.direct_cache.
direct_stream_cache_hours controls how long cached direct manifests are considered valid.yt-dlp resolution on repeated playback.direct_serve_media_playlist enabled, ytdlp2STRM serves the selected media playlist directly instead of the full master playlist, reducing startup work for Jellyfin/Emby while keeping the selected highest-quality stream.
direct_stream_cache_hours, which can reduce extra playlist reloads during seek operations.googlevideo.com. For near-instant seeking like YouTube, the only fully reliable options are local/download playback or a future segment proxy/cache mode, both with higher disk/bandwidth cost.direct_prewarm_latest_per_channel precaches the latest detected videos per channel during the YouTube scan.direct_prewarm_neighbors precaches the previous and next video in the same folder when a video is played.yt-dlp resolutions at once.Season {year} folders (e.g., Season 2025, Season 2026)S{year}E{XX} format, resetting to E01 each yearyt-dlp >=2024.05.22.232749.dev0mutate_values.json A particular file for this plugin. Overwrites the value of a specified field. For example, in cases where the season given by yt-dlp - season_number does not correspond to the actual season of the series. The available fields are as follows: season_number, season, episode_number, and episode../temp/ and their lifespan will be 24 hours. This is configurable in config.json -> ytdlp2strm_temp_file_duration. The Crunchyroll plugin in download mode will automatically download the latest discovered episode of each series declared in channel_list.json.config.json file, and specifically for the Crunchyroll plugin, there are 4 parameters: jellyfin_preload_last_episode, jellyfin_base_url, jellyfin_user_id, and jellyfin_api_key. When configured with their correct values, they allow detecting if an episode is being played and pre-downloading the next one to achieve a seamless playback flow without interruptions.A little script to serve yt-dlp video/audio as HTTP data throught Flask and dynamic URLs. We can use this dynamic URLs with youtube id video in url like http://127.0.0.1:5000/youtube/direct/FxCqhXVc9iY and open it with VLC or save it in .strm file (works in Jellyfin)
cd /opt/ytdlp2STRM/ && python3 cli.py --media youtube --params direct
You can change --media value for another plugin
Working with Schedule library (https://schedule.readthedocs.io/en/stable/examples.html)
Do attribute needs a list with commands ["--media", "youtube", "--params", "direct"], replace youtube with your plugin name and direct with your prefered mode.
Custom timezone for each cron
direct : A simple redirect to final stream URL. (faster, no disk usage, sponsorblock not works)
bridge : Remuxing on fly. (fast, no disk usage)
download : First download full video then it's served. (slow, temp disk usage)
iframe : The .strm stores the public YouTube watch URL (https://www.youtube.com/watch?v=<id>) instead of a ytdlp2STRM URL. Useful for players/apps that can play YouTube URLs natively without going through this service.
With download mode, the files in the temp folder older than 24h will be deleted.
sequential uses incremental episode numbers; mmdd uses month/day style numbering.best for no limit, or values like 1080, 720, 480..vtt sidecar files and fix alignment/rollup issues for Jellyfin/Emby..srt sidecar files from cleaned .vtt subtitles for better Android/TV compatibility..vtt files after SRT conversion. Set False to leave only .srt files.<strm_output_folder>/.direct_cache.0 to disable.ytdlp2STRM generates .strm files under the strm_output_folder configured in each plugin (plugins/<media>/config.json). Each .strm just contains a URL that points back to this service, so Jellyfin/Emby must have access both to the STRM files and to the ytdlp2STRM HTTP server (http://<host>:5000 by default).
Typical setup:
/media/Youtube on Linux or D:\media\Youtube on Windows.strm_output_folder value in plugins/youtube/config.json (and equivalent for Twitch/etc.)./media/Youtube). Do the same for Twitch if you use that plugin.Channel [ID]/Season {year}/S{year}E{XX} - Video title.strm).jellyfin_integration so the service triggers a scan of the configured jellyfin_library_name after new STRM files are written. Populate jellyfin_base_url and jellyfin_api_key accordingly.If Jellyfin runs on a different host/container than ytdlp2STRM, the URL stored inside the
.strmfiles (http://127.0.0.1:5000/...by default) must be reachable from that host. Changeytdlp2strm_hostinconfig/config.jsonto the IP/DNS name the Jellyfin server can reach.
FileNotFoundError: [WinError 2] The system cannot find the file specified (or the equivalent on Linux/macOS) when opening a direct URL or running the plugin from the Dashboard: the service tried to run yt-dlp as a subprocess but could not find the binary on PATH. Install yt-dlp as a binary (see the Prerequisite section) and restart the ytdlp2STRM service.ytdlp2strm.log for FileNotFoundError or command not found: yt-dlp.only python cli.py command can be executed from here: fixed in recent versions by resolving the interpreter server-side. If you still hit it, update to the latest version.lang is set: YouTube videos with dubbed tracks default to the original. Recent versions add -S lang:<lang> automatically so the configured language is preferred. Update and restart.Python
74.2%
HTML
23.2%
JavaScript
2.2%
A little script to serve Youtube / Twitch / Crunchyroll videos without storage it. Uses yt-dlp HTTP data throught Flask and dynamic URLs. We can use this dynamic URLs to set STRM files.
493
stars
419
commits
Python
primary language
Jun 20, 2026
updated
PATH (ffmpeg -version from a terminal should work).PATH as a binary (yt-dlp --version must work from a terminal), because ytdlp2STRM calls it as an external process.
pip install -r requierments.txt installs the Python package, but on some systems the entry-point binary is not picked up on PATH. If that happens, install it as a binary instead:
winget install yt-dlp.yt-dlpsudo apt install yt-dlp or grab the release binary from the yt-dlp repo.brew install yt-dlp.cd /opt && git clone https://github.com/fe80Grau/ytdlp2STRM.git
cd /opt/ytdlp2STRM && pip install -r requierments.txt
sudo cp config/ytdlp2strm.service /etc/systemd/system/ytdlp2strm.service
sudo systemctl enable ytdlp2strm.service
sudo systemctl start ytdlp2strm.service
sudo systemctl status ytdlp2strm.service
http://localhost:5000/
cd C:\ProgramData; git clone https://github.com/fe80Grau/ytdlp2STRM.git;
cd C:\ProgramData\ytdlp2STRM; pip install -r requierments.txt
schtasks.exe /create /tn "ytdlp2STRM" /xml C:\ProgramData\ytdlp2STRM\config\MS-TASK-ytdlp2STRM.xml
schtasks.exe /run /tn "ytdlp2STRM"
(Get-ScheduledTask | Where TaskName -eq ytdlp2STRM ).State
http://localhost:5000/
To deploy this as a Docker container, follow these steps in the ytdlp2STRM root folder.
docker build . -t "ytdlp2strm"
docker create --name ytdlp2strm-data ytdlp2strm
docker run -p 5005:5000 --restart=always -d -v D:\media:/media --volumes-from ytdlp2strm-data --name ytdlp2STRM ytdlp2strm
http://localhost:5005/
Check the GUI in the browser
http://localhost:5001/
---
services:
ytdlp2strm:
image: fe80grau/ytdlp2strm
container_name: ytdlp2STRM
environment:
- AM_I_IN_A_DOCKER_CONTAINER=Yes
- DOCKER_PORT=5005
volumes:
- /local/path/to/media:/media
- /local/path/to/channel_list.json:/opt/ytdlp2STRM/plugins/youtube/channel_list.json
- /local/path/to/yt_config.json:/opt/ytdlp2STRM/plugins/youtube/config.json
- /local/path/to/config.json:/opt/ytdlp2STRM/config/config.json
- /local/path/to/crons.json:/opt/ytdlp2STRM/config/crons.json
- ytdlp2strm-data:/opt/ytdlp2STRM
ports:
- 5005:5000
restart: always
volumes:
ytdlp2strm-data:
Where:
/local/path/to/media is the local folder where the .strm file will be created/local/path/to/config.json is optional and will set your ytdlp2STRM general settings; formatted as config.example.json/local/path/to/crons.json is optional and will set your ytdlp2STRM cronjob settings; formatted as crons.example.json/local/path/to/channel_list.json is optional and will set your Youtube channel list; formatted as channel_list.example.json/local/path/to/yt_config.json is optional and will set your Youtube plugin settings; formatted as config.example.jsonjellyfin_preload was "False": the Crunchyroll plugin used bool(config['jellyfin_preload']), but bool("False") returns True in Python (any non-empty string is truthy). This caused the Jellyfin preload daemon to run and spam HTTP errors every minute even with the feature disabled. The check now uses str(value).lower() == 'true', matching the rest of the codebase./youtube/direct/<id>, /twitch/bridge/<id>, etc.) now validate the media ID against an allowlist ([A-Za-z0-9_.-], no leading -) before passing it to yt-dlp, returning HTTP 400 on invalid input.eval() in cli.py: plugin method resolution now uses getattr instead of eval() on user-supplied strings, preventing code injection.ytdlp2strm_host: main.py reads ytdlp2strm_host from config.json instead of hardcoding 0.0.0.0. The default config now ships with 127.0.0.1, reducing accidental exposure of the admin UI to the network.ytdlp2strm_web_username and ytdlp2strm_web_password in config.json protects the dashboard (/, /general, /plugin, /crons, /log, /restart_service) while leaving streaming endpoints open for Jellyfin/Emby/Kodi. Streaming routes (/youtube/*, /twitch/*, etc.) remain unauthenticated so players keep working..nfo, .png, .vtt and .srt files under a new episode number with no .strm next to them, while the real .strm lived at the original number. The existence check now runs before the new title/episode number is computed and reuses the path of the existing .strm for subtitle and metadata refreshes. The lookup also tolerates unreadable/locked files.video_quality change ignored until cache TTL expired: the direct-mode HLS cache (in memory and in <strm_output_folder>/.direct_cache) was keyed only by youtube_id + lang, so a previously-resolved low-quality variant kept being served after changing video_quality in plugins/youtube/config.json. The quality is now part of the cache key and filename, so changing the setting invalidates the previous caches immediately.scripts/clean_youtube_orphans.py removes companion files (.nfo, .png/.jpg/.webp, .vtt, .srt, .ass) that no longer have a matching .strm in the same folder. Runs in dry-run by default; pass --delete to actually remove files.
python scripts/clean_youtube_orphans.py "/media/Youtube" # dry-run
python scripts/clean_youtube_orphans.py "/media/Youtube" --delete # actually delete
version.py (__version__) and injected into every UI template as app_version, instead of being hardcoded in each HTML page and in cli.py. To cut a release just bump version.py.iframe mode: new generation method for the YouTube plugin. The .strm file is written with the public YouTube watch URL (https://www.youtube.com/watch?v=<id>) instead of a ytdlp2STRM endpoint, so players/apps that can resolve YouTube URLs natively don't need to go through this service. Use it from cron or CLI as --params iframe.Channel/Season {year}/S{year}E{XX} - video.strmChannel [ID]/Season 2025/S2025E01 - Video Title.strmSeason {year} folders (e.g., Season 2025, Season 2026)S{year}E{XX} format, resetting to E01 each yearlang parameter in config.jsonvideo_quality can limit playback quality. Use best for the current behavior or values like 1080, 720, 480 to avoid selecting higher resolutions.download_subtitles is enabled, YouTube .vtt subtitles are downloaded next to each .strm file. WebVTT files are post-processed to improve Jellyfin/Emby rendering:
/youtube/subtitles/<id>.vtt.convert_subtitles_to_srt can also create .srt sidecar files from the cleaned VTT subtitles for better compatibility with Android/TV clients.keep_vtt_subtitles keeps the original cleaned .vtt files when SRT conversion is enabled.<strm_output_folder>/.direct_cache.
direct_stream_cache_hours controls how long cached direct manifests are considered valid.yt-dlp resolution on repeated playback.direct_serve_media_playlist enabled, ytdlp2STRM serves the selected media playlist directly instead of the full master playlist, reducing startup work for Jellyfin/Emby while keeping the selected highest-quality stream.
direct_stream_cache_hours, which can reduce extra playlist reloads during seek operations.googlevideo.com. For near-instant seeking like YouTube, the only fully reliable options are local/download playback or a future segment proxy/cache mode, both with higher disk/bandwidth cost.direct_prewarm_latest_per_channel precaches the latest detected videos per channel during the YouTube scan.direct_prewarm_neighbors precaches the previous and next video in the same folder when a video is played.yt-dlp resolutions at once.Season {year} folders (e.g., Season 2025, Season 2026)S{year}E{XX} format, resetting to E01 each yearyt-dlp >=2024.05.22.232749.dev0mutate_values.json A particular file for this plugin. Overwrites the value of a specified field. For example, in cases where the season given by yt-dlp - season_number does not correspond to the actual season of the series. The available fields are as follows: season_number, season, episode_number, and episode../temp/ and their lifespan will be 24 hours. This is configurable in config.json -> ytdlp2strm_temp_file_duration. The Crunchyroll plugin in download mode will automatically download the latest discovered episode of each series declared in channel_list.json.config.json file, and specifically for the Crunchyroll plugin, there are 4 parameters: jellyfin_preload_last_episode, jellyfin_base_url, jellyfin_user_id, and jellyfin_api_key. When configured with their correct values, they allow detecting if an episode is being played and pre-downloading the next one to achieve a seamless playback flow without interruptions.A little script to serve yt-dlp video/audio as HTTP data throught Flask and dynamic URLs. We can use this dynamic URLs with youtube id video in url like http://127.0.0.1:5000/youtube/direct/FxCqhXVc9iY and open it with VLC or save it in .strm file (works in Jellyfin)
cd /opt/ytdlp2STRM/ && python3 cli.py --media youtube --params direct
You can change --media value for another plugin
Working with Schedule library (https://schedule.readthedocs.io/en/stable/examples.html)
Do attribute needs a list with commands ["--media", "youtube", "--params", "direct"], replace youtube with your plugin name and direct with your prefered mode.
Custom timezone for each cron
direct : A simple redirect to final stream URL. (faster, no disk usage, sponsorblock not works)
bridge : Remuxing on fly. (fast, no disk usage)
download : First download full video then it's served. (slow, temp disk usage)
iframe : The .strm stores the public YouTube watch URL (https://www.youtube.com/watch?v=<id>) instead of a ytdlp2STRM URL. Useful for players/apps that can play YouTube URLs natively without going through this service.
With download mode, the files in the temp folder older than 24h will be deleted.
sequential uses incremental episode numbers; mmdd uses month/day style numbering.best for no limit, or values like 1080, 720, 480..vtt sidecar files and fix alignment/rollup issues for Jellyfin/Emby..srt sidecar files from cleaned .vtt subtitles for better Android/TV compatibility..vtt files after SRT conversion. Set False to leave only .srt files.<strm_output_folder>/.direct_cache.0 to disable.ytdlp2STRM generates .strm files under the strm_output_folder configured in each plugin (plugins/<media>/config.json). Each .strm just contains a URL that points back to this service, so Jellyfin/Emby must have access both to the STRM files and to the ytdlp2STRM HTTP server (http://<host>:5000 by default).
Typical setup:
/media/Youtube on Linux or D:\media\Youtube on Windows.strm_output_folder value in plugins/youtube/config.json (and equivalent for Twitch/etc.)./media/Youtube). Do the same for Twitch if you use that plugin.Channel [ID]/Season {year}/S{year}E{XX} - Video title.strm).jellyfin_integration so the service triggers a scan of the configured jellyfin_library_name after new STRM files are written. Populate jellyfin_base_url and jellyfin_api_key accordingly.If Jellyfin runs on a different host/container than ytdlp2STRM, the URL stored inside the
.strmfiles (http://127.0.0.1:5000/...by default) must be reachable from that host. Changeytdlp2strm_hostinconfig/config.jsonto the IP/DNS name the Jellyfin server can reach.
FileNotFoundError: [WinError 2] The system cannot find the file specified (or the equivalent on Linux/macOS) when opening a direct URL or running the plugin from the Dashboard: the service tried to run yt-dlp as a subprocess but could not find the binary on PATH. Install yt-dlp as a binary (see the Prerequisite section) and restart the ytdlp2STRM service.ytdlp2strm.log for FileNotFoundError or command not found: yt-dlp.only python cli.py command can be executed from here: fixed in recent versions by resolving the interpreter server-side. If you still hit it, update to the latest version.lang is set: YouTube videos with dubbed tracks default to the original. Recent versions add -S lang:<lang> automatically so the configured language is preferred. Update and restart.Python
74.2%
HTML
23.2%
JavaScript
2.2%