├── .github ├── dependabot.yml └── workflows │ └── docker-image.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── requirements.txt ├── templates └── index.html ├── youtube-dl-server.png └── youtube-dl-server.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'pip' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | tags: 8 | - 'v*' 9 | pull_request: 10 | branches: 11 | - 'main' 12 | 13 | jobs: 14 | docker: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | - name: Docker meta 20 | id: meta 21 | uses: docker/metadata-action@v5 22 | with: 23 | images: | 24 | kmb32123/youtube-dl-server 25 | tags: | 26 | type=ref,event=branch 27 | type=ref,event=pr 28 | type=semver,pattern={{version}} 29 | type=semver,pattern={{major}}.{{minor}} 30 | type=raw,value=latest,enable={{is_default_branch}} 31 | - name: Set up QEMU 32 | uses: docker/setup-qemu-action@v3 33 | - name: Set up Docker Buildx 34 | uses: docker/setup-buildx-action@v3 35 | - name: Login to Docker Hub 36 | if: github.event_name != 'pull_request' 37 | uses: docker/login-action@v3 38 | with: 39 | username: ${{ secrets.DOCKERHUB_USERNAME }} 40 | password: ${{ secrets.DOCKERHUB_TOKEN }} 41 | - name: Build and push 42 | uses: docker/build-push-action@v5 43 | with: 44 | context: . 45 | platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 46 | push: ${{ github.event_name != 'pull_request' }} 47 | tags: ${{ steps.meta.outputs.tags }} 48 | labels: ${{ steps.meta.outputs.labels }} 49 | cache-from: type=gha 50 | cache-to: type=gha,mode=max 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pdm 86 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 87 | #pdm.lock 88 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 89 | # in version control. 90 | # https://pdm.fming.dev/#use-with-ide 91 | .pdm.toml 92 | 93 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 94 | __pypackages__/ 95 | 96 | # Celery stuff 97 | celerybeat-schedule 98 | celerybeat.pid 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # Pyre type checker 128 | .pyre/ 129 | 130 | # pytype static type analyzer 131 | .pytype/ 132 | 133 | # Cython debug symbols 134 | cython_debug/ 135 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.2.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-toml 7 | - id: check-yaml 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | rev: v0.3.4 12 | hooks: 13 | - id: ruff 14 | args: [--fix] 15 | - id: ruff-format 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "basic", 3 | "[python]": { 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll": "explicit", 7 | "source.organizeImports": "explicit" 8 | }, 9 | "editor.defaultFormatter": "charliermarsh.ruff" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # youtube-dl-server Dockerfile 3 | # 4 | # https://github.com/manbearwiz/youtube-dl-server-dockerfile 5 | # 6 | 7 | FROM python:alpine 8 | 9 | RUN apk add --no-cache \ 10 | ffmpeg \ 11 | tzdata 12 | 13 | RUN mkdir -p /usr/src/app 14 | WORKDIR /usr/src/app 15 | 16 | COPY requirements.txt /usr/src/app/ 17 | RUN apk --update-cache add --virtual build-dependencies gcc libc-dev make \ 18 | && pip install --no-cache-dir -r requirements.txt \ 19 | && apk del build-dependencies 20 | 21 | COPY . /usr/src/app 22 | 23 | EXPOSE 8080 24 | 25 | VOLUME ["/youtube-dl"] 26 | 27 | CMD ["uvicorn", "youtube-dl-server:app", "--host", "0.0.0.0", "--port", "8080"] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Kevin Brey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Docker Stars Shield](https://img.shields.io/docker/stars/kmb32123/youtube-dl-server.svg?style=flat-square)](https://hub.docker.com/r/kmb32123/youtube-dl-server/) 2 | [![Docker Pulls Shield](https://img.shields.io/docker/pulls/kmb32123/youtube-dl-server.svg?style=flat-square)](https://hub.docker.com/r/kmb32123/youtube-dl-server/) 3 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://raw.githubusercontent.com/manbearwiz/youtube-dl-server/master/LICENSE) 4 | ![Workflow](https://github.com/manbearwiz/youtube-dl-server/actions/workflows/docker-image.yml/badge.svg) 5 | 6 | # youtube-dl-server 7 | 8 | Very spartan Web and REST interface for downloading youtube videos onto a server. [`starlette`](https://github.com/encode/starlette) + [`yt-dlp`](https://github.com/yt-dlp/yt-dlp). 9 | 10 | ![screenshot][1] 11 | 12 | ## Running 13 | 14 | ### Docker CLI 15 | 16 | This example uses the docker run command to create the container to run the app. Here we also use host networking for simplicity. Also note the `-v` argument. This directory will be used to output the resulting videos 17 | 18 | ```shell 19 | docker run -d --net="host" --name youtube-dl -v /home/core/youtube-dl:/youtube-dl kmb32123/youtube-dl-server 20 | ``` 21 | 22 | ### Docker Compose 23 | 24 | This is an example service definition that could be put in `docker-compose.yml`. This service uses a VPN client container for its networking. 25 | 26 | ```yml 27 | youtube-dl: 28 | image: "kmb32123/youtube-dl-server" 29 | network_mode: "service:vpn" 30 | volumes: 31 | - /home/core/youtube-dl:/youtube-dl 32 | restart: always 33 | ``` 34 | 35 | ### Python 36 | 37 | If you have python ^3.6.0 installed in your PATH you can simply run like this, providing optional environment variable overrides inline. 38 | 39 | ```shell 40 | YDL_UPDATE_TIME=False python3 -m uvicorn youtube-dl-server:app --port 8123 41 | ``` 42 | 43 | In this example, `YDL_UPDATE_TIME=False` is the same as the command line option `--no-mtime`. 44 | 45 | ## Usage 46 | 47 | ### Start a download remotely 48 | 49 | Downloads can be triggered by supplying the `{{url}}` of the requested video through the Web UI or through the REST interface via curl, etc. 50 | 51 | #### HTML 52 | 53 | Just navigate to `http://{{host}}:8080/youtube-dl` and enter the requested `{{url}}`. 54 | 55 | #### Curl 56 | 57 | ```shell 58 | curl -X POST --data-urlencode "url={{url}}" http://{{host}}:8080/youtube-dl/q 59 | ``` 60 | 61 | #### Fetch 62 | 63 | ```javascript 64 | fetch(`http://${host}:8080/youtube-dl/q`, { 65 | method: "POST", 66 | body: new URLSearchParams({ 67 | url: url, 68 | format: "bestvideo" 69 | }), 70 | }); 71 | ``` 72 | 73 | #### Bookmarklet 74 | 75 | Add the following bookmarklet to your bookmark bar so you can conviently send the current page url to your youtube-dl-server instance. 76 | 77 | ```javascript 78 | javascript:!function(){fetch("http://${host}:8080/youtube-dl/q",{body:new URLSearchParams({url:window.location.href,format:"bestvideo"}),method:"POST"})}(); 79 | ``` 80 | 81 | ## Implementation 82 | 83 | The server uses [`starlette`](https://github.com/encode/starlette) for the web framework and [`youtube-dl`](https://github.com/rg3/youtube-dl) to handle the downloading. The integration with youtube-dl makes use of their [python api](https://github.com/rg3/youtube-dl#embedding-youtube-dl). 84 | 85 | This docker image is based on [`python:alpine`](https://registry.hub.docker.com/_/python/) and consequently [`alpine:3.8`](https://hub.docker.com/_/alpine/). 86 | 87 | [1]:youtube-dl-server.png 88 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | This service isn't intended to be exposed to the internet but if you find somehting that you think should be addressed, you can message me on twitter, [@manbearwiz](https://twitter.com/manbearwiz). 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles==24.1.0 2 | Jinja2==3.1.6 3 | python-multipart==0.0.9 4 | starlette==0.43.0 5 | uvicorn[standard]==0.34.2; platform_machine == 'x86_64' 6 | uvicorn==0.34.0; platform_machine != 'x86_64' 7 | yt-dlp==2025.5.22 8 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 15 | 24 | 25 | youtube-dl 26 | 27 | 28 | 29 |
30 |
31 | 32 |
33 |

