Fix downloads service tests with optional visit header

This commit is contained in:
TonyBlu 2026-05-01 11:54:30 +08:00
parent 5d96a581b9
commit 5fec503512
23 changed files with 587 additions and 269 deletions

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

47
.github/workflows/sync-upstream.yml vendored Normal file
View file

@ -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 并解决冲突。

View file

@ -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"

View file

@ -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

117
README.md
View file

@ -1,30 +1,33 @@
# MeTube
## Fork note
This project is forked from [alexta69/metube](https://github.com/alexta69/metube).
![Build Status](https://github.com/alexta69/metube/actions/workflows/main.yml/badge.svg)
![Docker Pulls](https://img.shields.io/docker/pulls/alexta69/metube.svg)
# 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).
![Build Status](https://github.com/TonyBlur/mytube/actions/workflows/main.yml/badge.svg)
![Docker Pulls](https://img.shields.io/docker/pulls/tonyblur/mytube.svg)
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.
![screenshot1](https://github.com/alexta69/metube/raw/master/screenshot.gif)
![screenshot1](https://github.com/TonyBlur/mytube/raw/master/screenshot.gif)
## 🐳 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 Safaris 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 Safaris 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/)
<Location /metube/>
# Serves mytube under a /mytube/ subdir (http://yourdomain.com/mytube/)
<Location /mytube/>
ProxyPass http://localhost:8081/ retry=0 timeout=30
ProxyPassReverse http://localhost:8081/
</Location>
<Location /metube/socket.io>
<Location /mytube/socket.io>
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`).

View file

@ -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

View file

@ -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}"'}

View file

@ -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}"

View file

@ -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"
}
},

View file

@ -1,5 +1,5 @@
{
"name": "metube",
"name": "mytube",
"version": "0.0.0",
"scripts": {
"ng": "ng",

View file

@ -1,8 +1,8 @@
<nav class="navbar navbar-expand-md navbar-dark">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="#">
<img src="assets/icons/android-chrome-192x192.png" alt="MeTube Logo" height="32" class="me-2">
MeTube
<img src="assets/icons/android-chrome-192x192.png" alt="MyTube Logo" height="32" class="me-2">
MyTube
</a>
<div class="download-metrics">
@if (activeDownloads > 0) {
@ -121,41 +121,29 @@
[disabled]="downloads.loading">
Download
</button>
@if (!isPublicMode()) {
<button class="btn btn-outline-secondary btn-lg px-3" type="button"
(click)="addSubscription()"
[disabled]="downloads.loading">
Subscribe
</button>
}
}
</ng-template>
<!-- Narrow viewports: full-width field, then Bootstrap btn-group (no faux input-group strip) -->
<div class="vstack gap-2 d-md-none">
<input type="text"
<div class="url-entry-wrap shadow-sm">
<textarea
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video, channel, or playlist URL"
class="form-control form-control-lg url-entry-textarea"
placeholder="Enter one or multiple links (separate with spaces, commas, or newlines)"
[(ngModel)]="addUrl"
[ngModelOptions]="{standalone: true}"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
<div class="btn-group w-100" role="group" aria-label="Download or subscribe">
[disabled]="addInProgress || subscribeInProgress || downloads.loading"></textarea>
<div class="url-entry-actions" aria-label="Download or subscribe">
<ng-container [ngTemplateOutlet]="urlBarActions" />
</div>
</div>
<!-- md and up: standard input-group so Bootstrap handles fused borders -->
<div class="input-group input-group-lg shadow-sm d-none d-md-flex">
<input type="text"
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video, channel, or playlist URL"
[(ngModel)]="addUrl"
[ngModelOptions]="{standalone: true}"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
<ng-container [ngTemplateOutlet]="urlBarActions" />
</div>
</div>
</div>
@ -893,6 +881,7 @@
</table>
</div>
@if (!isPublicMode()) {
<div class="metube-section-header">Subscriptions</div>
<div class="px-2 py-3 border-bottom">
@if (checkingAllSubscriptions) {
@ -1041,6 +1030,7 @@
</tbody>
</table>
</div>
}
</main><!-- /.container -->
<footer class="footer navbar-dark bg-dark py-3 mt-5">
@ -1053,7 +1043,7 @@
</div>
<div class="version-separator"></div>
<div class="version-item">
<span class="version-label">MeTube</span>
<span class="version-label">MyTube</span>
<span class="version-value">{{metubeVersion}}</span>
</div>
<div class="version-separator"></div>
@ -1066,7 +1056,7 @@
@if (ytDlpOptionsUpdateTime) {
<div class="version-separator"></div>
}
<a href="https://github.com/alexta69/metube" target="_blank" class="github-link">
<a href="https://github.com/TonyBlur/mytube" target="_blank" class="github-link">
<fa-icon [icon]="faGithub" />
<span>GitHub</span>
</a>

View file

@ -1,51 +1,154 @@
:host
--mt-radius-sm: 0.5rem
--mt-radius-md: 0.85rem
--mt-radius-lg: 1.1rem
--mt-shadow-sm: 0 2px 8px rgba(15, 23, 42, 0.06)
--mt-shadow-md: 0 8px 24px rgba(15, 23, 42, 0.08)
--mt-shadow-lg: 0 14px 40px rgba(15, 23, 42, 0.12)
--mt-surface: color-mix(in srgb, var(--bs-body-bg) 92%, #fff)
--mt-surface-soft: color-mix(in srgb, var(--bs-body-bg) 85%, var(--bs-tertiary-bg))
display: flex
flex-direction: column
min-height: 100vh
main
flex-grow: 1
.navbar
backdrop-filter: blur(8px)
background: color-mix(in srgb, var(--bs-dark) 92%, #000)
border-bottom: 1px solid rgba(255, 255, 255, 0.08)
.navbar-brand
font-weight: 700
letter-spacing: 0.02em
.add-url-box
max-width: 960px
margin: 4rem auto
max-width: 1024px
margin: 2.5rem auto
.metube-section-header
font-size: 1.8rem
font-weight: 300
position: relative
background: var(--bs-secondary-bg)
padding: 0.5rem 0
margin-top: 3.5rem
.url-entry-wrap
display: flex
gap: .75rem
align-items: stretch
.metube-section-header:before
content: ""
position: absolute
top: 0
bottom: 0
left: -9999px
right: 0
border-left: 9999px solid var(--bs-secondary-bg)
box-shadow: 9999px 0 0 var(--bs-secondary-bg)
.url-entry-textarea
min-height: 8.25rem
max-height: 12rem
resize: none
overflow-y: auto
button:hover
text-decoration: none
.url-entry-actions
display: flex
flex-direction: column
gap: .5rem
width: 11rem
th
border-top: 0
border-bottom-width: 3px !important
vertical-align: middle !important
.btn
flex: 1
white-space: nowrap
@media (max-width: 767.98px)
.url-entry-wrap
flex-direction: column
.url-entry-actions
width: 100%
.card
border: 1px solid color-mix(in srgb, var(--bs-border-color) 75%, transparent)
border-radius: var(--mt-radius-lg)
background: var(--mt-surface)
box-shadow: var(--mt-shadow-md)
.input-group-lg > .form-control,
.input-group-lg > .form-select,
.form-control-lg,
.form-select-lg,
.btn-lg
border-radius: var(--mt-radius-md)
.form-control,
.form-select,
.btn
transition: all .18s ease
.form-control:focus,
.form-select:focus
border-color: color-mix(in srgb, var(--bs-primary) 55%, #fff)
box-shadow: 0 0 0 .22rem color-mix(in srgb, var(--bs-primary) 20%, transparent)
.btn-primary
box-shadow: 0 6px 16px color-mix(in srgb, var(--bs-primary) 28%, transparent)
.btn-primary:hover
transform: translateY(-1px)
.metube-section-header
font-size: 1.2rem
font-weight: 650
letter-spacing: 0.01em
display: inline-flex
align-items: center
gap: .55rem
margin-top: 2rem
margin-bottom: .5rem
padding: .35rem .75rem
border-radius: var(--mt-radius-md)
color: var(--bs-body-color)
background: var(--mt-surface-soft)
border: 1px solid color-mix(in srgb, var(--bs-border-color) 72%, transparent)
.metube-section-header:before
content: ""
width: .45rem
height: .45rem
border-radius: 50%
background: var(--bs-primary)
.table
border-radius: var(--mt-radius-md)
overflow: hidden
box-shadow: var(--mt-shadow-sm)
th
border-top: 0
border-bottom-width: 2px !important
vertical-align: middle !important
white-space: nowrap
font-size: .82rem
text-transform: uppercase
letter-spacing: .04em
color: var(--bs-secondary-color)
td
vertical-align: middle
vertical-align: middle
tbody tr
transition: background-color .15s ease
tbody tr:hover
background: color-mix(in srgb, var(--bs-primary-bg-subtle) 35%, transparent)
.disabled
opacity: 0.5
pointer-events: none
opacity: 0.5
pointer-events: none
.form-switch
input
margin-top: 5px
.download-progressbar
width: 12rem
margin-left: auto
width: 12rem
margin-left: auto
.modal.fade.show
background-color: rgba(0, 0, 0, 0.5)
background-color: rgba(0, 0, 0, 0.45)
.modal-content
border: 0
border-radius: var(--mt-radius-lg)
box-shadow: var(--mt-shadow-lg)
.modal-header
border-bottom: 1px solid var(--bs-border-color)
@ -53,6 +156,7 @@ td
.modal-body
textarea.form-control
resize: vertical
min-height: 9rem
.add-url
display: inline-flex
@ -69,24 +173,17 @@ td
.add-cancel-btn
min-width: 3.25rem
:host
display: flex
flex-direction: column
min-height: 100vh
main
flex-grow: 1
.footer
width: 100%
padding: 10px 0
background: linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.1))
padding: 14px 0
border-top: 1px solid rgba(255,255,255,.08)
background: color-mix(in srgb, var(--bs-dark) 94%, #000)
.footer-content
display: flex
justify-content: center
align-items: center
gap: 20px
gap: 18px
color: #fff
font-size: 0.9rem
@ -96,23 +193,23 @@ main
gap: 8px
.version-label
font-size: 0.75rem
font-size: 0.72rem
text-transform: uppercase
letter-spacing: 0.5px
opacity: 0.7
letter-spacing: 0.08em
opacity: 0.72
.version-value
font-family: monospace
font-size: 0.85rem
padding: 2px 6px
background: rgba(255,255,255,0.1)
border-radius: 4px
font-family: ui-monospace, SFMono-Regular, Menlo, monospace
font-size: 0.84rem
padding: 2px 8px
background: rgba(255,255,255,0.08)
border-radius: var(--mt-radius-sm)
.version-separator
width: 1px
height: 16px
background: rgba(255,255,255,0.2)
margin: 0 4px
background: rgba(255,255,255,0.18)
margin: 0 2px
.github-link
display: flex
@ -121,18 +218,15 @@ main
color: #fff
text-decoration: none
font-size: 0.85rem
padding: 2px 8px
border-radius: 4px
padding: 3px 9px
border-radius: var(--mt-radius-sm)
transition: background-color 0.2s ease
&:hover
background: rgba(255,255,255,0.1)
background: rgba(255,255,255,0.12)
color: #fff
text-decoration: none
i
font-size: 1rem
.download-metrics
display: flex
align-items: center
@ -144,13 +238,7 @@ main
align-items: center
gap: 6px
font-size: 0.9rem
color: #adb5bd
fa-icon
font-size: 1rem
span
white-space: nowrap
color: #c4cad1
.cookie-btn
flex: 1 1 auto
@ -158,29 +246,8 @@ main
border-color: var(--bs-border-color)
color: var(--bs-emphasis-color)
&:hover
background-color: var(--bs-tertiary-bg)
border-color: var(--bs-secondary)
color: var(--bs-emphasis-color)
&.disabled
opacity: 0.65
pointer-events: none
.cookie-active-btn
flex: 1 1 auto
background-color: var(--bs-success-bg-subtle)
border-color: var(--bs-success-border-subtle)
color: var(--bs-success-text-emphasis)
&:hover
background-color: var(--bs-success-bg-subtle)
border-color: var(--bs-success)
color: var(--bs-success-text-emphasis)
&.disabled
opacity: 0.65
pointer-events: none
.settings-section-label
font-size: 0.8rem
@ -191,9 +258,6 @@ main
margin-top: 1.75rem
margin-bottom: 0.75rem
&:first-child
margin-top: 0
.action-group-label
font-size: 0.7rem
text-transform: uppercase
@ -205,6 +269,3 @@ main
font-size: 0.8rem
margin-top: 0.35rem
color: var(--bs-secondary-color)
&.active
color: var(--bs-success-text-emphasis)

View file

@ -1,7 +1,7 @@
import { AsyncPipe, DatePipe, KeyValuePipe, NgTemplateOutlet } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, viewChild, inject, OnDestroy, OnInit } from '@angular/core';
import { Observable, Subject, Subscription, from, map, distinctUntilChanged, finalize, mergeMap, takeUntil, tap } from 'rxjs';
import { Observable, Subject, Subscription, from, map, distinctUntilChanged, finalize, mergeMap, takeUntil, tap, concatMap, toArray } from 'rxjs';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
@ -375,6 +375,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
return this.downloads.configuration['ALLOW_YTDL_OPTIONS_OVERRIDES'] === true;
}
isPublicMode() {
return this.downloads.configuration['PUBLIC_MODE'] === true;
}
allowCustomDir(tag: string) {
if (this.downloads.configuration['CREATE_CUSTOM_DIRS']) {
return tag;
@ -1059,6 +1063,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
addDownload(overrides: Partial<AddDownloadPayload> = {}) {
const payload = this.buildAddPayload(overrides);
const urls = this.parseInputUrls(payload.url);
if (!urls.length) {
alert('Please enter at least one URL');
return;
}
// Validate chapter template if chapter splitting is enabled
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
@ -1073,16 +1082,27 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.addInProgress = true;
this.cancelRequested = false;
this.addRequestSub?.unsubscribe();
this.addRequestSub = this.downloads.add(payload).subscribe((status: Status) => {
if (status.status === 'error' && !this.cancelRequested) {
alert(`Error adding URL: ${status.msg}`);
} else if (status.status !== 'error') {
this.addRequestSub = from(urls).pipe(
concatMap(url => this.downloads.add({ ...payload, url })),
toArray(),
).subscribe((statuses: Status[]) => {
const failed = statuses.find(s => s.status === 'error');
if (failed && !this.cancelRequested) {
alert(`Error adding URL: ${failed.msg}`);
} else if (!failed) {
this.addUrl = '';
}
this.resetAddState();
});
}
private parseInputUrls(raw: string): string[] {
return (raw || '')
.split(/[\s,\n\r]+/)
.map(url => url.trim())
.filter(url => url.length > 0);
}
cancelAdding() {
this.cancelRequested = true;
this.downloads.cancelAdd().subscribe({

View file

@ -1,5 +1,5 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { of, Subject } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { MeTubeSocket } from './metube-socket.service';
@ -44,6 +44,11 @@ export class DownloadsService {
configuration: Record<string, unknown> = {};
customDirs: Record<string, string[]> = {};
private getDownloadKey(download: Download): string {
const maybeKey = (download as Download & { key?: string }).key;
return maybeKey && maybeKey.length > 0 ? maybeKey : download.url;
}
constructor() {
this.socket.fromEvent('all')
.pipe(takeUntilDestroyed())
@ -61,25 +66,28 @@ export class DownloadsService {
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
this.queue.set(data.url, data);
const key = this.getDownloadKey(data);
this.queue.set(key, data);
this.queueChanged.next();
});
this.socket.fromEvent('updated')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
const dl: Download | undefined = this.queue.get(data.url);
const key = this.getDownloadKey(data);
const dl: Download | undefined = this.queue.get(key);
data.checked = !!dl?.checked;
data.deleting = !!dl?.deleting;
this.queue.set(data.url, data);
this.queue.set(key, data);
this.updated.next();
});
this.socket.fromEvent('completed')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
this.queue.delete(data.url);
this.done.set(data.url, data);
const key = this.getDownloadKey(data);
this.queue.delete(key);
this.done.set(key, data);
this.queueChanged.next();
this.doneChanged.next();
});
@ -127,7 +135,7 @@ export class DownloadsService {
: (typeof error.error === 'string'
? error.error
: (error.error?.msg || error.message || 'Request failed'));
return of({ status: 'error', msg });
return of<Status>({ status: 'error', msg });
}
public add(payload: AddDownloadPayload) {
@ -152,8 +160,10 @@ export class DownloadsService {
const ce = payload.clipEnd?.trim();
if (cs) body['clip_start'] = cs;
if (ce) body['clip_end'] = ce;
return this.http.post<Status>('add', body).pipe(
catchError(this.handleHTTPError)
const visitId = typeof this.socket.getVisitId === 'function' ? this.socket.getVisitId() : '';
const headers = visitId ? new HttpHeaders({ 'X-Visit-Id': visitId }) : undefined;
return this.http.post<Status>('add', body, { headers }).pipe(
catchError((error: HttpErrorResponse) => this.handleHTTPError(error))
);
}

View file

@ -6,12 +6,19 @@ import { Socket } from 'ngx-socket-io';
{ providedIn: 'root' }
)
export class MeTubeSocket extends Socket {
private readonly visitId: string;
getVisitId(): string {
return this.visitId;
}
constructor() {
const appRef = inject(ApplicationRef);
const path =
document.location.pathname.replace(/share-target/, '') + 'socket.io';
super({ url: '', options: { path } }, appRef);
const visitId = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
super({ url: '', options: { path, query: { visit_id: visitId } } }, appRef);
this.visitId = visitId;
}
}

View file

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>MeTube</title>
<title>MyTube</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="apple-touch-icon" sizes="180x180" href="assets/icons/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icons/favicon-32x32.png">

View file

@ -1,6 +1,6 @@
{
"name": "MeTube",
"short_name": "MeTube",
"name": "MyTube",
"short_name": "MyTube",
"theme_color": "#212529",
"background_color": "#fafafa",
"display": "standalone",