From 5fec503512cd6cb768fe369710e93438d6b5ce04 Mon Sep 17 00:00:00 2001
From: TonyBlu <101348912+TonyBlur@users.noreply.github.com>
Date: Fri, 1 May 2026 11:54:30 +0800
Subject: [PATCH 1/2] Fix downloads service tests with optional visit header
---
.../configuration-help.yml | 12 +-
.github/DISCUSSION_TEMPLATE/general.yml | 2 +-
.github/DISCUSSION_TEMPLATE/q-and-a.yml | 10 +-
.github/ISSUE_TEMPLATE/bug_report.yml | 4 +-
.github/ISSUE_TEMPLATE/config.yml | 2 +-
.github/ISSUE_TEMPLATE/feature_request.yml | 2 +-
.github/ISSUE_TEMPLATE/question.yml | 12 +-
.github/workflows/sync-upstream.yml | 47 ++++
.github/workflows/update-yt-dlp.yml | 2 +-
Dockerfile | 4 +-
README.md | 117 +++++----
app/main.py | 124 ++++++++-
app/ytdl.py | 138 +++++++---
docker-entrypoint.sh | 16 ++
ui/angular.json | 8 +-
ui/package.json | 2 +-
ui/src/app/app.html | 38 +--
ui/src/app/app.sass | 243 +++++++++++-------
ui/src/app/app.ts | 30 ++-
ui/src/app/services/downloads.service.ts | 28 +-
ui/src/app/services/metube-socket.service.ts | 9 +-
ui/src/index.html | 2 +-
ui/src/manifest.webmanifest | 4 +-
23 files changed, 587 insertions(+), 269 deletions(-)
create mode 100644 .github/workflows/sync-upstream.yml
diff --git a/.github/DISCUSSION_TEMPLATE/configuration-help.yml b/.github/DISCUSSION_TEMPLATE/configuration-help.yml
index 6f3965b..7c6d21f 100644
--- a/.github/DISCUSSION_TEMPLATE/configuration-help.yml
+++ b/.github/DISCUSSION_TEMPLATE/configuration-help.yml
@@ -13,9 +13,9 @@ body:
options:
- label: I have searched existing discussions and issues for similar configuration problems
required: true
- - label: I have read the [configuration section](https://github.com/alexta69/metube#%EF%B8%8F-configuration-via-environment-variables) in the README
+ - label: I have read the [configuration section](https://github.com/TonyBlur/mytube#%EF%B8%8F-configuration-via-environment-variables) in the README
required: true
- - label: I have checked the [Wiki](https://github.com/alexta69/metube/wiki) for configuration examples
+ - label: I have checked the [Wiki](https://github.com/TonyBlur/mytube/wiki) for configuration examples
required: true
- type: markdown
@@ -24,9 +24,9 @@ body:
## Configuration Resources
Before asking for help, please check these resources:
- - **[Configuration Guide](https://github.com/alexta69/metube#%EF%B8%8F-configuration-via-environment-variables)** - All available environment variables
- - **[YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations
- - **[OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples
+ - **[Configuration Guide](https://github.com/TonyBlur/mytube#%EF%B8%8F-configuration-via-environment-variables)** - All available environment variables
+ - **[YTDL_OPTIONS Cookbook](https://github.com/TonyBlur/mytube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations
+ - **[OUTPUT_TEMPLATE Cookbook](https://github.com/TonyBlur/mytube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples
- type: textarea
id: configuration-goal
@@ -53,7 +53,7 @@ body:
Environment Details:
- MeTube version: [e.g., latest, specific version]
- - Docker image: [e.g., ghcr.io/alexta69/metube:latest]
+ - Docker image: [e.g., ghcr.io/tonyblur/mytube:latest]
- Operating System: [e.g., Ubuntu 20.04, Windows 10, macOS 12]
Error Messages or Issues:
diff --git a/.github/DISCUSSION_TEMPLATE/general.yml b/.github/DISCUSSION_TEMPLATE/general.yml
index debc250..a69a1dc 100644
--- a/.github/DISCUSSION_TEMPLATE/general.yml
+++ b/.github/DISCUSSION_TEMPLATE/general.yml
@@ -13,7 +13,7 @@ body:
options:
- label: I have searched existing discussions to ensure this topic hasn't been discussed before
required: true
- - label: I have read the [README](https://github.com/alexta69/metube#readme) and relevant sections
+ - label: I have read the [README](https://github.com/TonyBlur/mytube#readme) and relevant sections
required: true
- type: markdown
diff --git a/.github/DISCUSSION_TEMPLATE/q-and-a.yml b/.github/DISCUSSION_TEMPLATE/q-and-a.yml
index ccae5ac..6232857 100644
--- a/.github/DISCUSSION_TEMPLATE/q-and-a.yml
+++ b/.github/DISCUSSION_TEMPLATE/q-and-a.yml
@@ -13,9 +13,9 @@ body:
options:
- label: I have searched existing discussions and issues to see if my question has been answered before
required: true
- - label: I have read the [README](https://github.com/alexta69/metube#readme) and relevant sections
+ - label: I have read the [README](https://github.com/TonyBlur/mytube#readme) and relevant sections
required: true
- - label: I have checked the [Wiki](https://github.com/alexta69/metube/wiki) for configuration examples
+ - label: I have checked the [Wiki](https://github.com/TonyBlur/mytube/wiki) for configuration examples
required: true
- type: markdown
@@ -24,9 +24,9 @@ body:
## Quick Resources
Before asking your question, please check these resources:
- - **[README](https://github.com/alexta69/metube#readme)** - Complete setup and configuration guide
- - **[YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations
- - **[OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples
+ - **[README](https://github.com/TonyBlur/mytube#readme)** - Complete setup and configuration guide
+ - **[YTDL_OPTIONS Cookbook](https://github.com/TonyBlur/mytube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations
+ - **[OUTPUT_TEMPLATE Cookbook](https://github.com/TonyBlur/mytube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples
- type: textarea
id: question
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index ac6da39..3f6e7ef 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -13,7 +13,7 @@ body:
options:
- label: I have searched existing issues and discussions to ensure this bug hasn't been reported before
required: true
- - label: I have read the [troubleshooting section](https://github.com/alexta69/metube#-troubleshooting-and-submitting-issues) in the README
+ - label: I have read the [troubleshooting section](https://github.com/TonyBlur/mytube#-troubleshooting-and-submitting-issues) in the README
required: true
- label: I have tested this issue with yt-dlp directly (not just through MeTube UI) as described in the README
required: true
@@ -62,7 +62,7 @@ body:
placeholder: |
Environment:
- MeTube version: [e.g., latest, specific version]
- - Docker image: [e.g., ghcr.io/alexta69/metube:latest]
+ - Docker image: [e.g., ghcr.io/tonyblur/mytube:latest]
- Operating System: [e.g., Ubuntu 20.04, Windows 10, macOS 12]
Configuration:
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index aeb0284..f976a50 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,7 +1,7 @@
blank_issues_enabled: false
contact_links:
- name: MeTube Community Discussions
- url: https://github.com/alexta69/metube/discussions
+ url: https://github.com/TonyBlur/mytube/discussions
about: Ask questions and discuss MeTube with the community
- name: yt-dlp Issues
url: https://github.com/yt-dlp/yt-dlp/issues
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index e8a2255..2dd23d0 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -13,7 +13,7 @@ body:
options:
- label: I have searched existing issues and discussions to ensure this feature hasn't been requested before
required: true
- - label: I have read the [feature request guidelines](https://github.com/alexta69/metube#-submitting-feature-requests) in the README
+ - label: I have read the [feature request guidelines](https://github.com/TonyBlur/mytube#-submitting-feature-requests) in the README
required: true
- label: I understand that MeTube development relies on community contributions and the maintainer is not likely to implement this feature
required: true
diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml
index 3ca15dd..e6c1ae8 100644
--- a/.github/ISSUE_TEMPLATE/question.yml
+++ b/.github/ISSUE_TEMPLATE/question.yml
@@ -13,11 +13,11 @@ body:
options:
- label: I have searched existing issues and discussions to see if my question has been answered before
required: true
- - label: I have read the [README](https://github.com/alexta69/metube#readme) and relevant sections
+ - label: I have read the [README](https://github.com/TonyBlur/mytube#readme) and relevant sections
required: true
- - label: I have checked the [Wiki](https://github.com/alexta69/metube/wiki) for configuration examples
+ - label: I have checked the [Wiki](https://github.com/TonyBlur/mytube/wiki) for configuration examples
required: true
- - label: I have read the [troubleshooting section](https://github.com/alexta69/metube#-troubleshooting-and-submitting-issues) if this is a technical issue
+ - label: I have read the [troubleshooting section](https://github.com/TonyBlur/mytube#-troubleshooting-and-submitting-issues) if this is a technical issue
required: true
- type: markdown
@@ -26,9 +26,9 @@ body:
## Quick Resources
Before asking your question, please check these resources:
- - **[README](https://github.com/alexta69/metube#readme)** - Complete setup and configuration guide
- - **[YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations
- - **[OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples
+ - **[README](https://github.com/TonyBlur/mytube#readme)** - Complete setup and configuration guide
+ - **[YTDL_OPTIONS Cookbook](https://github.com/TonyBlur/mytube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations
+ - **[OUTPUT_TEMPLATE Cookbook](https://github.com/TonyBlur/mytube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples
- type: textarea
id: question
diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml
new file mode 100644
index 0000000..dc82c16
--- /dev/null
+++ b/.github/workflows/sync-upstream.yml
@@ -0,0 +1,47 @@
+name: Upstream Sync
+
+permissions:
+ contents: write
+ issues: write
+
+on:
+ schedule:
+ - cron: "0 */6 * * *"
+ workflow_dispatch:
+
+jobs:
+ sync_latest_from_upstream:
+ name: Sync latest commits from upstream repo
+ runs-on: ubuntu-latest
+ if: ${{ github.event.repository.fork }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Clean issue notice
+ uses: actions-cool/issues-helper@v3
+ with:
+ actions: close-issues
+ labels: "π¨ Sync Fail"
+
+ - name: Sync upstream changes
+ id: sync
+ uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
+ with:
+ upstream_sync_repo: alexta69/metube
+ upstream_sync_branch: master
+ target_sync_branch: master
+ target_repo_token: ${{ secrets.GITHUB_TOKEN }}
+ test_mode: false
+
+ - name: Sync check
+ if: failure()
+ uses: actions-cool/issues-helper@v3
+ with:
+ actions: create-issue
+ title: "π¨ εζ₯ε€±θ΄₯ | Sync Fail"
+ labels: "π¨ Sync Fail"
+ body: |
+ Due to conflicts or workflow changes in the [alexta69/metube](https://github.com/alexta69/metube) upstream repository, the automatic sync has failed. Please manually sync your fork and resolve any conflicts.
+
+ η±δΊδΈζΈΈδ»εΊ [alexta69/metube](https://github.com/alexta69/metube) εε¨ε²ηͺζ workflow εζ΄οΌθͺε¨εζ₯ε€±θ΄₯γθ―·ζε¨ Sync Fork εΉΆθ§£ε³ε²ηͺγ
diff --git a/.github/workflows/update-yt-dlp.yml b/.github/workflows/update-yt-dlp.yml
index b9104e6..f05d66b 100644
--- a/.github/workflows/update-yt-dlp.yml
+++ b/.github/workflows/update-yt-dlp.yml
@@ -31,7 +31,7 @@ jobs:
if [ -n "$LATEST_VERSION" ] && [ "$LATEST_VERSION" != "$CURRENT_VERSION" ]; then
echo "Updating yt-dlp from $CURRENT_VERSION to $LATEST_VERSION"
uv lock --upgrade-package "yt-dlp[default,curl-cffi]"
- git config --global user.email "updater@metube"
+ git config --global user.email "updater@mytube"
git config --global user.name "AutoUpdater"
git add uv.lock
git commit -m "upgrade yt-dlp from $CURRENT_VERSION to $LATEST_VERSION"
diff --git a/Dockerfile b/Dockerfile
index f9896bc..ef3f012 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -54,7 +54,7 @@ RUN BGUTIL_TAG="$(curl -Ls -o /dev/null -w '%{url_effective}' https://github.com
rm /tmp/bgutil-ytdlp-pot-provider-rs.zip
COPY app ./app
-COPY --from=builder /metube/dist/metube ./ui/dist/metube
+COPY --from=builder /metube/dist/mytube ./ui/dist/mytube
ENV PUID=1000
ENV PGID=1000
@@ -64,6 +64,8 @@ ENV DOWNLOAD_DIR=/downloads
ENV STATE_DIR=/downloads/.metube
ENV TEMP_DIR=/downloads
ENV PORT=8081
+ENV PUBLIC_MODE=false
+ENV ENV_FILE=
VOLUME /downloads
EXPOSE 8081
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS "http://localhost:${PORT}/" || exit 1
diff --git a/README.md b/README.md
index d6f80aa..29ed197 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,33 @@
-# MeTube
+## Fork note
+This project is forked from [alexta69/metube](https://github.com/alexta69/metube).
-
-
+# MyTube
-MeTube is a self-hosted web UI for `yt-dlp`, for downloading media from YouTube and [dozens of other sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
+
+
+
+MyTube is a self-hosted web UI for `yt-dlp`, for downloading media from YouTube and [dozens of other sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
Key capabilities:
* Download videos, audio, captions, and thumbnails from a browser UI.
* Download playlists and channels, with configurable output and download options.
* Subscribe to channels and playlists, periodically check for new items, and queue new uploads automatically.
-
+
## π³ Run using Docker
```bash
-docker run -d -p 8081:8081 -v /path/to/downloads:/downloads ghcr.io/alexta69/metube
+docker run -d -p 8081:8081 -v /path/to/downloads:/downloads ghcr.io/tonyblur/mytube
```
## π³ Run using docker-compose
```yaml
services:
- metube:
- image: ghcr.io/alexta69/metube
- container_name: metube
+ mytube:
+ image: ghcr.io/tonyblur/mytube
+ container_name: mytube
restart: unless-stopped
ports:
- "8081:8081"
@@ -36,6 +39,10 @@ services:
Certain values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in docker-compose.
+You can also load variables from files:
+* Docker CLI supports `--env-file /path/to/.env`.
+* The container entrypoint auto-loads `/app/.env` if present, and also loads a custom file when `ENV_FILE` points to it.
+
### β¬οΈ Download Behavior
* __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`.
@@ -54,11 +61,11 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`.
* __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`.
* __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`.
-* __STATE_DIR__: Path to where MeTube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise.
+* __STATE_DIR__: Path to where mytube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.mytube` in the Docker image, and `.` otherwise.
* __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
* Set this to an SSD or RAM filesystem (e.g., `tmpfs`) for better performance.
* __Note__: Using a RAM filesystem may prevent downloads from being resumed.
-* __CHOWN_DIRS__: If `false`, ownership of `DOWNLOAD_DIR`, `STATE_DIR`, and `TEMP_DIR` (and their contents) will not be set on container start. Ensure user under which MeTube runs has necessary access to these directories already. Defaults to `true`.
+* __CHOWN_DIRS__: If `false`, ownership of `DOWNLOAD_DIR`, `STATE_DIR`, and `TEMP_DIR` (and their contents) will not be set on container start. Ensure user under which mytube runs has necessary access to these directories already. Defaults to `true`.
### π File Naming & yt-dlp
@@ -77,26 +84,26 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __HOST__: The host address the web server will bind to. Defaults to `0.0.0.0` (all interfaces).
* __PORT__: The port number the web server will listen on. Defaults to `8081`.
* __URL_PREFIX__: Base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`.
-* __PUBLIC_HOST_URL__: Base URL for the download links shown in the UI for completed files. By default, MeTube serves them under its own URL. If your download directory is accessible on another URL and you want the download links to be based there, use this variable to set it.
+* __PUBLIC_HOST_URL__: Base URL for the download links shown in the UI for completed files. By default, mytube serves them under its own URL. If your download directory is accessible on another URL and you want the download links to be based there, use this variable to set it.
* __PUBLIC_HOST_AUDIO_URL__: Same as PUBLIC_HOST_URL but for audio downloads.
* __HTTPS__: Use `https` instead of `http` (__CERTFILE__ and __KEYFILE__ required). Defaults to `false`.
* __CERTFILE__: HTTPS certificate file path.
* __KEYFILE__: HTTPS key file path.
-* __CORS_ALLOWED_ORIGINS__: Comma-separated list of origins permitted to make cross-origin requests to the MeTube API. When unset or empty, all cross-origin requests are denied. Set to `*` to allow all origins. This must be configured for [browser extensions](#-browser-extensions), [bookmarklets](#-bookmarklet), and any other browser-based tools that contact MeTube from a different origin. For browser extensions use `*` (see below); for bookmarklets you can list specific sites, e.g. `https://www.youtube.com,https://www.vimeo.com`.
+* __CORS_ALLOWED_ORIGINS__: Comma-separated list of origins permitted to make cross-origin requests to the mytube API. When unset or empty, all cross-origin requests are denied. Set to `*` to allow all origins. This must be configured for [browser extensions](#-browser-extensions), [bookmarklets](#-bookmarklet), and any other browser-based tools that contact mytube from a different origin. For browser extensions use `*` (see below); for bookmarklets you can list specific sites, e.g. `https://www.youtube.com,https://www.vimeo.com`.
* __ROBOTS_TXT__: A path to a `robots.txt` file mounted in the container.
### π Basic Setup
-* __PUID__: User under which MeTube will run. Defaults to `1000` (legacy `UID` also supported).
-* __PGID__: Group under which MeTube will run. Defaults to `1000` (legacy `GID` also supported).
-* __UMASK__: Umask value used by MeTube. Defaults to `022`.
+* __PUID__: User under which mytube will run. Defaults to `1000` (legacy `UID` also supported).
+* __PGID__: Group under which mytube will run. Defaults to `1000` (legacy `GID` also supported).
+* __UMASK__: Umask value used by mytube. Defaults to `022`.
* __DEFAULT_THEME__: Default theme to use for the UI, can be set to `light`, `dark`, or `auto`. Defaults to `auto`.
* __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or `NONE`. Defaults to `INFO`.
* __ENABLE_ACCESSLOG__: Whether to enable access log. Defaults to `false`.
## ποΈ Configuring yt-dlp options
-MeTube lets you customize how [yt-dlp](https://github.com/yt-dlp/yt-dlp) behaves at three levels, from broadest to most specific:
+MyTube lets you customize how [yt-dlp](https://github.com/yt-dlp/yt-dlp) behaves at three levels, from broadest to most specific:
1. **Global options** β apply to every download by default.
2. **Presets** β named bundles of options that users can pick per download from the UI.
@@ -108,7 +115,7 @@ In JSON presets and overrides, setting an option to **`null`** clears that optio
### Option format
-yt-dlp options in MeTube are expressed as JSON objects. The keys are yt-dlp API option names, which roughly correspond to command-line flags with dashes replaced by underscores. For example, the command-line flag `--write-subs` becomes `"writesubtitles": true` in JSON.
+yt-dlp options in MyTube are expressed as JSON objects. The keys are yt-dlp API option names, which roughly correspond to command-line flags with dashes replaced by underscores. For example, the command-line flag `--write-subs` becomes `"writesubtitles": true` in JSON.
> **Tip:** Some command-line flags don't have a direct single-key equivalent β for instance, `--embed-thumbnail` and `--recode-video` must be expressed via `"postprocessors"`. A full list of available API options can be found [in the yt-dlp source](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L224), and [this conversion script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) can help translate command-line flags to their API equivalents.
@@ -187,7 +194,7 @@ When multiple presets are selected for a download, they are applied in order. If
### Per-download overrides
-For one-off tweaks, MeTube can expose a free-text JSON field in the UI ("Custom yt-dlp Options") where users type yt-dlp options that apply only to that single download. This is disabled by default:
+For one-off tweaks, MyTube can expose a free-text JSON field in the UI ("Custom yt-dlp Options") where users type yt-dlp options that apply only to that single download. This is disabled by default:
```yaml
environment:
@@ -206,79 +213,79 @@ When a download starts, the final set of yt-dlp options is built in this order:
2. Apply each selected **preset** in order (later presets overwrite earlier ones for conflicting keys).
3. Apply any **per-download overrides** on top (overwrite everything else for conflicting keys).
-MeTube always forces its own flat-extract behaviour during the initial metadata fetch (`extract_flat`, `noplaylist`, etc.); presets cannot override those keys for that phase.
+MyTube always forces its own flat-extract behaviour during the initial metadata fetch (`extract_flat`, `noplaylist`, etc.); presets cannot override those keys for that phase.
**Example:** Suppose your global options set `"writesubtitles": false`, but you select a preset that sets `"writesubtitles": true`. Subtitles will be written for that download because the preset overrides the global setting. If you additionally enter `{"writesubtitles": false}` in the per-download overrides field, that value wins and subtitles will not be written.
### Configuration cookbooks
-The project's Wiki contains examples of useful configurations contributed by users of MeTube:
-* [YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)
-* [OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)
+The project's Wiki contains examples of useful configurations contributed by users of MyTube:
+* [YTDL_OPTIONS Cookbook](https://github.com/TonyBlur/mytube/wiki/YTDL_OPTIONS-Cookbook)
+* [OUTPUT_TEMPLATE Cookbook](https://github.com/TonyBlur/mytube/wiki/OUTPUT_TEMPLATE-Cookbook)
## πͺ Using browser cookies
-In case you need to use your browser's cookies with MeTube, for example to download restricted or private videos:
+In case you need to use your browser's cookies with MyTube, for example to download restricted or private videos:
* Install in your browser an extension to extract cookies:
* [Firefox](https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/)
* [Chrome](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
* Extract the cookies you need with the extension and save/export them as `cookies.txt`.
-* In MeTube, open **Advanced Options** and use the **Upload Cookies** button to upload the file.
+* In MyTube, open **Advanced Options** and use the **Upload Cookies** button to upload the file.
* After upload, the cookie indicator should show as active.
* Use **Delete Cookies** in the same section to remove uploaded cookies.
## π Browser extensions
-Browser extensions allow right-clicking videos and sending them directly to MeTube. If you're on an HTTPS page, your MeTube instance must be behind an HTTPS reverse proxy (see below) for extensions to work.
+Browser extensions allow right-clicking videos and sending them directly to MyTube. If you're on an HTTPS page, your MyTube instance must be behind an HTTPS reverse proxy (see below) for extensions to work.
Since browser extensions make requests from their own origin (`chrome-extension://...` or `moz-extension://...`), you must set `CORS_ALLOWED_ORIGINS=*` for them to work.
-__Chrome:__ contributed by [Rpsl](https://github.com/rpsl). You can install it from [Google Chrome Webstore](https://chrome.google.com/webstore/detail/metube-downloader/fbmkmdnlhacefjljljlbhkodfmfkijdh) or use developer mode and install [from sources](https://github.com/Rpsl/metube-browser-extension).
+__Chrome:__ contributed by [Rpsl](https://github.com/rpsl). You can install it from [Google Chrome Webstore](https://chrome.google.com/webstore/detail/mytube-downloader/fbmkmdnlhacefjljljlbhkodfmfkijdh) or use developer mode and install [from sources](https://github.com/Rpsl/mytube-browser-extension).
-__Firefox:__ contributed by [nanocortex](https://github.com/nanocortex). You can install it from [Firefox Addons](https://addons.mozilla.org/en-US/firefox/addon/metube-downloader) or get sources from [here](https://github.com/nanocortex/metube-firefox-addon).
+__Firefox:__ contributed by [nanocortex](https://github.com/nanocortex). You can install it from [Firefox Addons](https://addons.mozilla.org/en-US/firefox/addon/mytube-downloader) or get sources from [here](https://github.com/nanocortex/mytube-firefox-addon).
## π± iOS Shortcut
-[rithask](https://github.com/rithask) created an iOS shortcut to send URLs to MeTube from Safari. Enter the MeTube instance address when prompted which will be saved for later use. You can run the shortcut from Safariβs share menu. The shortcut can be downloaded from [this iCloud link](https://www.icloud.com/shortcuts/66627a9f334c467baabdb2769763a1a6).
+[rithask](https://github.com/rithask) created an iOS shortcut to send URLs to MyTube from Safari. Enter the mytube instance address when prompted which will be saved for later use. You can run the shortcut from Safariβs share menu. The shortcut can be downloaded from [this iCloud link](https://www.icloud.com/shortcuts/66627a9f334c467baabdb2769763a1a6).
## π Bookmarklet
-[kushfest](https://github.com/kushfest) has created a Chrome bookmarklet for sending the currently open webpage to MeTube. Please note that if you're on an HTTPS page, your MeTube instance must be configured with `HTTPS` as `true` in the environment, or be behind an HTTPS reverse proxy (see below) for the bookmarklet to work.
+[kushfest](https://github.com/kushfest) has created a Chrome bookmarklet for sending the currently open webpage to mytube. Please note that if you're on an HTTPS page, your MyTube instance must be configured with `HTTPS` as `true` in the environment, or be behind an HTTPS reverse proxy (see below) for the bookmarklet to work.
-Since bookmarklets run in the context of the current page (e.g. youtube.com), the requests they make to MeTube are cross-origin. You must add the origins of sites where you use the bookmarklet to the __CORS_ALLOWED_ORIGINS__ environment variable, otherwise the browser will block the requests. For example, to use the bookmarklet on YouTube and Vimeo: `CORS_ALLOWED_ORIGINS=https://www.youtube.com,https://www.vimeo.com`.
+Since bookmarklets run in the context of the current page (e.g. youtube.com), the requests they make to mytube are cross-origin. You must add the origins of sites where you use the bookmarklet to the __CORS_ALLOWED_ORIGINS__ environment variable, otherwise the browser will block the requests. For example, to use the bookmarklet on YouTube and Vimeo: `CORS_ALLOWED_ORIGINS=https://www.youtube.com,https://www.vimeo.com`.
-GitHub doesn't allow embedding JavaScript as a link, so the bookmarklet has to be created manually by copying the following code to a new bookmark you create on your bookmarks bar. Change the hostname in the URL below to point to your MeTube instance.
+GitHub doesn't allow embedding JavaScript as a link, so the bookmarklet has to be created manually by copying the following code to a new bookmark you create on your bookmarks bar. Change the hostname in the URL below to point to your MyTube instance.
```javascript
-javascript:!function(){xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.withCredentials=true;xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to metube!")}else{alert("Send to metube failed. Check the javascript console for clues.")}}}();
+javascript:!function(){xhr=new XMLHttpRequest();xhr.open("POST","https://mytube.domain.com/add");xhr.withCredentials=true;xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to mytube!")}else{alert("Send to mytube failed. Check the javascript console for clues.")}}}();
```
[shoonya75](https://github.com/shoonya75) has contributed a Firefox version:
```javascript
-javascript:(function(){xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to metube!")}else{alert("Send to metube failed. Check the javascript console for clues.")}}})();
+javascript:(function(){xhr=new XMLHttpRequest();xhr.open("POST","https://mytube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to mytube!")}else{alert("Send to mytube failed. Check the javascript console for clues.")}}})();
```
The above bookmarklets use `alert()` for notifications. This variant shows a toast instead (Chrome β for Firefox, replace the `!function(){...}()` wrapper with `(function(){...})()`):
```javascript
-javascript:!function(){function notify(msg) {var sc = document.scrollingElement.scrollTop; var text = document.createElement('span');text.innerHTML=msg;var ts = text.style;ts.all = 'revert';ts.color = '#000';ts.fontFamily = 'Verdana, sans-serif';ts.fontSize = '15px';ts.backgroundColor = 'white';ts.padding = '15px';ts.border = '1px solid gainsboro';ts.boxShadow = '3px 3px 10px';ts.zIndex = '100';document.body.appendChild(text);ts.position = 'absolute'; ts.top = 50 + sc + 'px'; ts.left = (window.innerWidth / 2)-(text.offsetWidth / 2) + 'px'; setTimeout(function () { text.style.visibility = "hidden"; }, 1500);}xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function() { if(xhr.status==200){notify("Sent to metube!")}else {notify("Send to metube failed. Check the javascript console for clues.")}}}();
+javascript:!function(){function notify(msg) {var sc = document.scrollingElement.scrollTop; var text = document.createElement('span');text.innerHTML=msg;var ts = text.style;ts.all = 'revert';ts.color = '#000';ts.fontFamily = 'Verdana, sans-serif';ts.fontSize = '15px';ts.backgroundColor = 'white';ts.padding = '15px';ts.border = '1px solid gainsboro';ts.boxShadow = '3px 3px 10px';ts.zIndex = '100';document.body.appendChild(text);ts.position = 'absolute'; ts.top = 50 + sc + 'px'; ts.left = (window.innerWidth / 2)-(text.offsetWidth / 2) + 'px'; setTimeout(function () { text.style.visibility = "hidden"; }, 1500);}xhr=new XMLHttpRequest();xhr.open("POST","https://mytube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function() { if(xhr.status==200){notify("Sent to mytube!")}else {notify("Send to mytube failed. Check the javascript console for clues.")}}}();
```
## β‘ Raycast extension
-[dotvhs](https://github.com/dotvhs) has created an [extension for Raycast](https://www.raycast.com/dot/metube) for adding videos to MeTube directly from Raycast.
+[dotvhs](https://github.com/dotvhs) has created an [extension for Raycast](https://www.raycast.com/dot/mytube) for adding videos to MyTube directly from Raycast.
## π HTTPS support, and running behind a reverse proxy
-It's possible to configure MeTube to listen in HTTPS mode. `docker-compose` example:
+It's possible to configure MyTube to listen in HTTPS mode. `docker-compose` example:
```yaml
services:
- metube:
- image: ghcr.io/alexta69/metube
- container_name: metube
+ mytube:
+ image: ghcr.io/tonyblur/mytube
+ container_name: mytube
restart: unless-stopped
ports:
- "8081:8081"
@@ -292,15 +299,15 @@ services:
- KEYFILE=/ssl/key.pem
```
-MeTube can also run behind a reverse proxy for HTTPS termination or authentication. When serving under a subdirectory, set `URL_PREFIX` accordingly.
+MyTube can also run behind a reverse proxy for HTTPS termination or authentication. When serving under a subdirectory, set `URL_PREFIX` accordingly.
-The [linuxserver/swag](https://docs.linuxserver.io/general/swag) image includes ready-made snippets for MeTube in [subfolder](https://github.com/linuxserver/reverse-proxy-confs/blob/master/metube.subfolder.conf.sample) and [subdomain](https://github.com/linuxserver/reverse-proxy-confs/blob/master/metube.subdomain.conf.sample) modes, plus Authelia for authentication.
+The [linuxserver/swag](https://docs.linuxserver.io/general/swag) image includes ready-made snippets for mytube in [subfolder](https://github.com/linuxserver/reverse-proxy-confs/blob/master/mytube.subfolder.conf.sample) and [subdomain](https://github.com/linuxserver/reverse-proxy-confs/blob/master/mytube.subdomain.conf.sample) modes, plus Authelia for authentication.
### π NGINX
```nginx
-location /metube/ {
- proxy_pass http://metube:8081;
+location /mytube/ {
+ proxy_pass http://mytube:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -316,13 +323,13 @@ Contributed by [PIE-yt](https://github.com/PIE-yt). Source [here](https://gist.g
```apache
# For putting in your Apache sites site.conf
-# Serves MeTube under a /metube/ subdir (http://yourdomain.com/metube/)
-
+# Serves mytube under a /mytube/ subdir (http://yourdomain.com/mytube/)
+
ProxyPass http://localhost:8081/ retry=0 timeout=30
ProxyPassReverse http://localhost:8081/
-
+
RewriteEngine On
RewriteCond %{QUERY_STRING} transport=websocket [NC]
RewriteRule /(.*) ws://localhost:8081/socket.io/$1 [P,L]
@@ -337,29 +344,29 @@ The following example Caddyfile gets a reverse proxy going behind [caddy](https:
```caddyfile
example.com {
- route /metube/* {
- uri strip_prefix metube
- reverse_proxy metube:8081
+ route /mytube/* {
+ uri strip_prefix mytube
+ reverse_proxy mytube:8081
}
}
```
## π Updating yt-dlp
-MeTube is powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp), which requires frequent updates as video sites change their layouts. A nightly build automatically publishes a new Docker image whenever a new yt-dlp version is available, so keep your container up to date β [watchtower](https://github.com/nicholas-fedor/watchtower) works well for this.
+MyTube is powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp), which requires frequent updates as video sites change their layouts. A nightly build automatically publishes a new Docker image whenever a new yt-dlp version is available, so keep your container up to date β [watchtower](https://github.com/nicholas-fedor/watchtower) works well for this.
## π§ Troubleshooting and submitting issues
-MeTube is only a UI for [yt-dlp](https://github.com/yt-dlp/yt-dlp). Issues with authentication, postprocessing, permissions, or `YTDL_OPTIONS` should be debugged with yt-dlp directly first β once working, import those options into MeTube. To test inside the container:
+MyTube is only a UI for [yt-dlp](https://github.com/yt-dlp/yt-dlp). Issues with authentication, postprocessing, permissions, or `YTDL_OPTIONS` should be debugged with yt-dlp directly first β once working, import those options into MyTube. To test inside the container:
```bash
-docker exec -ti metube sh
+docker exec -ti mytube sh
cd /downloads
```
## π‘ Submitting feature requests
-MeTube development relies on community contributions. If you need additional features, please submit a PR. Create an issue first to discuss the implementation β some PRs may not be accepted to reduce bloat. Feature requests without an accompanying PR are unlikely to be fulfilled.
+MyTube development relies on community contributions. If you need additional features, please submit a PR. Create an issue first to discuss the implementation β some PRs may not be accepted to reduce bloat. Feature requests without an accompanying PR are unlikely to be fulfilled.
## π οΈ Building and running locally
@@ -382,7 +389,7 @@ uv run python3 app/main.py
A Docker image can be built locally (it will build the UI too):
```bash
-docker build -t metube .
+docker build -t mytube .
```
Note that if you're running the server in VSCode, your downloads will go to your user's Downloads folder (this is configured via the environment in `.vscode/launch.json`).
diff --git a/app/main.py b/app/main.py
index 61d7e6e..6a17bc2 100644
--- a/app/main.py
+++ b/app/main.py
@@ -73,9 +73,10 @@ class Config:
'MAX_CONCURRENT_DOWNLOADS': '3',
'LOGLEVEL': 'INFO',
'ENABLE_ACCESSLOG': 'false',
+ 'PUBLIC_MODE': 'false',
}
- _BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG', 'ALLOW_YTDL_OPTIONS_OVERRIDES')
+ _BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG', 'ALLOW_YTDL_OPTIONS_OVERRIDES', 'PUBLIC_MODE')
def __init__(self):
for k, v in self._DEFAULTS.items():
@@ -135,6 +136,7 @@ class Config:
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL',
'ALLOW_YTDL_OPTIONS_OVERRIDES',
+ 'PUBLIC_MODE',
)
def frontend_safe(self) -> dict:
@@ -443,26 +445,79 @@ def _migrate_legacy_request(post: dict) -> dict:
return post
+
+public_visit_downloads: dict[str, set[str]] = {}
+public_download_owner: dict[str, str] = {}
+public_download_owner_by_id: dict[str, str] = {}
+public_visit_sids: dict[str, set[str]] = {}
+public_sid_visit: dict[str, str] = {}
+
+
+def _register_public_download(visit_id: str, url: str):
+ if not visit_id or not url:
+ return
+ public_visit_downloads.setdefault(visit_id, set()).add(url)
+ public_download_owner[url] = visit_id
+
+
+def _forget_public_download(url: str | None = None, *, dl_id: str | None = None):
+ visit_id = None
+ if dl_id:
+ visit_id = public_download_owner_by_id.pop(dl_id, None)
+ if visit_id is None and url:
+ visit_id = public_download_owner.pop(url, None)
+ if not visit_id:
+ return
+ urls = public_visit_downloads.get(visit_id)
+ if not urls:
+ return
+ if url:
+ urls.discard(url)
+ if not urls:
+ public_visit_downloads.pop(visit_id, None)
+
+
+async def _emit_download_event(event: str, payload, *, url: str | None = None, dl_id: str | None = None):
+ if not config.PUBLIC_MODE:
+ await sio.emit(event, payload)
+ return
+ visit_id = (public_download_owner_by_id.get(dl_id) if dl_id else None) or (public_download_owner.get(url) if url else None)
+ if not visit_id:
+ return
+ for sid in list(public_visit_sids.get(visit_id, set())):
+ await sio.emit(event, payload, to=sid)
+
+
+def _public_visit_from_environ(environ: dict) -> str:
+ qs = parse_qs(environ.get('QUERY_STRING', ''))
+ return (qs.get('visit_id', [''])[0] or '').strip()
+
+
class Notifier(DownloadQueueNotifier):
async def added(self, dl):
log.info(f"Notifier: Download added - {dl.title}")
- await sio.emit('added', serializer.encode(dl))
+ visit_id = public_download_owner.get(dl.url)
+ if visit_id:
+ public_download_owner_by_id[dl.id] = visit_id
+ await _emit_download_event('added', serializer.encode(dl), url=dl.url, dl_id=dl.id)
async def updated(self, dl):
log.debug(f"Notifier: Download updated - {dl.title}")
- await sio.emit('updated', serializer.encode(dl))
+ await _emit_download_event('updated', serializer.encode(dl), url=dl.url, dl_id=dl.id)
async def completed(self, dl):
log.info(f"Notifier: Download completed - {dl.title}")
- await sio.emit('completed', serializer.encode(dl))
+ await _emit_download_event('completed', serializer.encode(dl), url=dl.url, dl_id=dl.id)
+ _forget_public_download(dl.url, dl_id=dl.id)
async def canceled(self, id):
log.info(f"Notifier: Download canceled - {id}")
- await sio.emit('canceled', serializer.encode(id))
+ await _emit_download_event('canceled', serializer.encode(id), url=id, dl_id=id)
+ _forget_public_download(id, dl_id=id)
async def cleared(self, id):
log.info(f"Notifier: Download cleared - {id}")
- await sio.emit('cleared', serializer.encode(id))
+ await _emit_download_event('cleared', serializer.encode(id), url=id, dl_id=id)
dqueue = DownloadQueue(config, Notifier())
app.on_startup.append(lambda app: dqueue.initialize())
@@ -723,6 +778,11 @@ async def add(request):
bool(o.get('folder')),
o['auto_start'],
)
+ if config.PUBLIC_MODE:
+ visit_id = request.headers.get('X-Visit-Id', '').strip()
+ if not visit_id:
+ raise web.HTTPBadRequest(reason='missing X-Visit-Id in public mode')
+ _register_public_download(visit_id, o['url'])
status = await dqueue.add(
o['url'],
o['download_type'],
@@ -760,6 +820,8 @@ async def cancel_add(request):
@routes.post(config.URL_PREFIX + 'subscribe')
async def subscribe(request):
+ if config.PUBLIC_MODE:
+ raise web.HTTPForbidden(reason='subscriptions are disabled in public mode')
post = await _read_json_request(request)
try:
o = parse_download_options(post)
@@ -811,11 +873,15 @@ async def subscribe(request):
@routes.get(config.URL_PREFIX + 'subscriptions')
async def subscriptions_list(request):
+ if config.PUBLIC_MODE:
+ return web.Response(text=serializer.encode([]))
return web.Response(text=serializer.encode([s.to_public_dict() for s in submgr.list_all()]))
@routes.post(config.URL_PREFIX + 'subscriptions/update')
async def subscriptions_update(request):
+ if config.PUBLIC_MODE:
+ raise web.HTTPForbidden(reason='subscriptions are disabled in public mode')
post = await _read_json_request(request)
sub_id = post.get('id')
if not sub_id:
@@ -835,6 +901,8 @@ async def subscriptions_update(request):
@routes.post(config.URL_PREFIX + 'subscriptions/delete')
async def subscriptions_delete(request):
+ if config.PUBLIC_MODE:
+ raise web.HTTPForbidden(reason='subscriptions are disabled in public mode')
post = await _read_json_request(request)
ids = post.get('ids')
if not ids or not isinstance(ids, list):
@@ -845,6 +913,8 @@ async def subscriptions_delete(request):
@routes.post(config.URL_PREFIX + 'subscriptions/check')
async def subscriptions_check(request):
+ if config.PUBLIC_MODE:
+ raise web.HTTPForbidden(reason='subscriptions are disabled in public mode')
post = await _read_json_request(request)
ids = post.get('ids')
if ids is not None and not isinstance(ids, list):
@@ -955,14 +1025,46 @@ async def history(request):
@sio.event
async def connect(sid, environ):
log.info(f"Client connected: {sid}")
- await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
- await sio.emit('subscriptions_all', serializer.encode([s.to_public_dict() for s in submgr.list_all()]), to=sid)
+ if config.PUBLIC_MODE:
+ visit_id = _public_visit_from_environ(environ)
+ if visit_id:
+ public_sid_visit[sid] = visit_id
+ public_visit_sids.setdefault(visit_id, set()).add(sid)
+ if config.PUBLIC_MODE:
+ await sio.emit('all', serializer.encode(([], [])), to=sid)
+ else:
+ await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
+ if not config.PUBLIC_MODE:
+ await sio.emit('subscriptions_all', serializer.encode([s.to_public_dict() for s in submgr.list_all()]), to=sid)
await sio.emit('configuration', serializer.encode(config.frontend_safe()), to=sid)
if config.CUSTOM_DIRS:
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
if config.YTDL_OPTIONS_FILE:
await sio.emit('ytdl_options_changed', serializer.encode(get_options_update_time()), to=sid)
+@sio.event
+async def disconnect(sid):
+ if not config.PUBLIC_MODE:
+ return
+ visit_id = public_sid_visit.pop(sid, '')
+ if not visit_id:
+ return
+ sids = public_visit_sids.get(visit_id, set())
+ sids.discard(sid)
+ if sids:
+ return
+ public_visit_sids.pop(visit_id, None)
+ owned = list(public_visit_downloads.pop(visit_id, set()))
+ for url in owned:
+ public_download_owner.pop(url, None)
+ # remove any lingering id ownership for this visit
+ for dl_id, owner in list(public_download_owner_by_id.items()):
+ if owner == visit_id:
+ public_download_owner_by_id.pop(dl_id, None)
+ if owned:
+ await dqueue.cancel(owned)
+
+
def get_custom_dirs():
cache_ttl_seconds = 5
now = asyncio.get_running_loop().time()
@@ -1027,7 +1129,7 @@ def get_custom_dirs():
@routes.get(config.URL_PREFIX)
async def index(request):
- response = web.FileResponse(os.path.join(config.BASE_DIR, 'ui/dist/metube/browser/index.html'))
+ response = web.FileResponse(os.path.join(config.BASE_DIR, 'ui/dist/mytube/browser/index.html'))
if 'metube_theme' not in request.cookies:
response.set_cookie('metube_theme', config.DEFAULT_THEME)
return response
@@ -1060,11 +1162,11 @@ if config.URL_PREFIX != '/':
routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
routes.static(config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
-routes.static(config.URL_PREFIX, os.path.join(config.BASE_DIR, 'ui/dist/metube/browser'))
+routes.static(config.URL_PREFIX, os.path.join(config.BASE_DIR, 'ui/dist/mytube/browser'))
try:
app.add_routes(routes)
except ValueError as e:
- if 'ui/dist/metube/browser' in str(e):
+ if 'ui/dist/mytube/browser' in str(e):
raise RuntimeError('Could not find the frontend UI static assets. Please run `node_modules/.bin/ng build` inside the ui folder') from e
raise e
diff --git a/app/ytdl.py b/app/ytdl.py
index d928c0f..07d7f0c 100644
--- a/app/ytdl.py
+++ b/app/ytdl.py
@@ -447,6 +447,22 @@ class Download:
for subtitle in requested_subtitles.values():
if isinstance(subtitle, dict) and subtitle.get('filepath'):
self.status_queue.put({'subtitle_file': subtitle['filepath']})
+ elif getattr(self.info, 'download_type', '') == 'thumbnail':
+ info_dict = d.get('info_dict', {}) or {}
+ image_exts = ('.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp')
+
+ def enqueue_if_image(path: str | None):
+ if isinstance(path, str) and path.lower().endswith(image_exts):
+ self.status_queue.put({'thumbnail_file': path})
+
+ # Common yt-dlp structures where thumbnail paths may appear.
+ enqueue_if_image(info_dict.get('thumbnail'))
+ for thumb in (info_dict.get('thumbnails') or []):
+ if isinstance(thumb, dict):
+ enqueue_if_image(thumb.get('filepath') or thumb.get('filename') or thumb.get('url'))
+ for item in (info_dict.get('requested_downloads') or []):
+ if isinstance(item, dict):
+ enqueue_if_image(item.get('filepath') or item.get('filename'))
# Capture all chapter files when SplitChapters finishes
elif d.get('postprocessor') == 'SplitChapters' and d.get('status') == 'finished':
@@ -565,10 +581,11 @@ class Download:
allowed_caption_exts = ('.txt',) if requested_subtitle_format == 'txt' else ('.vtt', '.srt', '.sbv', '.scc', '.ttml', '.dfxp')
if not rel_name.lower().endswith(allowed_caption_exts):
continue
+ if getattr(self.info, 'download_type', '') == 'thumbnail':
+ if not rel_name.lower().endswith(('.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp')):
+ continue
self.info.filename = rel_name
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
- if getattr(self.info, 'download_type', '') == 'thumbnail':
- self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
# Handle chapter files
log.debug(f"Update status for {self.info.title}: {status}")
@@ -613,7 +630,13 @@ class Download:
str(getattr(self.info, 'format', '')).lower() == 'txt'
):
self.info.filename = rel_path
- self.info.size = file_size
+
+ if 'thumbnail_file' in status:
+ thumbnail_file = status.get('thumbnail_file')
+ if thumbnail_file and os.path.isfile(thumbnail_file):
+ rel_path = os.path.relpath(thumbnail_file, self.download_dir)
+ self.info.filename = rel_path
+ self.info.size = os.path.getsize(thumbnail_file)
continue
self.info.status = status['status']
@@ -643,14 +666,25 @@ class PersistentQueue:
self.dict[k] = Download(None, None, None, None, getattr(v, 'quality', 'best'), getattr(v, 'format', 'any'), {}, v)
def exists(self, key):
- return key in self.dict
+ return self._resolve_key(key) is not None
def get(self, key):
- return self.dict[key]
+ resolved = self._resolve_key(key)
+ if resolved is None:
+ raise KeyError(key)
+ return self.dict[resolved]
def items(self):
return self.dict.items()
+ def _resolve_key(self, key):
+ if key in self.dict:
+ return key
+ for existing_key, download in self.dict.items():
+ if getattr(download.info, "url", None) == key:
+ return existing_key
+ return None
+
def saved_items(self):
items = [
(item["key"], _download_info_from_record(item["info"]))
@@ -716,7 +750,7 @@ class PersistentQueue:
return items
def put(self, value):
- key = value.info.url
+ key = getattr(value.info, "key", value.info.url)
old = self.dict.get(key)
self.dict[key] = value
try:
@@ -729,13 +763,14 @@ class PersistentQueue:
raise
def delete(self, key):
- if key in self.dict:
- old = self.dict[key]
- del self.dict[key]
+ resolved = self._resolve_key(key)
+ if resolved is not None:
+ old = self.dict[resolved]
+ del self.dict[resolved]
try:
self._save_dict()
except Exception:
- self.dict[key] = old
+ self.dict[resolved] = old
raise
def next(self):
@@ -758,6 +793,25 @@ class DownloadQueue:
self._add_generation = 0
self._canceled_urls = set() # URLs canceled during current playlist add
+ @staticmethod
+ def _download_key(dl: DownloadInfo) -> str:
+ return "|".join([
+ dl.url,
+ dl.download_type,
+ dl.codec,
+ dl.format,
+ dl.quality,
+ dl.folder or "",
+ dl.custom_name_prefix or "",
+ str(dl.playlist_item_limit or 0),
+ "1" if dl.split_by_chapters else "0",
+ dl.chapter_template or "",
+ dl.subtitle_language or "",
+ dl.subtitle_mode or "",
+ str(dl.clip_start if dl.clip_start is not None else ""),
+ str(dl.clip_end if dl.clip_end is not None else ""),
+ ])
+
def cancel_add(self):
self._add_generation += 1
log.info('Playlist add operation canceled by user')
@@ -795,10 +849,11 @@ class DownloadQueue:
pass
download.info.status = 'error'
download.close()
- if self.queue.exists(download.info.url):
- self.queue.delete(download.info.url)
+ key = getattr(download.info, 'key', download.info.url)
+ if self.queue.exists(key):
+ self.queue.delete(key)
if download.canceled:
- asyncio.create_task(self.notifier.canceled(download.info.url))
+ asyncio.create_task(self.notifier.canceled(key))
else:
self.done.put(download)
asyncio.create_task(self.notifier.completed(download.info))
@@ -808,14 +863,14 @@ class DownloadQueue:
log.error(f'CLEAR_COMPLETED_AFTER is set to an invalid value "{self.config.CLEAR_COMPLETED_AFTER}", expected an integer number of seconds')
clear_after = 0
if clear_after > 0:
- task = asyncio.create_task(self.__auto_clear_after_delay(download.info.url, clear_after))
+ task = asyncio.create_task(self.__auto_clear_after_delay(key, clear_after))
task.add_done_callback(lambda t: log.error(f'Auto-clear task failed: {t.exception()}') if not t.cancelled() and t.exception() else None)
- async def __auto_clear_after_delay(self, url, delay_seconds):
+ async def __auto_clear_after_delay(self, key, delay_seconds):
await asyncio.sleep(delay_seconds)
- if self.done.exists(url):
- log.debug(f'Auto-clearing completed download: {url}')
- await self.clear([url])
+ if self.done.exists(key):
+ log.debug(f'Auto-clearing completed download: {key}')
+ await self.clear([key])
def _build_ytdl_options(self, ytdl_options_presets=None, ytdl_options_overrides=None):
"""Merge global options, presets (in order), and per-download overrides."""
@@ -1014,29 +1069,30 @@ class DownloadQueue:
if key in self._canceled_urls:
log.info(f'Skipping canceled URL: {entry.get("title") or key}')
return {'status': 'ok'}
- if not self.queue.exists(key):
- dl = DownloadInfo(
- id=entry['id'],
- title=entry.get('title') or entry['id'],
- url=key,
- quality=quality,
- download_type=download_type,
- codec=codec,
- format=format,
- folder=folder,
- custom_name_prefix=custom_name_prefix,
- error=error,
- entry=entry,
- playlist_item_limit=playlist_item_limit,
- split_by_chapters=split_by_chapters,
- chapter_template=chapter_template,
- subtitle_language=subtitle_language,
- subtitle_mode=subtitle_mode,
- ytdl_options_presets=ytdl_options_presets,
- ytdl_options_overrides=ytdl_options_overrides,
- clip_start=clip_start,
- clip_end=clip_end,
- )
+ dl = DownloadInfo(
+ id=entry['id'],
+ title=entry.get('title') or entry['id'],
+ url=key,
+ quality=quality,
+ download_type=download_type,
+ codec=codec,
+ format=format,
+ folder=folder,
+ custom_name_prefix=custom_name_prefix,
+ error=error,
+ entry=entry,
+ playlist_item_limit=playlist_item_limit,
+ split_by_chapters=split_by_chapters,
+ chapter_template=chapter_template,
+ subtitle_language=subtitle_language,
+ subtitle_mode=subtitle_mode,
+ ytdl_options_presets=ytdl_options_presets,
+ ytdl_options_overrides=ytdl_options_overrides,
+ clip_start=clip_start,
+ clip_end=clip_end,
+ )
+ dl.key = self._download_key(dl)
+ if not self.queue.exists(dl.key):
await self.__add_download(dl, auto_start)
return {'status': 'ok'}
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index 5f07bc5..0010cb5 100644
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -1,5 +1,21 @@
#!/bin/sh
+load_env_file() {
+ env_file="$1"
+ if [ -f "$env_file" ]; then
+ echo "Loading environment from ${env_file}"
+ # shellcheck disable=SC1090
+ set -a
+ . "$env_file"
+ set +a
+ fi
+}
+
+load_env_file "/app/.env"
+if [ -n "${ENV_FILE}" ]; then
+ load_env_file "${ENV_FILE}"
+fi
+
PUID="${UID:-$PUID}"
PGID="${GID:-$PGID}"
diff --git a/ui/angular.json b/ui/angular.json
index 11245d1..6ce4338 100644
--- a/ui/angular.json
+++ b/ui/angular.json
@@ -3,7 +3,7 @@
"version": 1,
"newProjectRoot": "projects",
"projects": {
- "metube": {
+ "mytube": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
@@ -18,7 +18,7 @@
"builder": "@angular/build:application",
"options": {
"outputPath": {
- "base": "dist/metube"
+ "base": "dist/mytube"
},
"index": "src/index.html",
"tsConfig": "tsconfig.app.json",
@@ -72,10 +72,10 @@
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
- "buildTarget": "metube:build:production"
+ "buildTarget": "mytube:build:production"
},
"development": {
- "buildTarget": "metube:build:development",
+ "buildTarget": "mytube:build:development",
"proxyConfig": "proxy.conf.json"
}
},
diff --git a/ui/package.json b/ui/package.json
index 6a48bde..7496fe5 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -1,5 +1,5 @@
{
- "name": "metube",
+ "name": "mytube",
"version": "0.0.0",
"scripts": {
"ng": "ng",
diff --git a/ui/src/app/app.html b/ui/src/app/app.html
index b2ae0db..5519620 100644
--- a/ui/src/app/app.html
+++ b/ui/src/app/app.html
@@ -1,8 +1,8 @@