Route public canceled events by composite download key

This commit is contained in:
TonyBlu 2026-05-01 13:19:37 +08:00
parent 5d96a581b9
commit d78bc41124
23 changed files with 601 additions and 269 deletions

View file

@ -13,9 +13,9 @@ body:
options: options:
- label: I have searched existing discussions and issues for similar configuration problems - label: I have searched existing discussions and issues for similar configuration problems
required: true 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 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 required: true
- type: markdown - type: markdown
@ -24,9 +24,9 @@ body:
## Configuration Resources ## Configuration Resources
Before asking for help, please check these 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 - **[Configuration Guide](https://github.com/TonyBlur/mytube#%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 - **[YTDL_OPTIONS Cookbook](https://github.com/TonyBlur/mytube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations
- **[OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples - **[OUTPUT_TEMPLATE Cookbook](https://github.com/TonyBlur/mytube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples
- type: textarea - type: textarea
id: configuration-goal id: configuration-goal
@ -53,7 +53,7 @@ body:
Environment Details: Environment Details:
- MeTube version: [e.g., latest, specific version] - 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] - Operating System: [e.g., Ubuntu 20.04, Windows 10, macOS 12]
Error Messages or Issues: Error Messages or Issues:

View file

@ -13,7 +13,7 @@ body:
options: options:
- label: I have searched existing discussions to ensure this topic hasn't been discussed before - label: I have searched existing discussions to ensure this topic hasn't been discussed before
required: true 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 required: true
- type: markdown - type: markdown

View file

@ -13,9 +13,9 @@ body:
options: options:
- label: I have searched existing discussions and issues to see if my question has been answered before - label: I have searched existing discussions and issues to see if my question has been answered before
required: true 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 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 required: true
- type: markdown - type: markdown
@ -24,9 +24,9 @@ body:
## Quick Resources ## Quick Resources
Before asking your question, please check these resources: Before asking your question, please check these resources:
- **[README](https://github.com/alexta69/metube#readme)** - Complete setup and configuration guide - **[README](https://github.com/TonyBlur/mytube#readme)** - Complete setup and configuration guide
- **[YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations - **[YTDL_OPTIONS Cookbook](https://github.com/TonyBlur/mytube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations
- **[OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples - **[OUTPUT_TEMPLATE Cookbook](https://github.com/TonyBlur/mytube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples
- type: textarea - type: textarea
id: question id: question

View file

@ -13,7 +13,7 @@ body:
options: options:
- label: I have searched existing issues and discussions to ensure this bug hasn't been reported before - label: I have searched existing issues and discussions to ensure this bug hasn't been reported before
required: true 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 required: true
- label: I have tested this issue with yt-dlp directly (not just through MeTube UI) as described in the README - label: I have tested this issue with yt-dlp directly (not just through MeTube UI) as described in the README
required: true required: true
@ -62,7 +62,7 @@ body:
placeholder: | placeholder: |
Environment: Environment:
- MeTube version: [e.g., latest, specific version] - 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] - Operating System: [e.g., Ubuntu 20.04, Windows 10, macOS 12]
Configuration: Configuration:

View file

@ -1,7 +1,7 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: MeTube Community Discussions - 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 about: Ask questions and discuss MeTube with the community
- name: yt-dlp Issues - name: yt-dlp Issues
url: https://github.com/yt-dlp/yt-dlp/issues url: https://github.com/yt-dlp/yt-dlp/issues

View file

@ -13,7 +13,7 @@ body:
options: options:
- label: I have searched existing issues and discussions to ensure this feature hasn't been requested before - label: I have searched existing issues and discussions to ensure this feature hasn't been requested before
required: true 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 required: true
- label: I understand that MeTube development relies on community contributions and the maintainer is not likely to implement this feature - label: I understand that MeTube development relies on community contributions and the maintainer is not likely to implement this feature
required: true required: true

View file

@ -13,11 +13,11 @@ body:
options: options:
- label: I have searched existing issues and discussions to see if my question has been answered before - label: I have searched existing issues and discussions to see if my question has been answered before
required: true 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 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 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 required: true
- type: markdown - type: markdown
@ -26,9 +26,9 @@ body:
## Quick Resources ## Quick Resources
Before asking your question, please check these resources: Before asking your question, please check these resources:
- **[README](https://github.com/alexta69/metube#readme)** - Complete setup and configuration guide - **[README](https://github.com/TonyBlur/mytube#readme)** - Complete setup and configuration guide
- **[YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations - **[YTDL_OPTIONS Cookbook](https://github.com/TonyBlur/mytube/wiki/YTDL_OPTIONS-Cookbook)** - Common yt-dlp configurations
- **[OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples - **[OUTPUT_TEMPLATE Cookbook](https://github.com/TonyBlur/mytube/wiki/OUTPUT_TEMPLATE-Cookbook)** - Filename template examples
- type: textarea - type: textarea
id: question 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 if [ -n "$LATEST_VERSION" ] && [ "$LATEST_VERSION" != "$CURRENT_VERSION" ]; then
echo "Updating yt-dlp from $CURRENT_VERSION to $LATEST_VERSION" echo "Updating yt-dlp from $CURRENT_VERSION to $LATEST_VERSION"
uv lock --upgrade-package "yt-dlp[default,curl-cffi]" 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 config --global user.name "AutoUpdater"
git add uv.lock git add uv.lock
git commit -m "upgrade yt-dlp from $CURRENT_VERSION to $LATEST_VERSION" 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 rm /tmp/bgutil-ytdlp-pot-provider-rs.zip
COPY app ./app 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 PUID=1000
ENV PGID=1000 ENV PGID=1000
@ -64,6 +64,8 @@ ENV DOWNLOAD_DIR=/downloads
ENV STATE_DIR=/downloads/.metube ENV STATE_DIR=/downloads/.metube
ENV TEMP_DIR=/downloads ENV TEMP_DIR=/downloads
ENV PORT=8081 ENV PORT=8081
ENV PUBLIC_MODE=false
ENV ENV_FILE=
VOLUME /downloads VOLUME /downloads
EXPOSE 8081 EXPOSE 8081
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS "http://localhost:${PORT}/" || exit 1 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) # MyTube
![Docker Pulls](https://img.shields.io/docker/pulls/alexta69/metube.svg)
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: Key capabilities:
* Download videos, audio, captions, and thumbnails from a browser UI. * Download videos, audio, captions, and thumbnails from a browser UI.
* Download playlists and channels, with configurable output and download options. * 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. * 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 ## 🐳 Run using Docker
```bash ```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 ## 🐳 Run using docker-compose
```yaml ```yaml
services: services:
metube: mytube:
image: ghcr.io/alexta69/metube image: ghcr.io/tonyblur/mytube
container_name: metube container_name: mytube
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8081:8081" - "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. 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 ### ⬇️ 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`. * __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`. * __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 `@`. * __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`. * __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. * __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. * 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. * __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 ### 📝 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). * __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`. * __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 `/`. * __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. * __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`. * __HTTPS__: Use `https` instead of `http` (__CERTFILE__ and __KEYFILE__ required). Defaults to `false`.
* __CERTFILE__: HTTPS certificate file path. * __CERTFILE__: HTTPS certificate file path.
* __KEYFILE__: HTTPS key 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. * __ROBOTS_TXT__: A path to a `robots.txt` file mounted in the container.
### 🏠 Basic Setup ### 🏠 Basic Setup
* __PUID__: User under which MeTube will run. Defaults to `1000` (legacy `UID` also supported). * __PUID__: User under which mytube will run. Defaults to `1000` (legacy `UID` also supported).
* __PGID__: Group under which MeTube will run. Defaults to `1000` (legacy `GID` also supported). * __PGID__: Group under which mytube will run. Defaults to `1000` (legacy `GID` also supported).
* __UMASK__: Umask value used by MeTube. Defaults to `022`. * __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`. * __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`. * __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`. * __ENABLE_ACCESSLOG__: Whether to enable access log. Defaults to `false`.
## 🎛️ Configuring yt-dlp options ## 🎛️ 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. 1. **Global options** — apply to every download by default.
2. **Presets** — named bundles of options that users can pick per download from the UI. 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 ### 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. > **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 ### 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 ```yaml
environment: 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). 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). 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. **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 ### Configuration cookbooks
The project's Wiki contains examples of useful configurations contributed by users of MeTube: The project's Wiki contains examples of useful configurations contributed by users of MyTube:
* [YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook) * [YTDL_OPTIONS Cookbook](https://github.com/TonyBlur/mytube/wiki/YTDL_OPTIONS-Cookbook)
* [OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook) * [OUTPUT_TEMPLATE Cookbook](https://github.com/TonyBlur/mytube/wiki/OUTPUT_TEMPLATE-Cookbook)
## 🍪 Using browser cookies ## 🍪 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: * Install in your browser an extension to extract cookies:
* [Firefox](https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/) * [Firefox](https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/)
* [Chrome](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) * [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`. * 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. * After upload, the cookie indicator should show as active.
* Use **Delete Cookies** in the same section to remove uploaded cookies. * Use **Delete Cookies** in the same section to remove uploaded cookies.
## 🔌 Browser extensions ## 🔌 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. 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 ## 📱 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 ## 🔖 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
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: [shoonya75](https://github.com/shoonya75) has contributed a Firefox version:
```javascript ```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(){...})()`): The above bookmarklets use `alert()` for notifications. This variant shows a toast instead (Chrome — for Firefox, replace the `!function(){...}()` wrapper with `(function(){...})()`):
```javascript ```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 ## ⚡ 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 ## 🔒 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 ```yaml
services: services:
metube: mytube:
image: ghcr.io/alexta69/metube image: ghcr.io/tonyblur/mytube
container_name: metube container_name: mytube
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8081:8081" - "8081:8081"
@ -292,15 +299,15 @@ services:
- KEYFILE=/ssl/key.pem - 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
```nginx ```nginx
location /metube/ { location /mytube/ {
proxy_pass http://metube:8081; proxy_pass http://mytube:8081;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "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 ```apache
# For putting in your Apache sites site.conf # 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/)
<Location /metube/> <Location /mytube/>
ProxyPass http://localhost:8081/ retry=0 timeout=30 ProxyPass http://localhost:8081/ retry=0 timeout=30
ProxyPassReverse http://localhost:8081/ ProxyPassReverse http://localhost:8081/
</Location> </Location>
<Location /metube/socket.io> <Location /mytube/socket.io>
RewriteEngine On RewriteEngine On
RewriteCond %{QUERY_STRING} transport=websocket [NC] RewriteCond %{QUERY_STRING} transport=websocket [NC]
RewriteRule /(.*) ws://localhost:8081/socket.io/$1 [P,L] 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 ```caddyfile
example.com { example.com {
route /metube/* { route /mytube/* {
uri strip_prefix metube uri strip_prefix mytube
reverse_proxy metube:8081 reverse_proxy mytube:8081
} }
} }
``` ```
## 🔄 Updating yt-dlp ## 🔄 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 ## 🔧 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 ```bash
docker exec -ti metube sh docker exec -ti mytube sh
cd /downloads cd /downloads
``` ```
## 💡 Submitting feature requests ## 💡 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 ## 🛠️ 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): A Docker image can be built locally (it will build the UI too):
```bash ```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`). 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', 'MAX_CONCURRENT_DOWNLOADS': '3',
'LOGLEVEL': 'INFO', 'LOGLEVEL': 'INFO',
'ENABLE_ACCESSLOG': 'false', '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): def __init__(self):
for k, v in self._DEFAULTS.items(): for k, v in self._DEFAULTS.items():
@ -135,6 +136,7 @@ class Config:
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT', 'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL', 'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL',
'ALLOW_YTDL_OPTIONS_OVERRIDES', 'ALLOW_YTDL_OPTIONS_OVERRIDES',
'PUBLIC_MODE',
) )
def frontend_safe(self) -> dict: def frontend_safe(self) -> dict:
@ -443,26 +445,93 @@ def _migrate_legacy_request(post: dict) -> dict:
return post return post
public_visit_downloads: dict[str, set[str]] = {}
public_download_owner: dict[str, str] = {}
public_download_owner_by_id: dict[str, str] = {}
public_download_owner_by_key: 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 _register_public_download_key(visit_id: str, dl_key: str | None):
if not visit_id or not dl_key:
return
public_download_owner_by_key[dl_key] = visit_id
def _forget_public_download(url: str | None = None, *, dl_id: str | None = None, dl_key: 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 dl_key:
visit_id = public_download_owner_by_key.pop(dl_key, 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, dl_key: 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_by_key.get(dl_key) if dl_key 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): class Notifier(DownloadQueueNotifier):
async def added(self, dl): async def added(self, dl):
log.info(f"Notifier: Download added - {dl.title}") 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
_register_public_download_key(visit_id, getattr(dl, 'key', None))
await _emit_download_event('added', serializer.encode(dl), url=dl.url, dl_id=dl.id, dl_key=getattr(dl, 'key', None))
async def updated(self, dl): async def updated(self, dl):
log.debug(f"Notifier: Download updated - {dl.title}") 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, dl_key=getattr(dl, 'key', None))
async def completed(self, dl): async def completed(self, dl):
log.info(f"Notifier: Download completed - {dl.title}") 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, dl_key=getattr(dl, 'key', None))
_forget_public_download(dl.url, dl_id=dl.id, dl_key=getattr(dl, 'key', None))
async def canceled(self, id): async def canceled(self, id):
log.info(f"Notifier: Download canceled - {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, dl_key=id)
_forget_public_download(id, dl_id=id, dl_key=id)
async def cleared(self, id): async def cleared(self, id):
log.info(f"Notifier: Download cleared - {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()) dqueue = DownloadQueue(config, Notifier())
app.on_startup.append(lambda app: dqueue.initialize()) app.on_startup.append(lambda app: dqueue.initialize())
@ -723,6 +792,11 @@ async def add(request):
bool(o.get('folder')), bool(o.get('folder')),
o['auto_start'], 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( status = await dqueue.add(
o['url'], o['url'],
o['download_type'], o['download_type'],
@ -760,6 +834,8 @@ async def cancel_add(request):
@routes.post(config.URL_PREFIX + 'subscribe') @routes.post(config.URL_PREFIX + 'subscribe')
async def subscribe(request): async def subscribe(request):
if config.PUBLIC_MODE:
raise web.HTTPForbidden(reason='subscriptions are disabled in public mode')
post = await _read_json_request(request) post = await _read_json_request(request)
try: try:
o = parse_download_options(post) o = parse_download_options(post)
@ -811,11 +887,15 @@ async def subscribe(request):
@routes.get(config.URL_PREFIX + 'subscriptions') @routes.get(config.URL_PREFIX + 'subscriptions')
async def subscriptions_list(request): 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()])) return web.Response(text=serializer.encode([s.to_public_dict() for s in submgr.list_all()]))
@routes.post(config.URL_PREFIX + 'subscriptions/update') @routes.post(config.URL_PREFIX + 'subscriptions/update')
async def subscriptions_update(request): 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) post = await _read_json_request(request)
sub_id = post.get('id') sub_id = post.get('id')
if not sub_id: if not sub_id:
@ -835,6 +915,8 @@ async def subscriptions_update(request):
@routes.post(config.URL_PREFIX + 'subscriptions/delete') @routes.post(config.URL_PREFIX + 'subscriptions/delete')
async def subscriptions_delete(request): 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) post = await _read_json_request(request)
ids = post.get('ids') ids = post.get('ids')
if not ids or not isinstance(ids, list): if not ids or not isinstance(ids, list):
@ -845,6 +927,8 @@ async def subscriptions_delete(request):
@routes.post(config.URL_PREFIX + 'subscriptions/check') @routes.post(config.URL_PREFIX + 'subscriptions/check')
async def subscriptions_check(request): 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) post = await _read_json_request(request)
ids = post.get('ids') ids = post.get('ids')
if ids is not None and not isinstance(ids, list): if ids is not None and not isinstance(ids, list):
@ -955,14 +1039,46 @@ async def history(request):
@sio.event @sio.event
async def connect(sid, environ): async def connect(sid, environ):
log.info(f"Client connected: {sid}") log.info(f"Client connected: {sid}")
await sio.emit('all', serializer.encode(dqueue.get()), to=sid) if config.PUBLIC_MODE:
await sio.emit('subscriptions_all', serializer.encode([s.to_public_dict() for s in submgr.list_all()]), to=sid) 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) await sio.emit('configuration', serializer.encode(config.frontend_safe()), to=sid)
if config.CUSTOM_DIRS: if config.CUSTOM_DIRS:
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid) await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
if config.YTDL_OPTIONS_FILE: if config.YTDL_OPTIONS_FILE:
await sio.emit('ytdl_options_changed', serializer.encode(get_options_update_time()), to=sid) 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(): def get_custom_dirs():
cache_ttl_seconds = 5 cache_ttl_seconds = 5
now = asyncio.get_running_loop().time() now = asyncio.get_running_loop().time()
@ -1027,7 +1143,7 @@ def get_custom_dirs():
@routes.get(config.URL_PREFIX) @routes.get(config.URL_PREFIX)
async def index(request): 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: if 'metube_theme' not in request.cookies:
response.set_cookie('metube_theme', config.DEFAULT_THEME) response.set_cookie('metube_theme', config.DEFAULT_THEME)
return response return response
@ -1060,11 +1176,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 + '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 + '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: try:
app.add_routes(routes) app.add_routes(routes)
except ValueError as e: 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 RuntimeError('Could not find the frontend UI static assets. Please run `node_modules/.bin/ng build` inside the ui folder') from e
raise e raise e

View file

@ -447,6 +447,22 @@ class Download:
for subtitle in requested_subtitles.values(): for subtitle in requested_subtitles.values():
if isinstance(subtitle, dict) and subtitle.get('filepath'): if isinstance(subtitle, dict) and subtitle.get('filepath'):
self.status_queue.put({'subtitle_file': subtitle['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 # Capture all chapter files when SplitChapters finishes
elif d.get('postprocessor') == 'SplitChapters' and d.get('status') == 'finished': 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') 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): if not rel_name.lower().endswith(allowed_caption_exts):
continue 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.filename = rel_name
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None 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 # Handle chapter files
log.debug(f"Update status for {self.info.title}: {status}") log.debug(f"Update status for {self.info.title}: {status}")
@ -613,7 +630,13 @@ class Download:
str(getattr(self.info, 'format', '')).lower() == 'txt' str(getattr(self.info, 'format', '')).lower() == 'txt'
): ):
self.info.filename = rel_path 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 continue
self.info.status = status['status'] 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) self.dict[k] = Download(None, None, None, None, getattr(v, 'quality', 'best'), getattr(v, 'format', 'any'), {}, v)
def exists(self, key): def exists(self, key):
return key in self.dict return self._resolve_key(key) is not None
def get(self, key): 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): def items(self):
return self.dict.items() 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): def saved_items(self):
items = [ items = [
(item["key"], _download_info_from_record(item["info"])) (item["key"], _download_info_from_record(item["info"]))
@ -716,7 +750,7 @@ class PersistentQueue:
return items return items
def put(self, value): def put(self, value):
key = value.info.url key = getattr(value.info, "key", value.info.url)
old = self.dict.get(key) old = self.dict.get(key)
self.dict[key] = value self.dict[key] = value
try: try:
@ -729,13 +763,14 @@ class PersistentQueue:
raise raise
def delete(self, key): def delete(self, key):
if key in self.dict: resolved = self._resolve_key(key)
old = self.dict[key] if resolved is not None:
del self.dict[key] old = self.dict[resolved]
del self.dict[resolved]
try: try:
self._save_dict() self._save_dict()
except Exception: except Exception:
self.dict[key] = old self.dict[resolved] = old
raise raise
def next(self): def next(self):
@ -758,6 +793,25 @@ class DownloadQueue:
self._add_generation = 0 self._add_generation = 0
self._canceled_urls = set() # URLs canceled during current playlist add 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): def cancel_add(self):
self._add_generation += 1 self._add_generation += 1
log.info('Playlist add operation canceled by user') log.info('Playlist add operation canceled by user')
@ -795,10 +849,11 @@ class DownloadQueue:
pass pass
download.info.status = 'error' download.info.status = 'error'
download.close() download.close()
if self.queue.exists(download.info.url): key = getattr(download.info, 'key', download.info.url)
self.queue.delete(download.info.url) if self.queue.exists(key):
self.queue.delete(key)
if download.canceled: if download.canceled:
asyncio.create_task(self.notifier.canceled(download.info.url)) asyncio.create_task(self.notifier.canceled(key))
else: else:
self.done.put(download) self.done.put(download)
asyncio.create_task(self.notifier.completed(download.info)) 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') 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 clear_after = 0
if 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) 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) await asyncio.sleep(delay_seconds)
if self.done.exists(url): if self.done.exists(key):
log.debug(f'Auto-clearing completed download: {url}') log.debug(f'Auto-clearing completed download: {key}')
await self.clear([url]) await self.clear([key])
def _build_ytdl_options(self, ytdl_options_presets=None, ytdl_options_overrides=None): def _build_ytdl_options(self, ytdl_options_presets=None, ytdl_options_overrides=None):
"""Merge global options, presets (in order), and per-download overrides.""" """Merge global options, presets (in order), and per-download overrides."""
@ -1014,29 +1069,30 @@ class DownloadQueue:
if key in self._canceled_urls: if key in self._canceled_urls:
log.info(f'Skipping canceled URL: {entry.get("title") or key}') log.info(f'Skipping canceled URL: {entry.get("title") or key}')
return {'status': 'ok'} return {'status': 'ok'}
if not self.queue.exists(key): dl = DownloadInfo(
dl = DownloadInfo( id=entry['id'],
id=entry['id'], title=entry.get('title') or entry['id'],
title=entry.get('title') or entry['id'], url=key,
url=key, quality=quality,
quality=quality, download_type=download_type,
download_type=download_type, codec=codec,
codec=codec, format=format,
format=format, folder=folder,
folder=folder, custom_name_prefix=custom_name_prefix,
custom_name_prefix=custom_name_prefix, error=error,
error=error, entry=entry,
entry=entry, playlist_item_limit=playlist_item_limit,
playlist_item_limit=playlist_item_limit, split_by_chapters=split_by_chapters,
split_by_chapters=split_by_chapters, chapter_template=chapter_template,
chapter_template=chapter_template, subtitle_language=subtitle_language,
subtitle_language=subtitle_language, subtitle_mode=subtitle_mode,
subtitle_mode=subtitle_mode, ytdl_options_presets=ytdl_options_presets,
ytdl_options_presets=ytdl_options_presets, ytdl_options_overrides=ytdl_options_overrides,
ytdl_options_overrides=ytdl_options_overrides, clip_start=clip_start,
clip_start=clip_start, clip_end=clip_end,
clip_end=clip_end, )
) dl.key = self._download_key(dl)
if not self.queue.exists(dl.key):
await self.__add_download(dl, auto_start) await self.__add_download(dl, auto_start)
return {'status': 'ok'} return {'status': 'ok'}
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'} return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}

View file

@ -1,5 +1,21 @@
#!/bin/sh #!/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}" PUID="${UID:-$PUID}"
PGID="${GID:-$PGID}" PGID="${GID:-$PGID}"

View file

@ -3,7 +3,7 @@
"version": 1, "version": 1,
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
"metube": { "mytube": {
"projectType": "application", "projectType": "application",
"schematics": { "schematics": {
"@schematics/angular:component": { "@schematics/angular:component": {
@ -18,7 +18,7 @@
"builder": "@angular/build:application", "builder": "@angular/build:application",
"options": { "options": {
"outputPath": { "outputPath": {
"base": "dist/metube" "base": "dist/mytube"
}, },
"index": "src/index.html", "index": "src/index.html",
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
@ -72,10 +72,10 @@
"builder": "@angular/build:dev-server", "builder": "@angular/build:dev-server",
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "metube:build:production" "buildTarget": "mytube:build:production"
}, },
"development": { "development": {
"buildTarget": "metube:build:development", "buildTarget": "mytube:build:development",
"proxyConfig": "proxy.conf.json" "proxyConfig": "proxy.conf.json"
} }
}, },

View file

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

View file

@ -1,8 +1,8 @@
<nav class="navbar navbar-expand-md navbar-dark"> <nav class="navbar navbar-expand-md navbar-dark">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="#"> <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"> <img src="assets/icons/android-chrome-192x192.png" alt="MyTube Logo" height="32" class="me-2">
MeTube MyTube
</a> </a>
<div class="download-metrics"> <div class="download-metrics">
@if (activeDownloads > 0) { @if (activeDownloads > 0) {
@ -121,41 +121,29 @@
[disabled]="downloads.loading"> [disabled]="downloads.loading">
Download Download
</button> </button>
@if (!isPublicMode()) {
<button class="btn btn-outline-secondary btn-lg px-3" type="button" <button class="btn btn-outline-secondary btn-lg px-3" type="button"
(click)="addSubscription()" (click)="addSubscription()"
[disabled]="downloads.loading"> [disabled]="downloads.loading">
Subscribe Subscribe
</button> </button>
}
} }
</ng-template> </ng-template>
<!-- Narrow viewports: full-width field, then Bootstrap btn-group (no faux input-group strip) --> <div class="url-entry-wrap shadow-sm">
<div class="vstack gap-2 d-md-none"> <textarea
<input type="text"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
class="form-control form-control-lg" class="form-control form-control-lg url-entry-textarea"
placeholder="Enter video, channel, or playlist URL" placeholder="Enter one or multiple links (separate with spaces, commas, or newlines)"
[(ngModel)]="addUrl" [(ngModel)]="addUrl"
[ngModelOptions]="{standalone: true}" [ngModelOptions]="{standalone: true}"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"> [disabled]="addInProgress || subscribeInProgress || downloads.loading"></textarea>
<div class="btn-group w-100" role="group" aria-label="Download or subscribe"> <div class="url-entry-actions" aria-label="Download or subscribe">
<ng-container [ngTemplateOutlet]="urlBarActions" /> <ng-container [ngTemplateOutlet]="urlBarActions" />
</div> </div>
</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>
</div> </div>
@ -893,6 +881,7 @@
</table> </table>
</div> </div>
@if (!isPublicMode()) {
<div class="metube-section-header">Subscriptions</div> <div class="metube-section-header">Subscriptions</div>
<div class="px-2 py-3 border-bottom"> <div class="px-2 py-3 border-bottom">
@if (checkingAllSubscriptions) { @if (checkingAllSubscriptions) {
@ -1041,6 +1030,7 @@
</tbody> </tbody>
</table> </table>
</div> </div>
}
</main><!-- /.container --> </main><!-- /.container -->
<footer class="footer navbar-dark bg-dark py-3 mt-5"> <footer class="footer navbar-dark bg-dark py-3 mt-5">
@ -1053,7 +1043,7 @@
</div> </div>
<div class="version-separator"></div> <div class="version-separator"></div>
<div class="version-item"> <div class="version-item">
<span class="version-label">MeTube</span> <span class="version-label">MyTube</span>
<span class="version-value">{{metubeVersion}}</span> <span class="version-value">{{metubeVersion}}</span>
</div> </div>
<div class="version-separator"></div> <div class="version-separator"></div>
@ -1066,7 +1056,7 @@
@if (ytDlpOptionsUpdateTime) { @if (ytDlpOptionsUpdateTime) {
<div class="version-separator"></div> <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" /> <fa-icon [icon]="faGithub" />
<span>GitHub</span> <span>GitHub</span>
</a> </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 .add-url-box
max-width: 960px max-width: 1024px
margin: 4rem auto margin: 2.5rem auto
.metube-section-header .url-entry-wrap
font-size: 1.8rem display: flex
font-weight: 300 gap: .75rem
position: relative align-items: stretch
background: var(--bs-secondary-bg)
padding: 0.5rem 0
margin-top: 3.5rem
.metube-section-header:before .url-entry-textarea
content: "" min-height: 8.25rem
position: absolute max-height: 12rem
top: 0 resize: none
bottom: 0 overflow-y: auto
left: -9999px
right: 0
border-left: 9999px solid var(--bs-secondary-bg)
box-shadow: 9999px 0 0 var(--bs-secondary-bg)
button:hover .url-entry-actions
text-decoration: none display: flex
flex-direction: column
gap: .5rem
width: 11rem
th .btn
border-top: 0 flex: 1
border-bottom-width: 3px !important
vertical-align: middle !important
white-space: nowrap 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 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 .disabled
opacity: 0.5 opacity: 0.5
pointer-events: none pointer-events: none
.form-switch .form-switch
input input
margin-top: 5px margin-top: 5px
.download-progressbar .download-progressbar
width: 12rem width: 12rem
margin-left: auto margin-left: auto
.modal.fade.show .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 .modal-header
border-bottom: 1px solid var(--bs-border-color) border-bottom: 1px solid var(--bs-border-color)
@ -53,6 +156,7 @@ td
.modal-body .modal-body
textarea.form-control textarea.form-control
resize: vertical resize: vertical
min-height: 9rem
.add-url .add-url
display: inline-flex display: inline-flex
@ -69,24 +173,17 @@ td
.add-cancel-btn .add-cancel-btn
min-width: 3.25rem min-width: 3.25rem
:host
display: flex
flex-direction: column
min-height: 100vh
main
flex-grow: 1
.footer .footer
width: 100% width: 100%
padding: 10px 0 padding: 14px 0
background: linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.1)) border-top: 1px solid rgba(255,255,255,.08)
background: color-mix(in srgb, var(--bs-dark) 94%, #000)
.footer-content .footer-content
display: flex display: flex
justify-content: center justify-content: center
align-items: center align-items: center
gap: 20px gap: 18px
color: #fff color: #fff
font-size: 0.9rem font-size: 0.9rem
@ -96,23 +193,23 @@ main
gap: 8px gap: 8px
.version-label .version-label
font-size: 0.75rem font-size: 0.72rem
text-transform: uppercase text-transform: uppercase
letter-spacing: 0.5px letter-spacing: 0.08em
opacity: 0.7 opacity: 0.72
.version-value .version-value
font-family: monospace font-family: ui-monospace, SFMono-Regular, Menlo, monospace
font-size: 0.85rem font-size: 0.84rem
padding: 2px 6px padding: 2px 8px
background: rgba(255,255,255,0.1) background: rgba(255,255,255,0.08)
border-radius: 4px border-radius: var(--mt-radius-sm)
.version-separator .version-separator
width: 1px width: 1px
height: 16px height: 16px
background: rgba(255,255,255,0.2) background: rgba(255,255,255,0.18)
margin: 0 4px margin: 0 2px
.github-link .github-link
display: flex display: flex
@ -121,18 +218,15 @@ main
color: #fff color: #fff
text-decoration: none text-decoration: none
font-size: 0.85rem font-size: 0.85rem
padding: 2px 8px padding: 3px 9px
border-radius: 4px border-radius: var(--mt-radius-sm)
transition: background-color 0.2s ease transition: background-color 0.2s ease
&:hover &:hover
background: rgba(255,255,255,0.1) background: rgba(255,255,255,0.12)
color: #fff color: #fff
text-decoration: none text-decoration: none
i
font-size: 1rem
.download-metrics .download-metrics
display: flex display: flex
align-items: center align-items: center
@ -144,13 +238,7 @@ main
align-items: center align-items: center
gap: 6px gap: 6px
font-size: 0.9rem font-size: 0.9rem
color: #adb5bd color: #c4cad1
fa-icon
font-size: 1rem
span
white-space: nowrap
.cookie-btn .cookie-btn
flex: 1 1 auto flex: 1 1 auto
@ -158,29 +246,8 @@ main
border-color: var(--bs-border-color) border-color: var(--bs-border-color)
color: var(--bs-emphasis-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 .cookie-active-btn
flex: 1 1 auto 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 .settings-section-label
font-size: 0.8rem font-size: 0.8rem
@ -191,9 +258,6 @@ main
margin-top: 1.75rem margin-top: 1.75rem
margin-bottom: 0.75rem margin-bottom: 0.75rem
&:first-child
margin-top: 0
.action-group-label .action-group-label
font-size: 0.7rem font-size: 0.7rem
text-transform: uppercase text-transform: uppercase
@ -205,6 +269,3 @@ main
font-size: 0.8rem font-size: 0.8rem
margin-top: 0.35rem margin-top: 0.35rem
color: var(--bs-secondary-color) 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 { AsyncPipe, DatePipe, KeyValuePipe, NgTemplateOutlet } from '@angular/common';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, viewChild, inject, OnDestroy, OnInit } from '@angular/core'; 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 { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; 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; return this.downloads.configuration['ALLOW_YTDL_OPTIONS_OVERRIDES'] === true;
} }
isPublicMode() {
return this.downloads.configuration['PUBLIC_MODE'] === true;
}
allowCustomDir(tag: string) { allowCustomDir(tag: string) {
if (this.downloads.configuration['CREATE_CUSTOM_DIRS']) { if (this.downloads.configuration['CREATE_CUSTOM_DIRS']) {
return tag; return tag;
@ -1059,6 +1063,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
addDownload(overrides: Partial<AddDownloadPayload> = {}) { addDownload(overrides: Partial<AddDownloadPayload> = {}) {
const payload = this.buildAddPayload(overrides); 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 // Validate chapter template if chapter splitting is enabled
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) { if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
@ -1073,16 +1082,27 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.addInProgress = true; this.addInProgress = true;
this.cancelRequested = false; this.cancelRequested = false;
this.addRequestSub?.unsubscribe(); this.addRequestSub?.unsubscribe();
this.addRequestSub = this.downloads.add(payload).subscribe((status: Status) => { this.addRequestSub = from(urls).pipe(
if (status.status === 'error' && !this.cancelRequested) { concatMap(url => this.downloads.add({ ...payload, url })),
alert(`Error adding URL: ${status.msg}`); toArray(),
} else if (status.status !== 'error') { ).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.addUrl = '';
} }
this.resetAddState(); this.resetAddState();
}); });
} }
private parseInputUrls(raw: string): string[] {
return (raw || '')
.split(/[\s,\n\r]+/)
.map(url => url.trim())
.filter(url => url.length > 0);
}
cancelAdding() { cancelAdding() {
this.cancelRequested = true; this.cancelRequested = true;
this.downloads.cancelAdd().subscribe({ this.downloads.cancelAdd().subscribe({

View file

@ -1,5 +1,5 @@
import { inject, Injectable } from '@angular/core'; 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 { of, Subject } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { MeTubeSocket } from './metube-socket.service'; import { MeTubeSocket } from './metube-socket.service';
@ -44,6 +44,11 @@ export class DownloadsService {
configuration: Record<string, unknown> = {}; configuration: Record<string, unknown> = {};
customDirs: Record<string, string[]> = {}; 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() { constructor() {
this.socket.fromEvent('all') this.socket.fromEvent('all')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
@ -61,25 +66,28 @@ export class DownloadsService {
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata); 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.queueChanged.next();
}); });
this.socket.fromEvent('updated') this.socket.fromEvent('updated')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata); 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.checked = !!dl?.checked;
data.deleting = !!dl?.deleting; data.deleting = !!dl?.deleting;
this.queue.set(data.url, data); this.queue.set(key, data);
this.updated.next(); this.updated.next();
}); });
this.socket.fromEvent('completed') this.socket.fromEvent('completed')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata); const data: Download = JSON.parse(strdata);
this.queue.delete(data.url); const key = this.getDownloadKey(data);
this.done.set(data.url, data); this.queue.delete(key);
this.done.set(key, data);
this.queueChanged.next(); this.queueChanged.next();
this.doneChanged.next(); this.doneChanged.next();
}); });
@ -127,7 +135,7 @@ export class DownloadsService {
: (typeof error.error === 'string' : (typeof error.error === 'string'
? error.error ? error.error
: (error.error?.msg || error.message || 'Request failed')); : (error.error?.msg || error.message || 'Request failed'));
return of({ status: 'error', msg }); return of<Status>({ status: 'error', msg });
} }
public add(payload: AddDownloadPayload) { public add(payload: AddDownloadPayload) {
@ -152,8 +160,10 @@ export class DownloadsService {
const ce = payload.clipEnd?.trim(); const ce = payload.clipEnd?.trim();
if (cs) body['clip_start'] = cs; if (cs) body['clip_start'] = cs;
if (ce) body['clip_end'] = ce; if (ce) body['clip_end'] = ce;
return this.http.post<Status>('add', body).pipe( const visitId = typeof this.socket.getVisitId === 'function' ? this.socket.getVisitId() : '';
catchError(this.handleHTTPError) 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' } { providedIn: 'root' }
) )
export class MeTubeSocket extends Socket { export class MeTubeSocket extends Socket {
private readonly visitId: string;
getVisitId(): string {
return this.visitId;
}
constructor() { constructor() {
const appRef = inject(ApplicationRef); const appRef = inject(ApplicationRef);
const path = const path =
document.location.pathname.replace(/share-target/, '') + 'socket.io'; 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"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>MeTube</title> <title>MyTube</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <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="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"> <link rel="icon" type="image/png" sizes="32x32" href="assets/icons/favicon-32x32.png">

View file

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