youtube-dl

34 |

35 | Enter a video URL to download the video to the server. The URL can be 36 | from YouTube or 37 | any other supported site. The server will automatically download the highest quality version 42 | available. 43 |

44 |
45 | 46 |
47 | 56 | 77 | 80 |
81 |
82 |
83 | 96 |
97 | 102 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /youtube-dl-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manbearwiz/youtube-dl-server/8cb32a7cdba2ecc8881bdb80fdcee5faf2b78bd8/youtube-dl-server.png -------------------------------------------------------------------------------- /youtube-dl-server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | 4 | from starlette.status import HTTP_303_SEE_OTHER 5 | from starlette.applications import Starlette 6 | from starlette.config import Config 7 | from starlette.responses import JSONResponse, RedirectResponse 8 | from starlette.routing import Route 9 | from starlette.templating import Jinja2Templates 10 | from starlette.background import BackgroundTask 11 | 12 | from yt_dlp import YoutubeDL, version 13 | 14 | templates = Jinja2Templates(directory="templates") 15 | config = Config(".env") 16 | 17 | app_defaults = { 18 | "YDL_FORMAT": config("YDL_FORMAT", cast=str, default="bestvideo+bestaudio/best"), 19 | "YDL_EXTRACT_AUDIO_FORMAT": config("YDL_EXTRACT_AUDIO_FORMAT", default=None), 20 | "YDL_EXTRACT_AUDIO_QUALITY": config( 21 | "YDL_EXTRACT_AUDIO_QUALITY", cast=str, default="192" 22 | ), 23 | "YDL_RECODE_VIDEO_FORMAT": config("YDL_RECODE_VIDEO_FORMAT", default=None), 24 | "YDL_OUTPUT_TEMPLATE": config( 25 | "YDL_OUTPUT_TEMPLATE", 26 | cast=str, 27 | default="/youtube-dl/%(title).200s [%(id)s].%(ext)s", 28 | ), 29 | "YDL_ARCHIVE_FILE": config("YDL_ARCHIVE_FILE", default=None), 30 | "YDL_UPDATE_TIME": config("YDL_UPDATE_TIME", cast=bool, default=True), 31 | } 32 | 33 | 34 | async def dl_queue_list(request): 35 | return templates.TemplateResponse( 36 | "index.html", {"request": request, "ytdlp_version": version.__version__} 37 | ) 38 | 39 | 40 | async def redirect(request): 41 | return RedirectResponse(url="/youtube-dl") 42 | 43 | 44 | async def q_put(request): 45 | form = await request.form() 46 | url = form.get("url").strip() 47 | ui = form.get("ui") 48 | options = {"format": form.get("format")} 49 | 50 | if not url: 51 | return JSONResponse( 52 | {"success": False, "error": "/q called without a 'url' in form data"} 53 | ) 54 | 55 | task = BackgroundTask(download, url, options) 56 | 57 | print("Added url " + url + " to the download queue") 58 | 59 | if not ui: 60 | return JSONResponse( 61 | {"success": True, "url": url, "options": options}, background=task 62 | ) 63 | return RedirectResponse( 64 | url="/youtube-dl?added=" + url, status_code=HTTP_303_SEE_OTHER, background=task 65 | ) 66 | 67 | 68 | async def update_route(scope, receive, send): 69 | task = BackgroundTask(update) 70 | 71 | return JSONResponse({"output": "Initiated package update"}, background=task) 72 | 73 | 74 | def update(): 75 | try: 76 | output = subprocess.check_output( 77 | [sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp"] 78 | ) 79 | 80 | print(output.decode("utf-8")) 81 | except subprocess.CalledProcessError as e: 82 | print(e.output) 83 | 84 | 85 | def get_ydl_options(request_options): 86 | request_vars = { 87 | "YDL_EXTRACT_AUDIO_FORMAT": None, 88 | "YDL_RECODE_VIDEO_FORMAT": None, 89 | } 90 | 91 | requested_format = request_options.get("format", "bestvideo") 92 | 93 | if requested_format in ["aac", "flac", "mp3", "m4a", "opus", "vorbis", "wav"]: 94 | request_vars["YDL_EXTRACT_AUDIO_FORMAT"] = requested_format 95 | elif requested_format == "bestaudio": 96 | request_vars["YDL_EXTRACT_AUDIO_FORMAT"] = "best" 97 | elif requested_format in ["mp4", "flv", "webm", "ogg", "mkv", "avi"]: 98 | request_vars["YDL_RECODE_VIDEO_FORMAT"] = requested_format 99 | 100 | ydl_vars = app_defaults | request_vars 101 | 102 | postprocessors = [] 103 | 104 | if ydl_vars["YDL_EXTRACT_AUDIO_FORMAT"]: 105 | postprocessors.append( 106 | { 107 | "key": "FFmpegExtractAudio", 108 | "preferredcodec": ydl_vars["YDL_EXTRACT_AUDIO_FORMAT"], 109 | "preferredquality": ydl_vars["YDL_EXTRACT_AUDIO_QUALITY"], 110 | } 111 | ) 112 | 113 | if ydl_vars["YDL_RECODE_VIDEO_FORMAT"]: 114 | postprocessors.append( 115 | { 116 | "key": "FFmpegVideoConvertor", 117 | "preferedformat": ydl_vars["YDL_RECODE_VIDEO_FORMAT"], 118 | } 119 | ) 120 | 121 | return { 122 | "format": ydl_vars["YDL_FORMAT"], 123 | "postprocessors": postprocessors, 124 | "outtmpl": ydl_vars["YDL_OUTPUT_TEMPLATE"], 125 | "download_archive": ydl_vars["YDL_ARCHIVE_FILE"], 126 | "updatetime": ydl_vars["YDL_UPDATE_TIME"] == "True", 127 | } 128 | 129 | 130 | def download(url, request_options): 131 | with YoutubeDL(get_ydl_options(request_options)) as ydl: 132 | ydl.download([url]) 133 | 134 | 135 | routes = [ 136 | Route("/", endpoint=redirect), 137 | Route("/youtube-dl", endpoint=dl_queue_list), 138 | Route("/youtube-dl/q", endpoint=q_put, methods=["POST"]), 139 | Route("/youtube-dl/update", endpoint=update_route, methods=["PUT"]), 140 | ] 141 | 142 | app = Starlette(debug=True, routes=routes) 143 | 144 | print("Updating youtube-dl to the newest version") 145 | update() 146 | --------------------------------------------------------------------------------