Route public canceled events by composite download key
This commit is contained in:
parent
5d96a581b9
commit
d78bc41124
23 changed files with 601 additions and 269 deletions
|
|
@ -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:
|
||||
|
|
|
|||
2
.github/DISCUSSION_TEMPLATE/general.yml
vendored
2
.github/DISCUSSION_TEMPLATE/general.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
10
.github/DISCUSSION_TEMPLATE/q-and-a.yml
vendored
10
.github/DISCUSSION_TEMPLATE/q-and-a.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
12
.github/ISSUE_TEMPLATE/question.yml
vendored
12
.github/ISSUE_TEMPLATE/question.yml
vendored
|
|
@ -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
47
.github/workflows/sync-upstream.yml
vendored
Normal 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 并解决冲突。
|
||||
2
.github/workflows/update-yt-dlp.yml
vendored
2
.github/workflows/update-yt-dlp.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
117
README.md
|
|
@ -1,30 +1,33 @@
|
|||
# MeTube
|
||||
## Fork note
|
||||
This project is forked from [alexta69/metube](https://github.com/alexta69/metube).
|
||||
|
||||

|
||||

|
||||
# MyTube
|
||||
|
||||
MeTube is a self-hosted web UI for `yt-dlp`, for downloading media from YouTube and [dozens of other sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
|
||||

|
||||

|
||||
|
||||
MyTube is a self-hosted web UI for `yt-dlp`, for downloading media from YouTube and [dozens of other sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
|
||||
|
||||
Key capabilities:
|
||||
* Download videos, audio, captions, and thumbnails from a browser UI.
|
||||
* Download playlists and channels, with configurable output and download options.
|
||||
* Subscribe to channels and playlists, periodically check for new items, and queue new uploads automatically.
|
||||
|
||||

|
||||

|
||||
|
||||
## 🐳 Run using Docker
|
||||
|
||||
```bash
|
||||
docker run -d -p 8081:8081 -v /path/to/downloads:/downloads ghcr.io/alexta69/metube
|
||||
docker run -d -p 8081:8081 -v /path/to/downloads:/downloads ghcr.io/tonyblur/mytube
|
||||
```
|
||||
|
||||
## 🐳 Run using docker-compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
metube:
|
||||
image: ghcr.io/alexta69/metube
|
||||
container_name: metube
|
||||
mytube:
|
||||
image: ghcr.io/tonyblur/mytube
|
||||
container_name: mytube
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:8081"
|
||||
|
|
@ -36,6 +39,10 @@ services:
|
|||
|
||||
Certain values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in docker-compose.
|
||||
|
||||
You can also load variables from files:
|
||||
* Docker CLI supports `--env-file /path/to/.env`.
|
||||
* The container entrypoint auto-loads `/app/.env` if present, and also loads a custom file when `ENV_FILE` points to it.
|
||||
|
||||
### ⬇️ Download Behavior
|
||||
|
||||
* __MAX_CONCURRENT_DOWNLOADS__: Maximum number of simultaneous downloads allowed. For example, if set to `5`, then at most five downloads will run concurrently, and any additional downloads will wait until one of the active downloads completes. Defaults to `3`.
|
||||
|
|
@ -54,11 +61,11 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||
* __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`.
|
||||
* __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`.
|
||||
* __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`.
|
||||
* __STATE_DIR__: Path to where MeTube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise.
|
||||
* __STATE_DIR__: Path to where mytube will store its persistent state files (`queue.json`, `pending.json`, `completed.json`, `subscriptions.json`). Defaults to `/downloads/.mytube` in the Docker image, and `.` otherwise.
|
||||
* __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
|
||||
* Set this to an SSD or RAM filesystem (e.g., `tmpfs`) for better performance.
|
||||
* __Note__: Using a RAM filesystem may prevent downloads from being resumed.
|
||||
* __CHOWN_DIRS__: If `false`, ownership of `DOWNLOAD_DIR`, `STATE_DIR`, and `TEMP_DIR` (and their contents) will not be set on container start. Ensure user under which MeTube runs has necessary access to these directories already. Defaults to `true`.
|
||||
* __CHOWN_DIRS__: If `false`, ownership of `DOWNLOAD_DIR`, `STATE_DIR`, and `TEMP_DIR` (and their contents) will not be set on container start. Ensure user under which mytube runs has necessary access to these directories already. Defaults to `true`.
|
||||
|
||||
### 📝 File Naming & yt-dlp
|
||||
|
||||
|
|
@ -77,26 +84,26 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||
* __HOST__: The host address the web server will bind to. Defaults to `0.0.0.0` (all interfaces).
|
||||
* __PORT__: The port number the web server will listen on. Defaults to `8081`.
|
||||
* __URL_PREFIX__: Base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`.
|
||||
* __PUBLIC_HOST_URL__: Base URL for the download links shown in the UI for completed files. By default, MeTube serves them under its own URL. If your download directory is accessible on another URL and you want the download links to be based there, use this variable to set it.
|
||||
* __PUBLIC_HOST_URL__: Base URL for the download links shown in the UI for completed files. By default, mytube serves them under its own URL. If your download directory is accessible on another URL and you want the download links to be based there, use this variable to set it.
|
||||
* __PUBLIC_HOST_AUDIO_URL__: Same as PUBLIC_HOST_URL but for audio downloads.
|
||||
* __HTTPS__: Use `https` instead of `http` (__CERTFILE__ and __KEYFILE__ required). Defaults to `false`.
|
||||
* __CERTFILE__: HTTPS certificate file path.
|
||||
* __KEYFILE__: HTTPS key file path.
|
||||
* __CORS_ALLOWED_ORIGINS__: Comma-separated list of origins permitted to make cross-origin requests to the MeTube API. When unset or empty, all cross-origin requests are denied. Set to `*` to allow all origins. This must be configured for [browser extensions](#-browser-extensions), [bookmarklets](#-bookmarklet), and any other browser-based tools that contact MeTube from a different origin. For browser extensions use `*` (see below); for bookmarklets you can list specific sites, e.g. `https://www.youtube.com,https://www.vimeo.com`.
|
||||
* __CORS_ALLOWED_ORIGINS__: Comma-separated list of origins permitted to make cross-origin requests to the mytube API. When unset or empty, all cross-origin requests are denied. Set to `*` to allow all origins. This must be configured for [browser extensions](#-browser-extensions), [bookmarklets](#-bookmarklet), and any other browser-based tools that contact mytube from a different origin. For browser extensions use `*` (see below); for bookmarklets you can list specific sites, e.g. `https://www.youtube.com,https://www.vimeo.com`.
|
||||
* __ROBOTS_TXT__: A path to a `robots.txt` file mounted in the container.
|
||||
|
||||
### 🏠 Basic Setup
|
||||
|
||||
* __PUID__: User under which MeTube will run. Defaults to `1000` (legacy `UID` also supported).
|
||||
* __PGID__: Group under which MeTube will run. Defaults to `1000` (legacy `GID` also supported).
|
||||
* __UMASK__: Umask value used by MeTube. Defaults to `022`.
|
||||
* __PUID__: User under which mytube will run. Defaults to `1000` (legacy `UID` also supported).
|
||||
* __PGID__: Group under which mytube will run. Defaults to `1000` (legacy `GID` also supported).
|
||||
* __UMASK__: Umask value used by mytube. Defaults to `022`.
|
||||
* __DEFAULT_THEME__: Default theme to use for the UI, can be set to `light`, `dark`, or `auto`. Defaults to `auto`.
|
||||
* __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or `NONE`. Defaults to `INFO`.
|
||||
* __ENABLE_ACCESSLOG__: Whether to enable access log. Defaults to `false`.
|
||||
|
||||
## 🎛️ Configuring yt-dlp options
|
||||
|
||||
MeTube lets you customize how [yt-dlp](https://github.com/yt-dlp/yt-dlp) behaves at three levels, from broadest to most specific:
|
||||
MyTube lets you customize how [yt-dlp](https://github.com/yt-dlp/yt-dlp) behaves at three levels, from broadest to most specific:
|
||||
|
||||
1. **Global options** — apply to every download by default.
|
||||
2. **Presets** — named bundles of options that users can pick per download from the UI.
|
||||
|
|
@ -108,7 +115,7 @@ In JSON presets and overrides, setting an option to **`null`** clears that optio
|
|||
|
||||
### Option format
|
||||
|
||||
yt-dlp options in MeTube are expressed as JSON objects. The keys are yt-dlp API option names, which roughly correspond to command-line flags with dashes replaced by underscores. For example, the command-line flag `--write-subs` becomes `"writesubtitles": true` in JSON.
|
||||
yt-dlp options in MyTube are expressed as JSON objects. The keys are yt-dlp API option names, which roughly correspond to command-line flags with dashes replaced by underscores. For example, the command-line flag `--write-subs` becomes `"writesubtitles": true` in JSON.
|
||||
|
||||
> **Tip:** Some command-line flags don't have a direct single-key equivalent — for instance, `--embed-thumbnail` and `--recode-video` must be expressed via `"postprocessors"`. A full list of available API options can be found [in the yt-dlp source](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L224), and [this conversion script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) can help translate command-line flags to their API equivalents.
|
||||
|
||||
|
|
@ -187,7 +194,7 @@ When multiple presets are selected for a download, they are applied in order. If
|
|||
|
||||
### Per-download overrides
|
||||
|
||||
For one-off tweaks, MeTube can expose a free-text JSON field in the UI ("Custom yt-dlp Options") where users type yt-dlp options that apply only to that single download. This is disabled by default:
|
||||
For one-off tweaks, MyTube can expose a free-text JSON field in the UI ("Custom yt-dlp Options") where users type yt-dlp options that apply only to that single download. This is disabled by default:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
|
|
@ -206,79 +213,79 @@ When a download starts, the final set of yt-dlp options is built in this order:
|
|||
2. Apply each selected **preset** in order (later presets overwrite earlier ones for conflicting keys).
|
||||
3. Apply any **per-download overrides** on top (overwrite everything else for conflicting keys).
|
||||
|
||||
MeTube always forces its own flat-extract behaviour during the initial metadata fetch (`extract_flat`, `noplaylist`, etc.); presets cannot override those keys for that phase.
|
||||
MyTube always forces its own flat-extract behaviour during the initial metadata fetch (`extract_flat`, `noplaylist`, etc.); presets cannot override those keys for that phase.
|
||||
|
||||
**Example:** Suppose your global options set `"writesubtitles": false`, but you select a preset that sets `"writesubtitles": true`. Subtitles will be written for that download because the preset overrides the global setting. If you additionally enter `{"writesubtitles": false}` in the per-download overrides field, that value wins and subtitles will not be written.
|
||||
|
||||
### Configuration cookbooks
|
||||
|
||||
The project's Wiki contains examples of useful configurations contributed by users of MeTube:
|
||||
* [YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)
|
||||
* [OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)
|
||||
The project's Wiki contains examples of useful configurations contributed by users of MyTube:
|
||||
* [YTDL_OPTIONS Cookbook](https://github.com/TonyBlur/mytube/wiki/YTDL_OPTIONS-Cookbook)
|
||||
* [OUTPUT_TEMPLATE Cookbook](https://github.com/TonyBlur/mytube/wiki/OUTPUT_TEMPLATE-Cookbook)
|
||||
|
||||
## 🍪 Using browser cookies
|
||||
|
||||
In case you need to use your browser's cookies with MeTube, for example to download restricted or private videos:
|
||||
In case you need to use your browser's cookies with MyTube, for example to download restricted or private videos:
|
||||
|
||||
* Install in your browser an extension to extract cookies:
|
||||
* [Firefox](https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/)
|
||||
* [Chrome](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
||||
* Extract the cookies you need with the extension and save/export them as `cookies.txt`.
|
||||
* In MeTube, open **Advanced Options** and use the **Upload Cookies** button to upload the file.
|
||||
* In MyTube, open **Advanced Options** and use the **Upload Cookies** button to upload the file.
|
||||
* After upload, the cookie indicator should show as active.
|
||||
* Use **Delete Cookies** in the same section to remove uploaded cookies.
|
||||
|
||||
## 🔌 Browser extensions
|
||||
|
||||
Browser extensions allow right-clicking videos and sending them directly to MeTube. If you're on an HTTPS page, your MeTube instance must be behind an HTTPS reverse proxy (see below) for extensions to work.
|
||||
Browser extensions allow right-clicking videos and sending them directly to MyTube. If you're on an HTTPS page, your MyTube instance must be behind an HTTPS reverse proxy (see below) for extensions to work.
|
||||
|
||||
Since browser extensions make requests from their own origin (`chrome-extension://...` or `moz-extension://...`), you must set `CORS_ALLOWED_ORIGINS=*` for them to work.
|
||||
|
||||
__Chrome:__ contributed by [Rpsl](https://github.com/rpsl). You can install it from [Google Chrome Webstore](https://chrome.google.com/webstore/detail/metube-downloader/fbmkmdnlhacefjljljlbhkodfmfkijdh) or use developer mode and install [from sources](https://github.com/Rpsl/metube-browser-extension).
|
||||
__Chrome:__ contributed by [Rpsl](https://github.com/rpsl). You can install it from [Google Chrome Webstore](https://chrome.google.com/webstore/detail/mytube-downloader/fbmkmdnlhacefjljljlbhkodfmfkijdh) or use developer mode and install [from sources](https://github.com/Rpsl/mytube-browser-extension).
|
||||
|
||||
__Firefox:__ contributed by [nanocortex](https://github.com/nanocortex). You can install it from [Firefox Addons](https://addons.mozilla.org/en-US/firefox/addon/metube-downloader) or get sources from [here](https://github.com/nanocortex/metube-firefox-addon).
|
||||
__Firefox:__ contributed by [nanocortex](https://github.com/nanocortex). You can install it from [Firefox Addons](https://addons.mozilla.org/en-US/firefox/addon/mytube-downloader) or get sources from [here](https://github.com/nanocortex/mytube-firefox-addon).
|
||||
|
||||
## 📱 iOS Shortcut
|
||||
|
||||
[rithask](https://github.com/rithask) created an iOS shortcut to send URLs to MeTube from Safari. Enter the MeTube instance address when prompted which will be saved for later use. You can run the shortcut from Safari’s share menu. The shortcut can be downloaded from [this iCloud link](https://www.icloud.com/shortcuts/66627a9f334c467baabdb2769763a1a6).
|
||||
[rithask](https://github.com/rithask) created an iOS shortcut to send URLs to MyTube from Safari. Enter the mytube instance address when prompted which will be saved for later use. You can run the shortcut from Safari’s share menu. The shortcut can be downloaded from [this iCloud link](https://www.icloud.com/shortcuts/66627a9f334c467baabdb2769763a1a6).
|
||||
|
||||
## 🔖 Bookmarklet
|
||||
|
||||
[kushfest](https://github.com/kushfest) has created a Chrome bookmarklet for sending the currently open webpage to MeTube. Please note that if you're on an HTTPS page, your MeTube instance must be configured with `HTTPS` as `true` in the environment, or be behind an HTTPS reverse proxy (see below) for the bookmarklet to work.
|
||||
[kushfest](https://github.com/kushfest) has created a Chrome bookmarklet for sending the currently open webpage to mytube. Please note that if you're on an HTTPS page, your MyTube instance must be configured with `HTTPS` as `true` in the environment, or be behind an HTTPS reverse proxy (see below) for the bookmarklet to work.
|
||||
|
||||
Since bookmarklets run in the context of the current page (e.g. youtube.com), the requests they make to MeTube are cross-origin. You must add the origins of sites where you use the bookmarklet to the __CORS_ALLOWED_ORIGINS__ environment variable, otherwise the browser will block the requests. For example, to use the bookmarklet on YouTube and Vimeo: `CORS_ALLOWED_ORIGINS=https://www.youtube.com,https://www.vimeo.com`.
|
||||
Since bookmarklets run in the context of the current page (e.g. youtube.com), the requests they make to mytube are cross-origin. You must add the origins of sites where you use the bookmarklet to the __CORS_ALLOWED_ORIGINS__ environment variable, otherwise the browser will block the requests. For example, to use the bookmarklet on YouTube and Vimeo: `CORS_ALLOWED_ORIGINS=https://www.youtube.com,https://www.vimeo.com`.
|
||||
|
||||
GitHub doesn't allow embedding JavaScript as a link, so the bookmarklet has to be created manually by copying the following code to a new bookmark you create on your bookmarks bar. Change the hostname in the URL below to point to your MeTube instance.
|
||||
GitHub doesn't allow embedding JavaScript as a link, so the bookmarklet has to be created manually by copying the following code to a new bookmark you create on your bookmarks bar. Change the hostname in the URL below to point to your MyTube instance.
|
||||
|
||||
```javascript
|
||||
javascript:!function(){xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.withCredentials=true;xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to metube!")}else{alert("Send to metube failed. Check the javascript console for clues.")}}}();
|
||||
javascript:!function(){xhr=new XMLHttpRequest();xhr.open("POST","https://mytube.domain.com/add");xhr.withCredentials=true;xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to mytube!")}else{alert("Send to mytube failed. Check the javascript console for clues.")}}}();
|
||||
```
|
||||
|
||||
[shoonya75](https://github.com/shoonya75) has contributed a Firefox version:
|
||||
|
||||
```javascript
|
||||
javascript:(function(){xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to metube!")}else{alert("Send to metube failed. Check the javascript console for clues.")}}})();
|
||||
javascript:(function(){xhr=new XMLHttpRequest();xhr.open("POST","https://mytube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to mytube!")}else{alert("Send to mytube failed. Check the javascript console for clues.")}}})();
|
||||
```
|
||||
|
||||
The above bookmarklets use `alert()` for notifications. This variant shows a toast instead (Chrome — for Firefox, replace the `!function(){...}()` wrapper with `(function(){...})()`):
|
||||
|
||||
```javascript
|
||||
javascript:!function(){function notify(msg) {var sc = document.scrollingElement.scrollTop; var text = document.createElement('span');text.innerHTML=msg;var ts = text.style;ts.all = 'revert';ts.color = '#000';ts.fontFamily = 'Verdana, sans-serif';ts.fontSize = '15px';ts.backgroundColor = 'white';ts.padding = '15px';ts.border = '1px solid gainsboro';ts.boxShadow = '3px 3px 10px';ts.zIndex = '100';document.body.appendChild(text);ts.position = 'absolute'; ts.top = 50 + sc + 'px'; ts.left = (window.innerWidth / 2)-(text.offsetWidth / 2) + 'px'; setTimeout(function () { text.style.visibility = "hidden"; }, 1500);}xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function() { if(xhr.status==200){notify("Sent to metube!")}else {notify("Send to metube failed. Check the javascript console for clues.")}}}();
|
||||
javascript:!function(){function notify(msg) {var sc = document.scrollingElement.scrollTop; var text = document.createElement('span');text.innerHTML=msg;var ts = text.style;ts.all = 'revert';ts.color = '#000';ts.fontFamily = 'Verdana, sans-serif';ts.fontSize = '15px';ts.backgroundColor = 'white';ts.padding = '15px';ts.border = '1px solid gainsboro';ts.boxShadow = '3px 3px 10px';ts.zIndex = '100';document.body.appendChild(text);ts.position = 'absolute'; ts.top = 50 + sc + 'px'; ts.left = (window.innerWidth / 2)-(text.offsetWidth / 2) + 'px'; setTimeout(function () { text.style.visibility = "hidden"; }, 1500);}xhr=new XMLHttpRequest();xhr.open("POST","https://mytube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function() { if(xhr.status==200){notify("Sent to mytube!")}else {notify("Send to mytube failed. Check the javascript console for clues.")}}}();
|
||||
```
|
||||
|
||||
## ⚡ Raycast extension
|
||||
|
||||
[dotvhs](https://github.com/dotvhs) has created an [extension for Raycast](https://www.raycast.com/dot/metube) for adding videos to MeTube directly from Raycast.
|
||||
[dotvhs](https://github.com/dotvhs) has created an [extension for Raycast](https://www.raycast.com/dot/mytube) for adding videos to MyTube directly from Raycast.
|
||||
|
||||
## 🔒 HTTPS support, and running behind a reverse proxy
|
||||
|
||||
It's possible to configure MeTube to listen in HTTPS mode. `docker-compose` example:
|
||||
It's possible to configure MyTube to listen in HTTPS mode. `docker-compose` example:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
metube:
|
||||
image: ghcr.io/alexta69/metube
|
||||
container_name: metube
|
||||
mytube:
|
||||
image: ghcr.io/tonyblur/mytube
|
||||
container_name: mytube
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:8081"
|
||||
|
|
@ -292,15 +299,15 @@ services:
|
|||
- KEYFILE=/ssl/key.pem
|
||||
```
|
||||
|
||||
MeTube can also run behind a reverse proxy for HTTPS termination or authentication. When serving under a subdirectory, set `URL_PREFIX` accordingly.
|
||||
MyTube can also run behind a reverse proxy for HTTPS termination or authentication. When serving under a subdirectory, set `URL_PREFIX` accordingly.
|
||||
|
||||
The [linuxserver/swag](https://docs.linuxserver.io/general/swag) image includes ready-made snippets for MeTube in [subfolder](https://github.com/linuxserver/reverse-proxy-confs/blob/master/metube.subfolder.conf.sample) and [subdomain](https://github.com/linuxserver/reverse-proxy-confs/blob/master/metube.subdomain.conf.sample) modes, plus Authelia for authentication.
|
||||
The [linuxserver/swag](https://docs.linuxserver.io/general/swag) image includes ready-made snippets for mytube in [subfolder](https://github.com/linuxserver/reverse-proxy-confs/blob/master/mytube.subfolder.conf.sample) and [subdomain](https://github.com/linuxserver/reverse-proxy-confs/blob/master/mytube.subdomain.conf.sample) modes, plus Authelia for authentication.
|
||||
|
||||
### 🌐 NGINX
|
||||
|
||||
```nginx
|
||||
location /metube/ {
|
||||
proxy_pass http://metube:8081;
|
||||
location /mytube/ {
|
||||
proxy_pass http://mytube:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
|
@ -316,13 +323,13 @@ Contributed by [PIE-yt](https://github.com/PIE-yt). Source [here](https://gist.g
|
|||
|
||||
```apache
|
||||
# For putting in your Apache sites site.conf
|
||||
# Serves MeTube under a /metube/ subdir (http://yourdomain.com/metube/)
|
||||
<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`).
|
||||
|
|
|
|||
138
app/main.py
138
app/main.py
|
|
@ -73,9 +73,10 @@ class Config:
|
|||
'MAX_CONCURRENT_DOWNLOADS': '3',
|
||||
'LOGLEVEL': 'INFO',
|
||||
'ENABLE_ACCESSLOG': 'false',
|
||||
'PUBLIC_MODE': 'false',
|
||||
}
|
||||
|
||||
_BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG', 'ALLOW_YTDL_OPTIONS_OVERRIDES')
|
||||
_BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG', 'ALLOW_YTDL_OPTIONS_OVERRIDES', 'PUBLIC_MODE')
|
||||
|
||||
def __init__(self):
|
||||
for k, v in self._DEFAULTS.items():
|
||||
|
|
@ -135,6 +136,7 @@ class Config:
|
|||
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
|
||||
'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL',
|
||||
'ALLOW_YTDL_OPTIONS_OVERRIDES',
|
||||
'PUBLIC_MODE',
|
||||
)
|
||||
|
||||
def frontend_safe(self) -> dict:
|
||||
|
|
@ -443,26 +445,93 @@ 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_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):
|
||||
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
|
||||
_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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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 +792,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 +834,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 +887,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 +915,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 +927,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 +1039,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 +1143,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 +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 + '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
|
||||
|
||||
|
|
|
|||
138
app/ytdl.py
138
app/ytdl.py
|
|
@ -447,6 +447,22 @@ class Download:
|
|||
for subtitle in requested_subtitles.values():
|
||||
if isinstance(subtitle, dict) and subtitle.get('filepath'):
|
||||
self.status_queue.put({'subtitle_file': subtitle['filepath']})
|
||||
elif getattr(self.info, 'download_type', '') == 'thumbnail':
|
||||
info_dict = d.get('info_dict', {}) or {}
|
||||
image_exts = ('.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp')
|
||||
|
||||
def enqueue_if_image(path: str | None):
|
||||
if isinstance(path, str) and path.lower().endswith(image_exts):
|
||||
self.status_queue.put({'thumbnail_file': path})
|
||||
|
||||
# Common yt-dlp structures where thumbnail paths may appear.
|
||||
enqueue_if_image(info_dict.get('thumbnail'))
|
||||
for thumb in (info_dict.get('thumbnails') or []):
|
||||
if isinstance(thumb, dict):
|
||||
enqueue_if_image(thumb.get('filepath') or thumb.get('filename') or thumb.get('url'))
|
||||
for item in (info_dict.get('requested_downloads') or []):
|
||||
if isinstance(item, dict):
|
||||
enqueue_if_image(item.get('filepath') or item.get('filename'))
|
||||
|
||||
# Capture all chapter files when SplitChapters finishes
|
||||
elif d.get('postprocessor') == 'SplitChapters' and d.get('status') == 'finished':
|
||||
|
|
@ -565,10 +581,11 @@ class Download:
|
|||
allowed_caption_exts = ('.txt',) if requested_subtitle_format == 'txt' else ('.vtt', '.srt', '.sbv', '.scc', '.ttml', '.dfxp')
|
||||
if not rel_name.lower().endswith(allowed_caption_exts):
|
||||
continue
|
||||
if getattr(self.info, 'download_type', '') == 'thumbnail':
|
||||
if not rel_name.lower().endswith(('.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp')):
|
||||
continue
|
||||
self.info.filename = rel_name
|
||||
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
|
||||
if getattr(self.info, 'download_type', '') == 'thumbnail':
|
||||
self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
|
||||
|
||||
# Handle chapter files
|
||||
log.debug(f"Update status for {self.info.title}: {status}")
|
||||
|
|
@ -613,7 +630,13 @@ class Download:
|
|||
str(getattr(self.info, 'format', '')).lower() == 'txt'
|
||||
):
|
||||
self.info.filename = rel_path
|
||||
self.info.size = file_size
|
||||
|
||||
if 'thumbnail_file' in status:
|
||||
thumbnail_file = status.get('thumbnail_file')
|
||||
if thumbnail_file and os.path.isfile(thumbnail_file):
|
||||
rel_path = os.path.relpath(thumbnail_file, self.download_dir)
|
||||
self.info.filename = rel_path
|
||||
self.info.size = os.path.getsize(thumbnail_file)
|
||||
continue
|
||||
|
||||
self.info.status = status['status']
|
||||
|
|
@ -643,14 +666,25 @@ class PersistentQueue:
|
|||
self.dict[k] = Download(None, None, None, None, getattr(v, 'quality', 'best'), getattr(v, 'format', 'any'), {}, v)
|
||||
|
||||
def exists(self, key):
|
||||
return key in self.dict
|
||||
return self._resolve_key(key) is not None
|
||||
|
||||
def get(self, key):
|
||||
return self.dict[key]
|
||||
resolved = self._resolve_key(key)
|
||||
if resolved is None:
|
||||
raise KeyError(key)
|
||||
return self.dict[resolved]
|
||||
|
||||
def items(self):
|
||||
return self.dict.items()
|
||||
|
||||
def _resolve_key(self, key):
|
||||
if key in self.dict:
|
||||
return key
|
||||
for existing_key, download in self.dict.items():
|
||||
if getattr(download.info, "url", None) == key:
|
||||
return existing_key
|
||||
return None
|
||||
|
||||
def saved_items(self):
|
||||
items = [
|
||||
(item["key"], _download_info_from_record(item["info"]))
|
||||
|
|
@ -716,7 +750,7 @@ class PersistentQueue:
|
|||
return items
|
||||
|
||||
def put(self, value):
|
||||
key = value.info.url
|
||||
key = getattr(value.info, "key", value.info.url)
|
||||
old = self.dict.get(key)
|
||||
self.dict[key] = value
|
||||
try:
|
||||
|
|
@ -729,13 +763,14 @@ class PersistentQueue:
|
|||
raise
|
||||
|
||||
def delete(self, key):
|
||||
if key in self.dict:
|
||||
old = self.dict[key]
|
||||
del self.dict[key]
|
||||
resolved = self._resolve_key(key)
|
||||
if resolved is not None:
|
||||
old = self.dict[resolved]
|
||||
del self.dict[resolved]
|
||||
try:
|
||||
self._save_dict()
|
||||
except Exception:
|
||||
self.dict[key] = old
|
||||
self.dict[resolved] = old
|
||||
raise
|
||||
|
||||
def next(self):
|
||||
|
|
@ -758,6 +793,25 @@ class DownloadQueue:
|
|||
self._add_generation = 0
|
||||
self._canceled_urls = set() # URLs canceled during current playlist add
|
||||
|
||||
@staticmethod
|
||||
def _download_key(dl: DownloadInfo) -> str:
|
||||
return "|".join([
|
||||
dl.url,
|
||||
dl.download_type,
|
||||
dl.codec,
|
||||
dl.format,
|
||||
dl.quality,
|
||||
dl.folder or "",
|
||||
dl.custom_name_prefix or "",
|
||||
str(dl.playlist_item_limit or 0),
|
||||
"1" if dl.split_by_chapters else "0",
|
||||
dl.chapter_template or "",
|
||||
dl.subtitle_language or "",
|
||||
dl.subtitle_mode or "",
|
||||
str(dl.clip_start if dl.clip_start is not None else ""),
|
||||
str(dl.clip_end if dl.clip_end is not None else ""),
|
||||
])
|
||||
|
||||
def cancel_add(self):
|
||||
self._add_generation += 1
|
||||
log.info('Playlist add operation canceled by user')
|
||||
|
|
@ -795,10 +849,11 @@ class DownloadQueue:
|
|||
pass
|
||||
download.info.status = 'error'
|
||||
download.close()
|
||||
if self.queue.exists(download.info.url):
|
||||
self.queue.delete(download.info.url)
|
||||
key = getattr(download.info, 'key', download.info.url)
|
||||
if self.queue.exists(key):
|
||||
self.queue.delete(key)
|
||||
if download.canceled:
|
||||
asyncio.create_task(self.notifier.canceled(download.info.url))
|
||||
asyncio.create_task(self.notifier.canceled(key))
|
||||
else:
|
||||
self.done.put(download)
|
||||
asyncio.create_task(self.notifier.completed(download.info))
|
||||
|
|
@ -808,14 +863,14 @@ class DownloadQueue:
|
|||
log.error(f'CLEAR_COMPLETED_AFTER is set to an invalid value "{self.config.CLEAR_COMPLETED_AFTER}", expected an integer number of seconds')
|
||||
clear_after = 0
|
||||
if clear_after > 0:
|
||||
task = asyncio.create_task(self.__auto_clear_after_delay(download.info.url, clear_after))
|
||||
task = asyncio.create_task(self.__auto_clear_after_delay(key, clear_after))
|
||||
task.add_done_callback(lambda t: log.error(f'Auto-clear task failed: {t.exception()}') if not t.cancelled() and t.exception() else None)
|
||||
|
||||
async def __auto_clear_after_delay(self, url, delay_seconds):
|
||||
async def __auto_clear_after_delay(self, key, delay_seconds):
|
||||
await asyncio.sleep(delay_seconds)
|
||||
if self.done.exists(url):
|
||||
log.debug(f'Auto-clearing completed download: {url}')
|
||||
await self.clear([url])
|
||||
if self.done.exists(key):
|
||||
log.debug(f'Auto-clearing completed download: {key}')
|
||||
await self.clear([key])
|
||||
|
||||
def _build_ytdl_options(self, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
"""Merge global options, presets (in order), and per-download overrides."""
|
||||
|
|
@ -1014,29 +1069,30 @@ class DownloadQueue:
|
|||
if key in self._canceled_urls:
|
||||
log.info(f'Skipping canceled URL: {entry.get("title") or key}')
|
||||
return {'status': 'ok'}
|
||||
if not self.queue.exists(key):
|
||||
dl = DownloadInfo(
|
||||
id=entry['id'],
|
||||
title=entry.get('title') or entry['id'],
|
||||
url=key,
|
||||
quality=quality,
|
||||
download_type=download_type,
|
||||
codec=codec,
|
||||
format=format,
|
||||
folder=folder,
|
||||
custom_name_prefix=custom_name_prefix,
|
||||
error=error,
|
||||
entry=entry,
|
||||
playlist_item_limit=playlist_item_limit,
|
||||
split_by_chapters=split_by_chapters,
|
||||
chapter_template=chapter_template,
|
||||
subtitle_language=subtitle_language,
|
||||
subtitle_mode=subtitle_mode,
|
||||
ytdl_options_presets=ytdl_options_presets,
|
||||
ytdl_options_overrides=ytdl_options_overrides,
|
||||
clip_start=clip_start,
|
||||
clip_end=clip_end,
|
||||
)
|
||||
dl = DownloadInfo(
|
||||
id=entry['id'],
|
||||
title=entry.get('title') or entry['id'],
|
||||
url=key,
|
||||
quality=quality,
|
||||
download_type=download_type,
|
||||
codec=codec,
|
||||
format=format,
|
||||
folder=folder,
|
||||
custom_name_prefix=custom_name_prefix,
|
||||
error=error,
|
||||
entry=entry,
|
||||
playlist_item_limit=playlist_item_limit,
|
||||
split_by_chapters=split_by_chapters,
|
||||
chapter_template=chapter_template,
|
||||
subtitle_language=subtitle_language,
|
||||
subtitle_mode=subtitle_mode,
|
||||
ytdl_options_presets=ytdl_options_presets,
|
||||
ytdl_options_overrides=ytdl_options_overrides,
|
||||
clip_start=clip_start,
|
||||
clip_end=clip_end,
|
||||
)
|
||||
dl.key = self._download_key(dl)
|
||||
if not self.queue.exists(dl.key):
|
||||
await self.__add_download(dl, auto_start)
|
||||
return {'status': 'ok'}
|
||||
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "metube",
|
||||
"name": "mytube",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "MeTube",
|
||||
"short_name": "MeTube",
|
||||
"name": "MyTube",
|
||||
"short_name": "MyTube",
|
||||
"theme_color": "#212529",
|
||||
"background_color": "#fafafa",
|
||||
"display": "standalone",
|
||||
|
|
|
|||
Loading…
Reference in a new issue