Compare commits

..

1 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 5a38ba3de0 Initial plan 2026-03-07 14:15:45 +00:00
78 changed files with 5731 additions and 20296 deletions
+1
View File
@@ -0,0 +1 @@
blank_discussions_enabled: false
@@ -0,0 +1,65 @@
name: ⚙️ Configuration Help
description: Get help with MeTube configuration and setup
title: "[Config]: "
labels: ["configuration", "help"]
assignees: []
body:
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
description: Please confirm you have completed these steps before asking for configuration help
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
required: true
- label: I have checked the [Wiki](https://github.com/alexta69/metube/wiki) for configuration examples
required: true
- type: markdown
attributes:
value: |
## 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
- type: textarea
id: configuration-goal
attributes:
label: What are you trying to configure?
description: Describe what you want to achieve with your MeTube configuration
placeholder: |
What specific behavior are you trying to achieve?
What's not working as expected?
What have you tried so far?
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Your configuration, environment details, errors, and any other helpful information
placeholder: |
Current Configuration:
```yaml
# Your docker-compose.yml or environment variables
```
Environment Details:
- MeTube version: [e.g., latest, specific version]
- Docker image: [e.g., ghcr.io/alexta69/metube:latest]
- Operating System: [e.g., Ubuntu 20.04, Windows 10, macOS 12]
Error Messages or Issues:
[Paste any error messages, logs, or unexpected behavior here]
Other relevant information:
[Screenshots, examples, etc.]
validations:
required: true
+31 -5
View File
@@ -1,13 +1,30 @@
name: 💬 General Discussion
description: Start a general discussion about MeTube
title: "[Discussion]: "
labels: ["discussion"]
assignees: []
body:
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
description: Please confirm you have completed these steps before starting a discussion
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
required: true
- type: markdown
attributes:
value: |
## Discussion Guidelines
This is for general discussions about MeTube. For specific topics, better homes exist:
- **Bug reports** → [open an issue](https://github.com/alexta69/metube/issues/new?template=bug_report.yml)
- **Feature requests** → [open an issue](https://github.com/alexta69/metube/issues/new?template=feature_request.yml) or post in [Ideas](https://github.com/alexta69/metube/discussions/categories/ideas)
- **Questions** → post in [Q&A](https://github.com/alexta69/metube/discussions/categories/q-a)
This is for general discussions about MeTube. For specific issues, please use:
- **Bug reports** → Use the Bug Report issue template
- **Feature requests** → Use the Feature Request issue template
- **Questions** → Use the Question issue template
- type: textarea
id: discussion-topic
@@ -17,3 +34,12 @@ body:
placeholder: Please provide a clear topic for discussion
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Any other information that might be helpful for the discussion
placeholder: Links, examples, or other relevant information
validations:
required: false
-31
View File
@@ -1,31 +0,0 @@
body:
- type: markdown
attributes:
value: |
## Read this first — MeTube's scope
MeTube's scope is deliberately narrow: **it downloads well and stops once the file is written.**
Ideas that improve the download itself are welcome — usually as community PRs, since the
maintainer is unlikely to implement requests ([details](https://github.com/alexta69/metube#-submitting-feature-requests)).
Post-download file management is out of scope regardless of implementation quality.
**Already decided — please don't re-request:**
- 🔒 Built-in authentication / password → use a reverse proxy ([#931](https://github.com/alexta69/metube/issues/931), [wiki](https://github.com/alexta69/metube/wiki/Reverse-proxy-configurations))
- 🔔 Notifications on download completion → available today via the `Exec` postprocessor ([recipe](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook))
- ✏️ Renaming, converting, tagging, or organizing files after download → out of scope ([#495](https://github.com/alexta69/metube/issues/495), [#1027](https://github.com/alexta69/metube/issues/1027))
- 🎚️ Pre-download format/quality/audio-track picker → tracked in [#1032](https://github.com/alexta69/metube/issues/1032)
- 📋 Pre-download playlist item browser → tracked in [#1030](https://github.com/alexta69/metube/issues/1030)
- 🎛️ Config switches to disable individual features → declined ([#976](https://github.com/alexta69/metube/issues/976))
- ⏰ Download scheduler → declined; add items with auto-start off and start them when you like ([#838](https://github.com/alexta69/metube/issues/838))
- type: textarea
id: idea
attributes:
label: Your Idea
description: Describe the idea and what problem it solves
placeholder: |
What would you like to see?
What problem does it solve?
Would you be willing to implement it as a PR?
validations:
required: true
-23
View File
@@ -1,23 +0,0 @@
body:
- type: markdown
attributes:
value: |
## Quick Resources
Your question may already be answered here:
- **[README](https://github.com/alexta69/metube#readme)** — complete setup and configuration guide
- **[Troubleshooting FAQ](https://github.com/alexta69/metube/wiki/Troubleshooting-FAQ)** — common problems and their fixes
- **[YTDL_OPTIONS Cookbook](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook)** — common yt-dlp configurations (notifications, metadata, audio extraction, ...)
- **[OUTPUT_TEMPLATE Cookbook](https://github.com/alexta69/metube/wiki/OUTPUT_TEMPLATE-Cookbook)** — filename template examples
- type: textarea
id: question
attributes:
label: Your Question
description: What would you like to know about MeTube?
placeholder: |
What are you trying to achieve?
What's your current setup (docker-compose / environment variables)?
What have you tried so far, and what happened (errors, logs)?
validations:
required: true
+60
View File
@@ -0,0 +1,60 @@
name: ❓ Q&A
description: Ask a question and get answers from the community
title: "[Q&A]: "
labels: ["q-and-a"]
assignees: []
body:
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
description: Please confirm you have completed these steps before asking your question
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
required: true
- label: I have checked the [Wiki](https://github.com/alexta69/metube/wiki) for configuration examples
required: true
- type: markdown
attributes:
value: |
## 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
- type: textarea
id: question
attributes:
label: Your Question
description: What would you like to know about MeTube?
placeholder: |
What are you trying to achieve?
What's your current setup?
What have you tried so far?
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Any other information that might be helpful (configuration, errors, screenshots, etc.)
placeholder: |
Configuration:
```yaml
# Your docker-compose.yml or environment variables
```
Error messages or logs:
[Paste any error messages or logs here]
Other relevant information:
[Screenshots, examples, etc.]
validations:
required: false
+13 -12
View File
@@ -9,20 +9,24 @@ body:
id: prerequisites
attributes:
label: Prerequisites
description: Please confirm before submitting
description: Please confirm you have completed these steps before submitting your bug report
options:
- label: I have searched existing issues and discussions to ensure this bug hasn't been reported before
required: true
- label: If the download itself fails, I have tested the same URL and options with yt-dlp directly (see the note below) — or this bug is not download-related
- label: I have read the [troubleshooting section](https://github.com/alexta69/metube#-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
- label: I have checked that this is not a yt-dlp issue (if it is, please report it to [yt-dlp repository](https://github.com/yt-dlp/yt-dlp/issues) instead)
required: true
- type: markdown
attributes:
value: |
## Important Notes
- **MeTube is only a UI for [yt-dlp](https://github.com/yt-dlp/yt-dlp)** issues with authentication, postprocessing, site support, or other download functionality should be reported to the [yt-dlp repository](https://github.com/yt-dlp/yt-dlp/issues)
- To test with yt-dlp directly: `docker exec -ti metube sh`, then `cd /downloads` and run `yt-dlp` with your URL and options
- If yt-dlp works directly but MeTube doesn't, then it's a MeTube issue — report it here
- **MeTube is only a UI for yt-dlp** - issues with authentication, postprocessing, permissions, or other yt-dlp functionality should be reported to the [yt-dlp repository](https://github.com/yt-dlp/yt-dlp/issues)
- Before reporting, please test with yt-dlp directly using: `docker exec -ti metube sh` then `cd /downloads` and run yt-dlp commands
- If yt-dlp works directly but MeTube doesn't, then it's a MeTube issue
- type: textarea
id: bug-description
@@ -43,13 +47,10 @@ body:
id: ytdl-test-results
attributes:
label: yt-dlp Direct Test Results
description: >-
If the download itself fails: paste the exact yt-dlp command you ran AND its output —
download-failure reports without this will be closed as needs-info.
If this is a UI or app bug that doesn't involve a failing download, just write "UI bug".
description: Results of testing the same URL/configuration directly with yt-dlp (required)
placeholder: |
Command used: yt-dlp [your-command-here]
Result: [paste the output here]
Result: [success/error and output]
validations:
required: true
@@ -63,12 +64,12 @@ body:
- MeTube version: [e.g., latest, specific version]
- Docker image: [e.g., ghcr.io/alexta69/metube:latest]
- Operating System: [e.g., Ubuntu 20.04, Windows 10, macOS 12]
Configuration:
```yaml
# Your docker-compose.yml or environment variables
```
Logs:
```bash
docker logs metube
+4 -7
View File
@@ -1,11 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: ❓ Questions & Support
url: https://github.com/alexta69/metube/discussions/categories/q-a
about: Ask usage and configuration questions in Discussions Q&A — issues are for bugs and feature requests
- name: 📖 Troubleshooting FAQ
url: https://github.com/alexta69/metube/wiki/Troubleshooting-FAQ
about: Common problems and their fixes
- name: ⬇️ yt-dlp Issues
- name: MeTube Community Discussions
url: https://github.com/alexta69/metube/discussions
about: Ask questions and discuss MeTube with the community
- name: yt-dlp Issues
url: https://github.com/yt-dlp/yt-dlp/issues
about: Report issues related to video downloading, authentication, or site support
+15 -22
View File
@@ -5,35 +5,28 @@ labels: ["enhancement"]
assignees: []
body:
- type: markdown
attributes:
value: |
## Read this first — MeTube's scope
MeTube's scope is deliberately narrow: **it downloads well and stops once the file is written.**
Features that improve the download itself are welcome — usually as community PRs, since the
maintainer is unlikely to implement requests ([details](https://github.com/alexta69/metube#-submitting-feature-requests)).
Post-download file management is out of scope regardless of implementation quality.
**Already decided — please don't re-request:**
- 🔒 Built-in authentication / password → use a reverse proxy ([#931](https://github.com/alexta69/metube/issues/931), [wiki](https://github.com/alexta69/metube/wiki/Reverse-proxy-configurations))
- 🔔 Notifications on download completion → available today via the `Exec` postprocessor ([recipe](https://github.com/alexta69/metube/wiki/YTDL_OPTIONS-Cookbook))
- ✏️ Renaming, converting, tagging, or organizing files after download → out of scope ([#495](https://github.com/alexta69/metube/issues/495), [#1027](https://github.com/alexta69/metube/issues/1027))
- 🎚️ Pre-download format/quality/audio-track picker → tracked in [#1032](https://github.com/alexta69/metube/issues/1032)
- 📋 Pre-download playlist item browser → tracked in [#1030](https://github.com/alexta69/metube/issues/1030)
- 🎛️ Config switches to disable individual features → declined ([#976](https://github.com/alexta69/metube/issues/976))
- ⏰ Download scheduler → declined; add items with auto-start off and start them when you like ([#838](https://github.com/alexta69/metube/issues/838))
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
description: Please confirm before submitting
description: Please confirm you have completed these steps before submitting your feature request
options:
- label: I have searched existing issues and checked the "Already decided" list above
- label: I have searched existing issues and discussions to ensure this feature hasn't been requested before
required: true
- label: I have considered if this feature belongs in yt-dlp instead (downloading, processing, site support) — if so, please request it in the [yt-dlp repository](https://github.com/yt-dlp/yt-dlp/issues)
- label: I have read the [feature request guidelines](https://github.com/alexta69/metube#-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
- label: I have considered if this feature should be implemented in yt-dlp instead of MeTube (if so, please report to [yt-dlp repository](https://github.com/yt-dlp/yt-dlp/issues))
required: true
- type: markdown
attributes:
value: |
## Important Notes
- **MeTube development relies on code contributions by the community** - the project is feature-complete for the maintainer's use cases
- **Consider if this belongs in yt-dlp** - if it's related to video downloading, processing, or site support, it might belong in the [yt-dlp repository](https://github.com/yt-dlp/yt-dlp/issues) instead
- **Some features may not be accepted** - in an effort to reduce bloat, some PRs may not be accepted
- type: textarea
id: feature-description
+62
View File
@@ -0,0 +1,62 @@
name: ❓ Question
description: Ask a question about MeTube usage, configuration, or general help
title: "[Question]: "
labels: ["question"]
assignees: []
body:
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
description: Please confirm you have completed these steps before asking your question
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
required: true
- label: I have checked the [Wiki](https://github.com/alexta69/metube/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
required: true
- type: markdown
attributes:
value: |
## 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
- type: textarea
id: question
attributes:
label: Your Question
description: What would you like to know about MeTube?
placeholder: |
What are you trying to achieve?
What's your current setup?
What have you tried so far?
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Any other information that might be helpful (configuration, errors, screenshots, etc.)
placeholder: |
Configuration:
```yaml
# Your docker-compose.yml or environment variables
```
Error messages or logs:
[Paste any error messages or logs here]
Other relevant information:
[Screenshots, examples, etc.]
validations:
required: false
+54 -84
View File
@@ -4,84 +4,40 @@ on:
push:
branches:
- 'master'
paths-ignore:
- '**.md'
jobs:
quality-checks:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: lts/*
- name: Enable pnpm
run: corepack enable
- name: Install frontend dependencies
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Run frontend lint
working-directory: ui
run: pnpm run lint
- name: Build frontend
working-directory: ui
run: pnpm run build
- name: Run frontend tests
working-directory: ui
run: pnpm exec ng test --watch=false
env:
CI: true
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install Python dependencies
run: uv sync --frozen --group dev
- name: Run backend smoke checks
run: python -m compileall app
- name: Run backend tests
run: uv run pytest app/tests/
- name: Run Trivy filesystem scan
uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: fs
scan-ref: .
format: table
severity: CRITICAL,HIGH
dockerhub-build-push:
needs: quality-checks
runs-on: ubuntu-latest
steps:
-
name: Get current date
id: date
run: echo "date=$(date +'%Y.%m.%d')" >> "$GITHUB_OUTPUT"
run: echo "::set-output name=date::$(date +'%Y.%m.%d')"
-
name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
-
name: Login to DockerHub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Login to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -117,27 +73,25 @@ jobs:
- name: Get current date
id: date
run: echo "date=$(date +'%Y.%m.%d')" >> $GITHUB_OUTPUT
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Get commits since last release
id: commits
env:
DATE: ${{ steps.date.outputs.date }}
run: |
# Fetch all tags
git fetch --tags
# Exclude today's tag: on a same-day rerun the notes must cover the
# whole day, not just the commits since the morning release.
LAST_TAG=$(git tag -l --sort=-version:refname | grep -E '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$' | grep -v "^${DATE}$" | head -n 1)
# Get the last tag (sorted by version, using date format YYYY.MM.DD)
LAST_TAG=$(git tag -l --sort=-version:refname | grep -E '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}$' | head -n 1)
if [ -z "$LAST_TAG" ]; then
# No previous release, skip commits for first release
COMMITS=""
echo "has_commits=false" >> $GITHUB_OUTPUT
else
# Get commits since last tag
COMMITS=$(git log ${LAST_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges)
if [ -z "$COMMITS" ]; then
echo "has_commits=false" >> $GITHUB_OUTPUT
@@ -145,13 +99,18 @@ jobs:
echo "has_commits=true" >> $GITHUB_OUTPUT
fi
fi
# Escape for use in YAML/multiline output
{
echo 'commits<<EOF'
echo "$COMMITS"
echo EOF
} >> $GITHUB_OUTPUT
# Also output for debugging
echo "Last tag: ${LAST_TAG:-none}"
echo "Commits since last release:"
echo "$COMMITS"
- name: Generate release body
id: release_body
env:
@@ -173,7 +132,7 @@ jobs:
echo '**GitHub Container Registry:**'
echo "- \`${GHCR_REPO}:latest\`"
echo "- \`${GHCR_REPO}:${DATE}\`"
if [ "$HAS_COMMITS" = "true" ] && [ -n "$COMMITS" ]; then
echo ''
echo '## Changes'
@@ -181,28 +140,39 @@ jobs:
echo "$COMMITS"
fi
} > release_body.txt
- name: Create or update GitHub Release (mark as latest)
{
echo 'body<<EOF'
cat release_body.txt
echo EOF
} >> $GITHUB_OUTPUT
- name: Delete existing release if present
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ steps.date.outputs.date }}
run: |
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
echo "Release $TAG_NAME exists; updating."
# Force-move the tag in place so it matches the rebuilt Docker
# image. Never delete+recreate the tag: that corrupts GitHub's
# release index (broke release 2026.07.05).
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/git/refs/tags/${TAG_NAME}" \
-f sha="$GITHUB_SHA" -F force=true
gh release edit "$TAG_NAME" \
--title "Release $TAG_NAME" \
--notes-file release_body.txt \
--latest
else
echo "Release $TAG_NAME does not exist; creating."
gh release create "$TAG_NAME" \
--target "$GITHUB_SHA" \
--title "Release $TAG_NAME" \
--notes-file release_body.txt \
--latest
# Check if release exists and delete it
if gh release view "$TAG_NAME" &>/dev/null; then
echo "Release $TAG_NAME already exists, deleting it..."
gh release delete "$TAG_NAME" --yes || true
fi
# Fetch tags to check remote
git fetch --tags
# Check if tag exists (locally or remotely) and delete it
if git rev-parse "$TAG_NAME" &>/dev/null 2>&1 || git ls-remote --tags origin "$TAG_NAME" | grep -q "$TAG_NAME"; then
echo "Tag $TAG_NAME already exists, deleting it..."
git tag -d "$TAG_NAME" 2>/dev/null || true
git push origin ":refs/tags/$TAG_NAME" || true
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.date.outputs.date }}
name: Release ${{ steps.date.outputs.date }}
body_path: release_body.txt
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-21
View File
@@ -1,21 +0,0 @@
name: readme-size
on:
push:
paths:
- 'README.md'
pull_request:
paths:
- 'README.md'
jobs:
check-size:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Check README stays under Docker Hub's 25k character limit
run: |
size=$(wc -c < README.md)
echo "README.md is ${size} bytes (limit 25000)"
test "$size" -lt 25000
+2 -2
View File
@@ -10,12 +10,12 @@ jobs:
steps:
-
name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
token: ${{ secrets.AUTOUPDATE_PAT }}
-
name: Set up Python
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: '3.13'
-
-4
View File
@@ -13,16 +13,12 @@
"env": {
"DOWNLOAD_DIR": "${env:USERPROFILE}/Downloads",
"STATE_DIR": "${env:TEMP}",
"ALLOW_YTDL_OPTIONS_OVERRIDES": "true",
"YTDL_OPTIONS_PRESETS": "{\"sponsorblock\": {\"postprocessors\": [{\"key\": \"SponsorBlock\", \"categories\": [\"sponsor\", \"selfpromo\", \"interaction\"]}, {\"key\": \"ModifyChapters\", \"remove_sponsor_segments\": [\"sponsor\", \"selfpromo\", \"interaction\"]}]}, \"embed-subs\": {\"writesubtitles\": true, \"writeautomaticsub\": true, \"subtitleslangs\": [\"en\", \"de\"], \"postprocessors\": [{\"key\": \"FFmpegEmbedSubtitle\"}]}, \"limit-rate\": {\"ratelimit\": 5000000}}",
}
},
"osx": {
"env": {
"DOWNLOAD_DIR": "${env:HOME}/Downloads",
"STATE_DIR": "${env:TMPDIR}",
"ALLOW_YTDL_OPTIONS_OVERRIDES": "true",
"YTDL_OPTIONS_PRESETS": "{\"sponsorblock\": {\"postprocessors\": [{\"key\": \"SponsorBlock\", \"categories\": [\"sponsor\", \"selfpromo\", \"interaction\"]}, {\"key\": \"ModifyChapters\", \"remove_sponsor_segments\": [\"sponsor\", \"selfpromo\", \"interaction\"]}]}, \"embed-subs\": {\"writesubtitles\": true, \"writeautomaticsub\": true, \"subtitleslangs\": [\"en\", \"de\"], \"postprocessors\": [{\"key\": \"FFmpegEmbedSubtitle\"}]}, \"limit-rate\": {\"ratelimit\": 5000000}}",
}
},
"console": "integratedTerminal"
-173
View File
@@ -1,173 +0,0 @@
# Agent Guidelines
## Project scope — read this before planning a feature
MeTube's contract is: give it a URL, it runs yt-dlp well, and correct files appear.
The maintainer holds a deliberate line on what belongs inside that contract, and PRs
on the wrong side of it are declined **regardless of code quality**. Check your plan
against this line before writing any code.
**In scope — improving the write itself:**
- Features that make the file yt-dlp writes at download time come out more correct,
using only data the extractor already provides (e.g. filling a missing album-artist
tag from the extractor's own metadata).
- Surfacing functionality yt-dlp itself owns and maintains as first-class UI options
(e.g. a SponsorBlock toggle that just passes yt-dlp postprocessor params).
- Download queue, subscriptions, output templates, and UI improvements to the
download workflow.
**Out of scope — managing files after they exist:**
- Tag editors, metadata dialogs, or any workflow that rewrites files after the
download has finished. This holds even for slimmed-down versions.
- Lookups against external metadata services (iTunes, Deezer, MusicBrainz, etc.).
More broadly: any new dependency on a third-party API, or new network egress from
self-hosted instances, beyond what yt-dlp itself performs.
- Library organization: moving/renaming existing files into Artist/Album layouts,
watch-folder processing, and similar media-manager features. Dedicated tools
(beets, MusicBrainz Picard, Lidarr) do this properly; the README points users
to them.
**Corollaries that shape borderline PRs:**
- Site-specific intelligence (parsing playlist-ID prefixes, URL path conventions,
and other platform internals) is extractor work and belongs upstream in yt-dlp,
not re-implemented here — it silently breaks when the platform changes and
MeTube would own the breakage.
- Prefer enriching yt-dlp's info dict and letting its existing pipeline
(FFmpegMetadata etc.) do the writing, over adding custom per-format tag-writing
code to MeTube.
- Supplemental processing must never fail a download that otherwise succeeded:
warn and continue, don't raise.
- Keep feature scope minimal on first submission. A hardcoded sensible default
beats a configuration surface; follow-ups can add options when users actually
ask. PRs that bundle several "reasonable next steps" invite rejection of the
whole.
If a feature idea fails this test, the accepted alternative is usually a README
section documenting how to pair MeTube with the right dedicated tool.
## README.md size constraint
The README.md is synced to Docker Hub, which has a **25,000 character limit**.
Any change to README.md **must** keep the file under 25,000 characters (`wc -c README.md`).
If an addition would exceed the limit, trim existing prose elsewhere — prefer tightening verbose descriptions over removing sections.
## Tech stack
- **Backend:** Python 3.13+, aiohttp, python-socketio 5.x, yt-dlp
- **Frontend:** Angular 22, TypeScript, Bootstrap 5, SASS, ngx-socket-io
- **Package managers:** uv (Python), pnpm (frontend)
- **Container:** Multi-stage Docker (Node builder + Python runtime), multi-arch (amd64/arm64)
## Build & test commands
```bash
# Frontend (run from ui/)
pnpm install --frozen-lockfile
pnpm run lint
pnpm run build
pnpm exec ng test --watch=false
# Backend (run from repo root)
uv sync --frozen --group dev
python -m compileall app
uv run pytest app/tests/
```
All of these run in CI (`.github/workflows/main.yml`) on every push to master and must pass.
Gotchas:
- Backend tests must run **from the repo root**: `main.py` resolves the static-assets
path relative to the cwd, and several test modules import `main`. Running from
`app/` makes five test modules fail to import.
- The frontend must be **built before** running backend tests (same reason — the
assets at `ui/dist/metube/browser` must exist). The command order above is
load-bearing.
- `app/tests/test_ytdl_utils.py` stubs `yt_dlp` at import time. Run standalone,
two tests fail with `AttributeError: <module 'yt_dlp'> does not have the
attribute 'YoutubeDL'`; under the full suite the real module is imported first
and they pass. This is a known quirk, not a bug to fix in the code under test.
Every non-markdown push to master builds multi-arch Docker images and cuts a dated
release the same day. **Master is continuously released** — a PR must be
release-ready exactly as merged; there is no stabilization window for follow-up
fixes.
## Commit messages
A commit that resolves an issue must close it, with a GitHub closing keyword in
parentheses at the end of the subject line:
```
fix: stop metadata probes from writing playlist sidecar files (closes #1040)
```
Because master is the default branch and is released on every push, the issue
closes at the moment the fix ships, and keeps a permanent link to the commit that
fixed it. A bare `(#1040)` is only a reference — and reads as a pull-request
number — so it does not count; the keyword is what closes the issue.
Auto-closing leaves only a commit stub on the issue, which is not an answer to
whoever reported it. Post an explanatory comment as well: what the cause was, what
changed, and anything the reporter needs to do differently.
Follow `.editorconfig`:
- Python: 4-space indent
- Everything else (TypeScript, YAML, JSON, HTML): 2-space indent
- UTF-8, LF line endings, trim trailing whitespace, final newline
Frontend additionally uses ESLint (`ui/eslint.config.js`) and Prettier (config in `ui/package.json`: `printWidth=100`, `singleQuote=true`).
## Project structure
```
app/main.py — HTTP server, Socket.IO events, REST API routes, Config class
app/ytdl.py — Download queue logic, yt-dlp integration
app/subscriptions.py — Channel/playlist subscription manager
app/state_store.py — JSON-based persistent storage with atomic writes
app/dl_formats.py — Video/audio codec/quality mapping
app/tests/ — pytest tests (asyncio_mode=auto)
ui/src/app/ — Angular standalone components (no NgModules)
```
## Key conventions
- Backend configuration lives in the `Config` class in `app/main.py` with env-var defaults in `_DEFAULTS`. New env vars go there.
- Real-time communication uses Socket.IO events, not REST polling.
- Frontend uses standalone Angular components with `inject()` for DI, RxJS Subjects for state, and `takeUntilDestroyed()` for cleanup.
- Frontend components use OnPush change detection: subscribe callbacks must call `cdr.markForCheck()`.
- State is persisted as JSON files via `AtomicJsonStore` in `app/state_store.py`.
- Persisted state stays compact: the completed queue deliberately drops bulky entry data (see `_compact_persisted_entry` in `app/ytdl.py`). Don't expand what gets persisted without discussion.
- Custom yt-dlp postprocessors added to `ytdl_params['postprocessors']` run in **list order** within a stage. When combining postprocessors, mirror the ordering the yt-dlp CLI would produce (e.g. sponsor-segment removal before chapter splitting).
- No pre-commit hooks — linting and tests are enforced in CI only.
## Checklist: adding a per-download option
New options on the download form (the `split_by_chapters` pattern) need **all** of
these pieces — the last three are the ones commonly missed:
1. `parse_download_options` in `app/main.py`.
2. A field on `DownloadInfo` in `app/ytdl.py`.
3. A `hasattr` backfill in `DownloadInfo.__setstate__` for old persisted records.
4. The safe-deserialization field list in `app/ytdl.py`.
5. UI form control + cookie persistence in `ui/src/app/app.ts` / `app.html`, and
the payload in `downloads.service.ts` (plus its spec).
6. The redownload path in `app.ts`, so retries carry the option.
7. If the option makes sense for unattended downloads: threading through
`app/subscriptions.py` (`SubscriptionInfo` field, serializer, add/update
routes, the enqueue call) — or a note in the PR that it's deliberately
direct-downloads-only.
## Security invariants
User input and extractor-provided metadata (titles, playlist names, URLs) are
untrusted. Use the existing guards instead of hand-rolling:
- User-submitted URLs go through the SSRF guard (see `test_url_guard.py` for the
expected behavior).
- Anything that becomes a filesystem path goes through `_is_within_directory` and
`_sanitize_path_component` in `app/ytdl.py` — including values that arrive via
yt-dlp metadata, which sites can influence.
+8 -12
View File
@@ -1,8 +1,4 @@
# Pinned to a major version rather than the lts-alpine floating tag: that tag
# has lagged behind and resolved to a Node patch older than the Angular CLI's
# minimum supported version, breaking the build. node:22-alpine currently
# satisfies @angular/cli's >=22.22.3 requirement.
FROM node:22-alpine AS builder
FROM node:lts-alpine AS builder
WORKDIR /metube
COPY ui ./
@@ -30,12 +26,15 @@ RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
gosu \
curl \
tini \
file \
gdbmtool \
sqlite3 \
build-essential && \
curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && \
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \
uv cache clean && \
rm -f /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/uvw && \
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y && \
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh -s -- -y v2.7.2 && \
apt-get purge -y --auto-remove build-essential && \
rm -rf /var/lib/apt/lists/* && \
mkdir /.cache && chmod 777 /.cache
@@ -64,14 +63,11 @@ ENV PUID=1000
ENV PGID=1000
ENV UMASK=022
ENV DOWNLOAD_DIR=/downloads
ENV STATE_DIR=/downloads/.metube
ENV TEMP_DIR=/downloads
ENV PORT=8081
ENV DOWNLOAD_DIR /downloads
ENV STATE_DIR /downloads/.metube
ENV TEMP_DIR /downloads
VOLUME /downloads
EXPOSE 8081
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD case "$HTTPS" in true|True|on|1) curl -fsSk "https://localhost:${PORT}/";; *) curl -fsS "http://localhost:${PORT}/";; esac || exit 1
# Add build-time argument for version
ARG VERSION=dev
+117 -175
View File
@@ -3,14 +3,9 @@
![Build Status](https://github.com/alexta69/metube/actions/workflows/main.yml/badge.svg)
![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). Docker images are multi-arch (amd64/arm64).
Web GUI for youtube-dl (using the [yt-dlp](https://github.com/yt-dlp/yt-dlp) fork) with playlist support. Allows you to download videos 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](https://github.com/alexta69/metube/wiki/Subscriptions) to channels and playlists, periodically check for new items, and queue new uploads automatically.
![screenshot1](https://github.com/alexta69/metube/raw/master/screenshot.gif?v=2)
![screenshot1](https://github.com/alexta69/metube/raw/master/screenshot.gif)
## 🐳 Run using Docker
@@ -18,7 +13,7 @@ Key capabilities:
docker run -d -p 8081:8081 -v /path/to/downloads:/downloads ghcr.io/alexta69/metube
```
## 🐳 Run using Docker Compose
## 🐳 Run using docker-compose
```yaml
services:
@@ -34,37 +29,24 @@ services:
## ⚙️ Configuration via environment variables
Certain values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in Docker Compose.
### 🏠 Runtime & Permissions
* __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`.
* __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`.
Certain values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in docker-compose.
### ⬇️ 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`.
* __DELETE_FILE_ON_TRASHCAN__: if `true`, downloaded files are deleted on the server, when they are trashed from the "Completed" section of the UI. Defaults to `false`.
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
* __SUBSCRIPTION_DEFAULT_CHECK_INTERVAL__: Default minutes between automatic checks for each subscription. Defaults to `60`.
* __SUBSCRIPTION_SCAN_PLAYLIST_END__: Maximum playlist/channel entries to fetch per subscription check (newest-first). Defaults to `50`.
* __SUBSCRIPTION_MAX_SEEN_IDS__: Cap on stored video IDs per subscription to limit state file growth. Defaults to `50000`.
* __CLEAR_COMPLETED_AFTER__: Number of seconds after which completed (and failed) downloads are automatically removed from the "Completed" list. Defaults to `0` (disabled).
### 📁 Storage & Directories
* __DOWNLOAD_DIR__: Path to where the downloads will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
* __AUDIO_DOWNLOAD_DIR__: Path to where audio-only downloads will be saved, if you wish to separate them from the video downloads. Defaults to the value of `DOWNLOAD_DIR`.
* __CUSTOM_DIRS__: Whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a **Download Folder** field appears under **Advanced Options**, where the directory for each download can be specified. Defaults to `true`.
* __CUSTOM_DIRS__: Whether to enable downloading videos into custom directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__). When enabled, a dropdown appears next to the Add button to specify the download directory. 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 folder field's suggestions. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`.
* __DEFAULT_FOLDER__: Custom directory to pre-select in the download folder field, relative to __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__), for when most downloads go to the same place. It is only a starting value — the field stays editable, so any other folder can still be picked per download. Requires __CUSTOM_DIRS__; ignored with a warning otherwise. Defaults to empty, i.e. the base download directory.
* __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 the queue persistence files will be saved. Defaults to `/downloads/.metube` 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.
@@ -76,21 +58,12 @@ Certain values can be set via environment variables, using the `-e` parameter on
* __OUTPUT_TEMPLATE_CHAPTER__: The template for the filenames of the downloaded videos when split into chapters via postprocessors. Defaults to `%(title)s - %(section_number)s %(section_title)s.%(ext)s`.
* __OUTPUT_TEMPLATE_PLAYLIST__: The template for the filenames of the downloaded videos when downloaded as a playlist. Defaults to `%(playlist_title)s/%(title)s.%(ext)s`. Set to empty to use `OUTPUT_TEMPLATE` instead.
* __OUTPUT_TEMPLATE_CHANNEL__: The template for the filenames of the downloaded videos when downloaded as a channel. Defaults to `%(channel)s/%(title)s.%(ext)s`. Set to empty to use `OUTPUT_TEMPLATE` instead.
* __YTDL_OPTIONS__: Additional options to pass to yt-dlp, as a JSON object. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for details, examples, and available options reference.
* __YTDL_OPTIONS_FILE__: Path to a JSON file containing yt-dlp options. Monitored and reloaded automatically on changes. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options).
* __YTDL_OPTIONS_PRESETS__: Named bundles of yt-dlp options, selectable per download in the UI. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for format and examples.
* __YTDL_OPTIONS_PRESETS_FILE__: Path to a JSON file containing presets. Monitored and reloaded automatically on changes. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options).
* __ALLOW_YTDL_OPTIONS_OVERRIDES__: Whether to show a free-text field in the UI for per-download yt-dlp option overrides. Defaults to `false`. See [Configuring yt-dlp options](#%EF%B8%8F-configuring-yt-dlp-options) for details and security considerations.
* __ALLOW_PRIVATE_ADDRESSES__: Whether to allow downloads from private, loopback, link-local and other non-global addresses. Defaults to `false`, which protects against SSRF by refusing URLs that resolve to internal hosts. Set to `true` only in trusted environments — for example when routing traffic through a proxy/VPN client in Fake-IP mode (sing-box, Clash, Mihomo), which resolves hosts to the `198.18.0.0/15` range. Enabling this disables the SSRF protection entirely, so only use it when you control the network. You do **not** need this to use a proxy on an internal address: a proxy configured through the `proxy` option in `YTDL_OPTIONS` (or the `*_proxy` environment variables) is always reachable at its own host and port, wherever it lives. Nor do you need it for a proxy that resolves hostnames itself (an HTTP proxy, `socks5`, `socks5h` or `socks4a`): MeTube leaves those lookups to the proxy rather than resolving submitted URLs locally, so none leak and proxy-only hosts still work.
* __YTDL_NIGHTLY_UPDATE_TIME__: If set, will cause MeTube to use [nightly yt-dlp builds](https://github.com/yt-dlp/yt-dlp-nightly-builds) instead of the stable releases. Set to the time (`HH:MM`, 24-hour) when you want the daily upgrades and MeTube restart to happen. Defaults to empty (disabled).
A filename that would exceed the limit the filesystem accepts is shortened to fit, keeping its extension, with room left for the suffixes yt-dlp adds while downloading. Sites that put a long description in the title would otherwise fail the download outright with `File name too long`. Use `trim_file_name` in `YTDL_OPTIONS` if you want names shorter than the filesystem's own limit, or `restrictfilenames` to strip non-ASCII characters.
Enabling `writeinfojson` or `writethumbnail` in `YTDL_OPTIONS` also writes a feed-level `.info.json` and thumbnail when you add a playlist or channel. These reuse the template of the items they belong to — `OUTPUT_TEMPLATE_CHANNEL` or `OUTPUT_TEMPLATE_PLAYLIST` — evaluated against the feed itself, so with the defaults they land in the same folder as the videos, named after the feed. Set `allow_playlist_files` to `false` in `YTDL_OPTIONS` to skip them.
* __YTDL_OPTIONS__: Additional options to pass to yt-dlp in JSON format. [See available options here](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L222). They roughly correspond to command-line options, though some do not have exact equivalents here. For example, `--recode-video` has to be specified via `postprocessors`. Also note that dashes are replaced with underscores. You may find [this script](https://github.com/yt-dlp/yt-dlp/blob/master/devscripts/cli_to_api.py) helpful for converting from command-line options to `YTDL_OPTIONS`.
* __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected.
### 🌐 Web Server & URLs
* __HOST__: The host address the web server will bind to. Defaults to `0.0.0.0`, which is every IPv4 interface but no IPv6 one. Set it to `*` (or leave it empty) to listen on both stacks, or to `::` for IPv6 only — `::` does not also accept IPv4, whatever the host's `bindv6only` setting says.
* __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.
@@ -98,126 +71,16 @@ Enabling `writeinfojson` or `writethumbnail` in `YTDL_OPTIONS` also writes a fee
* __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; `*` allows all. When unset or empty, all cross-origin requests are denied. Required for browser extensions and bookmarklets — see [Sending links to MeTube](#-sending-links-to-metube). Naming origins explicitly also lets them send credentials (a login cookie, or the `Authorization` header a reverse proxy checks), which `*` deliberately does not: it would let any site you visit drive your instance with your own session.
* __ROBOTS_TXT__: A path to a `robots.txt` file mounted in the container.
## 🎛️ Configuring yt-dlp options
### 🏠 Basic Setup
MeTube 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.
3. **Per-download overrides** — free-form options entered in the UI for a single download.
When a download starts, these layers are combined in order. If the same option appears in more than one layer, the more specific one wins: per-download overrides beat presets, and presets beat global options.
In JSON presets and overrides, setting an option to **`null`** clears that option for that download (for example, `"download_archive": null` overrides a global archive path so the archive is not used). This follows yt-dlps usual meaning of `None` for that option.
### 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.
> **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.
### Global options
Global options form the baseline for every download. There are two ways to define them, and you can use either or both:
**Inline via environment variable** (`YTDL_OPTIONS`) — pass a JSON object directly:
```yaml
environment:
- 'YTDL_OPTIONS={"writesubtitles": true, "subtitleslangs": ["en", "de"], "updatetime": false, "writethumbnail": true}'
```
**Via a JSON file** (`YTDL_OPTIONS_FILE`) — mount a file into the container and point to it:
```yaml
volumes:
- /path/to/ytdl-options.json:/config/ytdl-options.json
environment:
- YTDL_OPTIONS_FILE=/config/ytdl-options.json
```
where `ytdl-options.json` contains:
```json
{
"writesubtitles": true,
"subtitleslangs": ["en", "de"],
"updatetime": false,
"writethumbnail": true
}
```
The file is monitored for changes and reloaded automatically — no container restart needed. If you use both methods and they define the same key, the **file takes precedence**.
### Presets
Presets let you define named bundles of options that appear in the web UI under **Advanced Options** as "Option Presets". Users can select one or more presets per download, making it easy to apply common option combinations without editing global settings.
Like global options, presets can be set inline or via a file:
* `YTDL_OPTIONS_PRESETS` — a JSON object where each key is a preset name and its value is a set of yt-dlp options.
* `YTDL_OPTIONS_PRESETS_FILE` — path to a JSON file containing presets, monitored and reloaded on changes.
If both are used and they define a preset with the same name, the **file's version takes precedence**.
**Example** — a presets file defining three presets:
```json
{
"sponsorblock": {
"postprocessors": [
{ "key": "SponsorBlock", "categories": ["sponsor", "selfpromo", "interaction"] },
{ "key": "ModifyChapters", "remove_sponsor_segments": ["sponsor", "selfpromo", "interaction"] }
]
},
"embed-subs": {
"writesubtitles": true,
"writeautomaticsub": true,
"subtitleslangs": ["en", "de"],
"postprocessors": [{ "key": "FFmpegEmbedSubtitle" }]
},
"limit-rate": {
"ratelimit": 5000000
}
}
```
This makes three presets available in the UI:
* **sponsorblock** — strips sponsor, self-promo, and interaction segments from videos.
* **embed-subs** — downloads English and German subtitles and embeds them into the video file.
* **limit-rate** — caps download speed to ~5 MB/s.
When multiple presets are selected for a download, they are applied in order. If two presets set the same option, the later one wins.
### 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:
```yaml
environment:
- ALLOW_YTDL_OPTIONS_OVERRIDES=true
```
Once enabled, the field appears under **Advanced Options**. Any options entered there take the highest priority, overriding both global options and selected presets.
> **⚠️ Security note:** Enabling this allows arbitrary yt-dlp API options to be supplied by anyone with access to the UI. Depending on the options used, this may enable arbitrary command execution inside the container. Enable only in trusted environments.
### How the layers combine
When a download starts, the final set of yt-dlp options is built in this order:
1. Start with **global options** (`YTDL_OPTIONS` / `YTDL_OPTIONS_FILE`).
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.
**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
* __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`.
* __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`.
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)
@@ -235,29 +98,62 @@ In case you need to use your browser's cookies with MeTube, for example to downl
* After upload, the cookie indicator should show as active.
* Use **Delete Cookies** in the same section to remove uploaded cookies.
## 🔗 Sending links to MeTube
## 🔌 Browser extensions
Several integrations let you send URLs to MeTube from wherever you are, instead of pasting them into the UI. The browser-based ones make cross-origin requests, so they require `CORS_ALLOWED_ORIGINS` to be set; and if you're on an HTTPS page, your MeTube instance must be served over HTTPS too (with `HTTPS=true` or behind an HTTPS reverse proxy see below).
Browser extensions allow right-clicking videos and sending them directly to MeTube. Please note that if you're on an HTTPS page, your MeTube instance must be behind an HTTPS reverse proxy (see below) for the extensions to work.
__Browser extensions__ allow right-clicking videos and sending them directly to MeTube. Since extensions request from their own origin, set `CORS_ALLOWED_ORIGINS=*`.
* __Chrome:__ contributed by [Rpsl](https://github.com/rpsl) — install from the [Chrome Webstore](https://chrome.google.com/webstore/detail/metube-downloader/fbmkmdnlhacefjljljlbhkodfmfkijdh) or [from sources](https://github.com/Rpsl/metube-browser-extension).
* __Firefox:__ contributed by [nanocortex](https://github.com/nanocortex) — install from [Firefox Addons](https://addons.mozilla.org/en-US/firefox/addon/metube-downloader) or get sources [here](https://github.com/nanocortex/metube-firefox-addon).
__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).
__Bookmarklets__ send the currently open page to MeTube with one click. Add the origins of the sites where you use them to `CORS_ALLOWED_ORIGINS`, e.g. `https://www.youtube.com,https://www.vimeo.com`. If your instance sits behind authentication, list the origins individually rather than using `*` — only named origins are allowed to send credentials. The code (Chrome and Firefox variants, contributed by [kushfest](https://github.com/kushfest) and [shoonya75](https://github.com/shoonya75)) is in the [Bookmarklets wiki page](https://github.com/alexta69/metube/wiki/Bookmarklets).
__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).
__iOS Shortcut:__ [rithask](https://github.com/rithask) created an [iOS shortcut](https://www.icloud.com/shortcuts/66627a9f334c467baabdb2769763a1a6) for sending URLs to MeTube from Safari's share menu; it prompts for your instance address on first use.
## 📱 iOS Shortcut
__Android:__ [sagheerys](https://github.com/sagheerys) created [MeTube Mobile](https://github.com/sagheerys/metube-mobile), two apps that take links from Android's share menu: Lite pulls finished downloads to the phone and removes them from the server, Super keeps them on the server and streams them.
[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).
__Raycast:__ [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.
## 📱 iOS Compatibility
## 🎵 Pairing with a music tagger
iOS has strict requirements for video files, requiring h264 or h265 video codec and aac audio codec in MP4 container. This can sometimes be a lower quality than the best quality available. To accommodate iOS requirements, when downloading a MP4 format you can choose "Best (iOS)" to get the best quality formats as compatible as possible with iOS requirements.
MeTube deliberately stops once the file is written — tagging and library organization belong to dedicated tools. Point one at your audio download folder (`AUDIO_DOWNLOAD_DIR`):
To force all downloads to be converted to an iOS-compatible codec, insert this as an environment variable:
* [beets](https://beets.io) — `beet import` matches tracks against MusicBrainz, fixes tags, and files them into an Artist/Album library; headless and scriptable.
* [MusicBrainz Picard](https://picard.musicbrainz.org) — GUI tagger with acoustic fingerprinting.
* [Lidarr](https://lidarr.audio) — full music library manager; add the folder as an import path.
```yaml
environment:
- 'YTDL_OPTIONS={"format": "best", "exec": "ffmpeg -i %(filepath)q -c:v libx264 -c:a aac %(filepath)q.h264.mp4"}'
```
## 🔖 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.
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.
```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.")}}}();
```
[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.")}}})();
```
The above bookmarklets use `alert()` as a success/failure notification. The following will show a toast message instead:
Chrome:
```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.")}}}();
```
Firefox:
```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.")}}})();
```
## ⚡ Raycast extension
[dotvhs](https://github.com/dotvhs) has created an [extension for Raycast](https://www.raycast.com/dot/metube) that allows adding videos to MeTube directly from Raycast.
## 🔒 HTTPS support, and running behind a reverse proxy
@@ -281,7 +177,13 @@ 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. MeTube uses WebSocket for real-time updates, so the proxy must pass the `Upgrade`/`Connection` headers, as in this NGINX example:
It's also possible to run MeTube behind a reverse proxy, in order to support authentication. HTTPS support can also be added in this way.
When running behind a reverse proxy which remaps the URL (i.e. serves MeTube under a subdirectory and not under root), don't forget to set the URL_PREFIX environment variable to the correct value.
If you're using the [linuxserver/swag](https://docs.linuxserver.io/general/swag) image for your reverse proxying needs (which I can heartily recommend), it already includes ready snippets for proxying MeTube both 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 under the `nginx/proxy-confs` directory in the configuration volume. It also includes Authelia which can be used for authentication.
### 🌐 NGINX
```nginx
location /metube/ {
@@ -293,26 +195,66 @@ location /metube/ {
}
```
Apache, Caddy, and [linuxserver/swag](https://docs.linuxserver.io/general/swag) (with Authelia) examples are in the [Reverse proxy configurations wiki page](https://github.com/alexta69/metube/wiki/Reverse-proxy-configurations).
Note: the extra `proxy_set_header` directives are there to make WebSocket work.
### 🌐 Apache
Contributed by [PIE-yt](https://github.com/PIE-yt). Source [here](https://gist.github.com/PIE-yt/29e7116588379032427f5bd446b2cac4).
```apache
# For putting in your Apache sites site.conf
# Serves MeTube under a /metube/ subdir (http://yourdomain.com/metube/)
<Location /metube/>
ProxyPass http://localhost:8081/ retry=0 timeout=30
ProxyPassReverse http://localhost:8081/
</Location>
<Location /metube/socket.io>
RewriteEngine On
RewriteCond %{QUERY_STRING} transport=websocket [NC]
RewriteRule /(.*) ws://localhost:8081/socket.io/$1 [P,L]
ProxyPass http://localhost:8081/socket.io retry=0 timeout=30
ProxyPassReverse http://localhost:8081/socket.io
</Location>
```
### 🌐 Caddy
The following example Caddyfile gets a reverse proxy going behind [caddy](https://caddyserver.com).
```caddyfile
example.com {
route /metube/* {
uri strip_prefix metube
reverse_proxy metube: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 new MeTube Docker image is published automatically when a new yt-dlp stable release is available, so keep your container up to date — [watchtower](https://github.com/nicholas-fedor/watchtower) works well for this. To follow yt-dlp's nightly channel instead, set `YTDL_NIGHTLY_UPDATE_TIME`.
The engine which powers the actual video downloads in MeTube is [yt-dlp](https://github.com/yt-dlp/yt-dlp). Since video sites regularly change their layouts, frequent updates of yt-dlp are required to keep up.
There's an automatic nightly build of MeTube which looks for a new version of yt-dlp, and if one exists, the build pulls it and publishes an updated docker image. Therefore, in order to keep up with the changes, it's recommended that you update your MeTube container regularly with the latest image.
I recommend installing and setting up [watchtower](https://github.com/nicholas-fedor/watchtower) for this purpose.
## 🔧 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:
Before asking a question or submitting an issue for MeTube, please remember that MeTube is only a UI for [yt-dlp](https://github.com/yt-dlp/yt-dlp). Any issues you might be experiencing with authentication to video websites, postprocessing, permissions, other `YTDL_OPTIONS` configurations which seem not to work, or anything else that concerns the workings of the underlying yt-dlp library, need not be opened on the MeTube project. In order to debug and troubleshoot them, it's advised to try using the yt-dlp binary directly first, bypassing the UI, and once that is working, importing the options that worked for you into `YTDL_OPTIONS`.
In order to test with the yt-dlp command directly, you can either download it and run it locally, or for a better simulation of its actual conditions, you can run it within the MeTube container itself. Assuming your MeTube container is called `metube`, run the following on your Docker host to get a shell inside the container:
```bash
docker exec -ti metube sh
cd /downloads
```
Common issues and their fixes are collected in the [Troubleshooting FAQ](https://github.com/alexta69/metube/wiki/Troubleshooting-FAQ) on the wiki.
Once there, you can use the yt-dlp command freely.
## 💡 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 before writing code — MeTube's scope is deliberately narrow: it downloads well and stops once the file is written. Features that improve the download itself are welcome; post-download file management (tag editing, metadata lookups, library organization) is out of scope regardless of implementation quality — see [AGENTS.md](AGENTS.md) for the full policy. Feature requests without an accompanying PR are unlikely to be fulfilled.
MeTube development relies on code contributions by the community. The program as it currently stands fits my own use cases, and is therefore feature-complete as far as I'm concerned. If your use cases are different and require additional features, please feel free to submit PRs that implement those features. It's advisable to create an issue first to discuss the planned implementation, because in an effort to reduce bloat, some PRs may not be accepted. However, note that opening a feature request when you don't intend to implement the feature will rarely result in the request being fulfilled.
## 🛠️ Building and running locally
-24
View File
@@ -1,24 +0,0 @@
# Security Policy
## Reporting a vulnerability
Please report vulnerabilities privately via
[GitHub private vulnerability reporting](https://github.com/alexta69/metube/security/advisories/new)
(Security tab → "Report a vulnerability"). Do **not** open a public issue for
security problems.
You can expect an initial response within a few days. Please include a
reproduction and the MeTube release version (visible in the UI footer).
## Supported versions
MeTube is continuously released; only the **latest release** is supported.
Update to the current Docker image before reporting.
## Scope notes
MeTube ships **without authentication by design** — it is intended to run on a
trusted network or behind an authenticating reverse proxy (see the
[wiki](https://github.com/alexta69/metube/wiki/Reverse-proxy-configurations)).
Reports that reduce to "the UI is reachable without a login" are expected
behavior, not vulnerabilities.
-26
View File
@@ -1,26 +0,0 @@
import asyncio
import logging
log = logging.getLogger("bg_tasks")
_TASKS: set[asyncio.Task] = set()
def create_task(coro, *, name: str | None = None) -> asyncio.Task:
"""create_task that keeps a strong reference and logs unexpected failures.
A bare ``asyncio.create_task(...)`` is only weakly referenced by the event
loop; if nothing else holds the returned Task, it can be garbage collected
mid-flight. Keeping a module-level strong reference (removed once the task
finishes) avoids that, and the done-callback surfaces otherwise-silent
failures.
"""
task = asyncio.get_running_loop().create_task(coro, name=name)
_TASKS.add(task)
def _done(t: asyncio.Task) -> None:
_TASKS.discard(t)
if not t.cancelled() and t.exception() is not None:
log.error("Background task %s failed", t.get_name(), exc_info=t.exception())
task.add_done_callback(_done)
return task
+42 -83
View File
@@ -4,28 +4,6 @@ AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac")
CAPTION_MODES = ("auto_only", "manual_only", "prefer_manual", "prefer_auto")
def merge_ytdl_option_layers(presets, overrides, presets_config) -> dict:
"""Overlay named presets (in order) then per-item overrides onto a fresh dict.
Does NOT include any base ``YTDL_OPTIONS`` — callers layer this on top of
their own base (a per-download build adds the global base; a subscription
scan relies on ``**config.YTDL_OPTIONS`` already being present in its
params). ``presets_config`` maps a preset name to its options dict.
"""
merged: dict = {}
for name in presets or []:
merged.update(presets_config.get(name, {}))
merged.update(overrides or {})
return merged
CODEC_FILTER_MAP = {
'h264': "[vcodec~='^(h264|avc)']",
'h265': "[vcodec~='^(h265|hevc)']",
'av1': "[vcodec~='^av0?1']",
'vp9': "[vcodec~='^vp0?9']",
}
def _normalize_caption_mode(mode: str) -> str:
mode = (mode or "").strip()
return mode if mode in CAPTION_MODES else "prefer_manual"
@@ -36,92 +14,84 @@ def _normalize_subtitle_language(language: str) -> str:
return language or "en"
def get_format(download_type: str, codec: str, format: str, quality: str) -> str:
def get_format(format: str, quality: str) -> str:
"""
Returns yt-dlp format selector.
Returns format for download
Args:
download_type (str): selected content type (video, audio, captions, thumbnail)
codec (str): selected video codec (auto, h264, h265, av1, vp9)
format (str): selected output format/profile for type
quality (str): selected quality
format (str): format selected
quality (str): quality selected
Raises:
Exception: unknown type/format
Exception: unknown quality, unknown format
Returns:
str: yt-dlp format selector
dl_format: Formatted download string
"""
download_type = (download_type or "video").strip().lower()
format = (format or "any").strip().lower()
codec = (codec or "auto").strip().lower()
quality = (quality or "best").strip().lower()
format = format or "any"
if format.startswith("custom:"):
# Unreachable via the HTTP API (format is validated against a fixed
# set in main.py), but legacy persisted downloads may carry a
# custom: format from before that validation existed; removing this
# would crash PersistentQueue.load() for those records.
return format[7:]
if download_type == "thumbnail":
if format == "thumbnail":
# Quality is irrelevant in this case since we skip the download
return "bestaudio/best"
if download_type == "captions":
if format == "captions":
# Quality is irrelevant in this case since we skip the download
return "bestaudio/best"
if download_type == "audio":
if format not in AUDIO_FORMATS:
raise ValueError(f"Unknown audio format {format}")
if format in AUDIO_FORMATS:
# Audio quality needs to be set post-download, set in opts
return f"bestaudio[ext={format}]/bestaudio/best"
if download_type == "video":
if format not in ("any", "mp4", "ios"):
raise ValueError(f"Unknown video format {format}")
vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format in ("mp4", "ios") else ("", "")
vres = f"[height<={quality}]" if quality not in ("best", "worst") else ""
if format in ("mp4", "any"):
if quality == "audio":
return "bestaudio/best"
# video {res} {vfmt} + audio {afmt} {res} {vfmt}
vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format == "mp4" else ("", "")
vres = f"[height<={quality}]" if quality not in ("best", "best_ios", "worst") else ""
vcombo = vres + vfmt
codec_filter = CODEC_FILTER_MAP.get(codec, "")
if format == "ios":
if quality == "best_ios":
# iOS has strict requirements for video files, requiring h264 or h265
# video codec and aac audio codec in MP4 container. This format string
# attempts to get the fully compatible formats first, then the h264/h265
# video codec with any M4A audio codec (because audio is faster to
# convert if needed), and falls back to getting the best available MP4
# file.
return f"bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio[acodec=aac]/bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}"
if codec_filter:
return f"bestvideo{codec_filter}{vcombo}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}"
return f"bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}"
raise ValueError(f"Unknown download_type {download_type}")
raise Exception(f"Unkown format {format}")
def get_opts(
download_type: str,
_codec: str,
format: str,
quality: str,
ytdl_opts: dict,
subtitle_format: str = "srt",
subtitle_language: str = "en",
subtitle_mode: str = "prefer_manual",
) -> dict:
"""
Returns extra yt-dlp options/postprocessors.
Returns extra download options
Mostly postprocessing options
Args:
download_type (str): selected content type
codec (str): selected codec (unused currently, kept for API consistency)
format (str): selected format/profile
quality (str): selected quality
format (str): format selected
quality (str): quality of format selected (needed for some formats)
ytdl_opts (dict): current options selected
Returns:
dict: extended options
ytdl_opts: Extra options
"""
download_type = (download_type or "video").strip().lower()
format = (format or "any").strip().lower()
opts = copy.deepcopy(ytdl_opts)
postprocessors = []
if download_type == "audio":
if format in AUDIO_FORMATS:
postprocessors.append(
{
"key": "FFmpegExtractAudio",
@@ -130,7 +100,8 @@ def get_opts(
}
)
if format != "wav" and "writethumbnail" not in opts:
# Audio formats without thumbnail
if format not in ("wav") and "writethumbnail" not in opts:
opts["writethumbnail"] = True
postprocessors.append(
{
@@ -142,34 +113,22 @@ def get_opts(
postprocessors.append({"key": "FFmpegMetadata"})
postprocessors.append({"key": "EmbedThumbnail"})
if download_type == "thumbnail":
if format == "thumbnail":
opts["skip_download"] = True
opts["writethumbnail"] = True
postprocessors.append(
{"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"}
)
if download_type == "captions":
if format == "captions":
mode = _normalize_caption_mode(subtitle_mode)
language = _normalize_subtitle_language(subtitle_language)
opts["skip_download"] = True
requested_subtitle_format = (format or "srt").lower()
requested_subtitle_format = (subtitle_format or "srt").lower()
# txt is a derived, non-timed format produced from SRT after download.
if requested_subtitle_format == "txt":
requested_subtitle_format = "srt"
opts["subtitlesformat"] = f"{requested_subtitle_format}/best"
if requested_subtitle_format in ("srt", "vtt"):
# subtitlesformat above is only a preference: if the extractor
# doesn't natively offer this ext (e.g. YouTube has no native srt),
# yt-dlp silently falls back to whatever it has. ffmpeg can only
# convert to srt/vtt/ass/lrc, so only guarantee the requested
# container for those; other formats stay best-effort.
postprocessors.append(
{
"key": "FFmpegSubtitlesConvertor",
"format": requested_subtitle_format,
"when": "before_dl",
}
)
opts["subtitlesformat"] = requested_subtitle_format
if mode == "manual_only":
opts["writesubtitles"] = True
opts["writeautomaticsub"] = False
+88 -927
View File
File diff suppressed because it is too large Load Diff
-124
View File
@@ -1,124 +0,0 @@
"""Conservative music metadata enrichment for audio downloads.
This module only consumes fields already supplied by yt-dlp or retained on
MeTube's queued playlist entry. It intentionally performs no external lookup
or site-specific album detection.
"""
from __future__ import annotations
from typing import Any, Optional
from yt_dlp.postprocessor.common import PostProcessor
def _has_value(value: Any) -> bool:
if isinstance(value, str):
return bool(value.strip())
if isinstance(value, (list, tuple)):
return any(_has_value(item) for item in value)
return value is not None
def _positive_int(value: Any) -> Optional[int]:
if isinstance(value, bool):
return None
try:
number = int(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
def _track_position(value: Any) -> tuple[Optional[int], Optional[int]]:
"""Return a track number and optional total from a scalar or ``n/total``."""
if isinstance(value, str) and '/' in value:
number, total = value.split('/', 1)
return _positive_int(number.strip()), _positive_int(total.strip())
return _positive_int(value), None
def _first_positive_int(*values: Any) -> Optional[int]:
return next((number for value in values if (number := _positive_int(value))), None)
def _has_album_signal(info: dict[str, Any], source_entry: dict[str, Any]) -> bool:
"""Use only extractor-owned fields to identify album-level metadata."""
return any(
_has_value(entry.get(key))
for entry in (info, source_entry)
for key in ('album', 'track_number')
)
def _is_music_audio(info: dict[str, Any], source_entry: dict[str, Any]) -> bool:
return _has_album_signal(info, source_entry) or any(
_has_value(entry.get(key))
for entry in (info, source_entry)
for key in ('track', 'artists')
)
def prefer_square_thumbnail(info: dict[str, Any]) -> None:
"""Move the largest known square thumbnail to yt-dlp's preferred slot."""
thumbnails = info.get('thumbnails')
if not isinstance(thumbnails, list) or len(thumbnails) < 2:
return
candidates: list[tuple[int, int]] = []
for index, thumbnail in enumerate(thumbnails):
if not isinstance(thumbnail, dict):
continue
width = _positive_int(thumbnail.get('width'))
height = _positive_int(thumbnail.get('height'))
if width is not None and width == height:
candidates.append((width * height, index))
if not candidates:
return
_, selected_index = max(candidates)
selected = thumbnails.pop(selected_index)
thumbnails.append(selected)
if selected.get('url'):
info['thumbnail'] = selected['url']
class MusicMetadataPreProcessor(PostProcessor):
"""Enrich extracted audio metadata using extractor-owned album signals."""
def __init__(self, downloader=None, *, source_entry=None):
super().__init__(downloader)
self._source_entry = source_entry if isinstance(source_entry, dict) else {}
def run(self, info):
if _has_album_signal(info, self._source_entry):
number, inline_total = _track_position(info.get('track_number'))
if number is None:
number, source_inline_total = _track_position(
self._source_entry.get('track_number')
)
inline_total = inline_total or source_inline_total
if number is None:
number = _positive_int(self._source_entry.get('playlist_index'))
total = inline_total or _first_positive_int(
info.get('track_count'),
info.get('track_total'),
self._source_entry.get('track_count'),
self._source_entry.get('track_total'),
self._source_entry.get('playlist_count'),
self._source_entry.get('n_entries'),
)
if number is not None:
info['track_number'] = f'{number}/{total}' if total is not None else number
if not _has_value(info.get('album')):
album = self._source_entry.get('album') or self._source_entry.get(
'playlist_title'
)
if isinstance(album, str) and album.strip():
info['album'] = album.strip()
if _is_music_audio(info, self._source_entry):
prefer_square_thumbnail(info)
return [], info
-237
View File
@@ -1,237 +0,0 @@
from __future__ import annotations
import base64
import collections.abc
import errno
import json
import logging
import os
import shelve
import tempfile
import time
from datetime import datetime
from typing import Any, Optional
log = logging.getLogger("state_store")
STATE_SCHEMA_VERSION = 2
_BYTES_MARKER = "__metube_bytes__"
_DATETIME_MARKER = "__metube_datetime__"
# Errnos that signal the filesystem cannot support the temp-file + rename
# atomic-write strategy (for example an NFS-backed state dir returning EPERM on
# mkstemp). These are safe to fall back on because they mean the atomic
# mechanism is unavailable, not that the data write itself failed. Errors like
# ENOSPC/EIO are deliberately excluded so a genuine storage failure surfaces
# instead of silently truncating an existing good state file.
_ATOMIC_UNSUPPORTED_ERRNOS = frozenset(
e
for e in (
errno.EPERM,
errno.EACCES,
errno.ENOSYS,
errno.EINVAL,
getattr(errno, "EOPNOTSUPP", None),
getattr(errno, "ENOTSUP", None),
)
if e is not None
)
def to_json_compatible(value: Any) -> Any:
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, bytes):
return {_BYTES_MARKER: base64.b64encode(value).decode("ascii")}
if isinstance(value, datetime):
return {_DATETIME_MARKER: value.isoformat()}
if isinstance(value, collections.abc.Mapping):
return {str(k): to_json_compatible(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set, frozenset)):
return [to_json_compatible(v) for v in value]
if isinstance(value, collections.abc.Iterable):
return [to_json_compatible(v) for v in value]
raise TypeError(f"Value of type {type(value).__name__} is not JSON serializable")
def from_json_compatible(value: Any) -> Any:
if isinstance(value, list):
return [from_json_compatible(v) for v in value]
if isinstance(value, dict):
if set(value.keys()) == {_BYTES_MARKER}:
return base64.b64decode(value[_BYTES_MARKER].encode("ascii"))
if set(value.keys()) == {_DATETIME_MARKER}:
return datetime.fromisoformat(value[_DATETIME_MARKER])
return {k: from_json_compatible(v) for k, v in value.items()}
return value
def read_legacy_shelf(path: str) -> Optional[list[tuple[Any, Any]]]:
if not os.path.exists(path):
return None
try:
with shelve.open(path, "r") as shelf:
return list(shelf.items())
except Exception as exc:
log.warning("Could not read legacy shelf at %s: %s", path, exc)
return None
class AtomicJsonStore:
def __init__(self, path: str, *, kind: str, schema_version: int = STATE_SCHEMA_VERSION):
self.path = path
self.kind = kind
self.schema_version = schema_version
self._direct_write_fallback_warned = False
def _ensure_parent(self) -> None:
parent = os.path.dirname(self.path)
if parent and not os.path.isdir(parent):
os.makedirs(parent, exist_ok=True)
def _build_payload(self, data: dict[str, Any]) -> dict[str, Any]:
payload = {
"schema_version": self.schema_version,
"kind": self.kind,
}
payload.update(data)
return payload
def load(self) -> Optional[dict[str, Any]]:
if not os.path.exists(self.path):
return None
try:
with open(self.path, encoding="utf-8") as f:
payload = json.load(f)
if not isinstance(payload, dict):
raise ValueError("State file must contain a JSON object")
if payload.get("kind") != self.kind:
raise ValueError(
f"State file kind mismatch: expected {self.kind}, got {payload.get('kind')}"
)
return payload
except Exception as exc:
self.quarantine_invalid_file(exc)
return None
def save(self, data: dict[str, Any]) -> None:
self._ensure_parent()
payload = self._build_payload(data)
try:
self._atomic_write(payload)
except OSError as exc:
if exc.errno not in _ATOMIC_UNSUPPORTED_ERRNOS:
raise
self._warn_direct_write_fallback(exc)
self._direct_write(payload)
def _atomic_write(self, payload: dict[str, Any]) -> None:
text = self._serialize(payload)
parent = os.path.dirname(self.path) or "."
fd, tmp_path = tempfile.mkstemp(
prefix=f".{os.path.basename(self.path)}.",
suffix=".tmp",
dir=parent,
text=True,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
self._best_effort_fsync(f.fileno())
os.replace(tmp_path, self.path)
self._fsync_directory(parent)
except Exception:
try:
os.remove(tmp_path)
except OSError:
pass
raise
def _direct_write(self, payload: dict[str, Any]) -> None:
# Serialize before truncating so a serialization failure never destroys
# the existing state file (the atomic path gets this for free via its
# temp file).
text = self._serialize(payload)
# Create with 0o600 so the fallback keeps the owner-only permissions the
# atomic path gets from mkstemp; state files can contain URLs and
# per-download option overrides that must not leak on shared mounts.
fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
# The 0o600 mode above only applies when the file is created; force
# it on rewrites too so an existing, broadly-permissioned state file
# is tightened to match the atomic path. Best-effort because some
# network filesystems reject chmod, and that must not re-crash save.
try:
os.fchmod(f.fileno(), 0o600)
except OSError:
pass
f.write(text)
f.flush()
self._best_effort_fsync(f.fileno())
# Make the new directory entry durable too, matching the atomic path.
self._fsync_directory(os.path.dirname(self.path) or ".")
@staticmethod
def _best_effort_fsync(fileno: int) -> None:
# Tolerate fsync being unsupported on the underlying filesystem (for
# example a network mount that returns EINVAL/ENOSYS), but let genuine
# storage failures such as ENOSPC/EIO surface so a non-durable write is
# never reported as success. An unsupported fsync must not by itself
# abandon the atomic rename path.
try:
os.fsync(fileno)
except OSError as exc:
if exc.errno not in _ATOMIC_UNSUPPORTED_ERRNOS:
raise
@staticmethod
def _serialize(payload: dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
def _warn_direct_write_fallback(self, exc: OSError) -> None:
if self._direct_write_fallback_warned:
return
self._direct_write_fallback_warned = True
log.warning(
"Atomic state write failed for %s (%s); falling back to direct write",
self.path,
exc,
)
def quarantine_invalid_file(self, exc: Exception) -> None:
if not os.path.exists(self.path):
return
ts = time.strftime("%Y%m%d%H%M%S")
backup_path = f"{self.path}.invalid.{ts}"
try:
os.replace(self.path, backup_path)
log.warning(
"State file at %s was invalid (%s); moved it to %s",
self.path,
exc,
backup_path,
)
except OSError as move_exc:
log.warning(
"State file at %s was invalid (%s) and could not be moved aside: %s",
self.path,
exc,
move_exc,
)
@staticmethod
def _fsync_directory(path: str) -> None:
try:
flags = os.O_RDONLY
if hasattr(os, "O_DIRECTORY"):
flags |= os.O_DIRECTORY
fd = os.open(path, flags)
except OSError:
return
try:
os.fsync(fd)
except OSError:
pass
finally:
os.close(fd)
-1079
View File
File diff suppressed because it is too large Load Diff
-32
View File
@@ -1,32 +0,0 @@
"""Pytest configuration: set env and filesystem layout before importing ``main``."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
def _ensure_test_env() -> None:
if os.environ.get("METUBE_TEST_ENV_READY"):
return
tmp = tempfile.mkdtemp(prefix="metube-pytest-")
base = Path(tmp)
browser = base / "ui" / "dist" / "metube" / "browser"
browser.mkdir(parents=True)
(browser / "index.html").write_text("<html><body></body></html>", encoding="utf-8")
dl = base / "downloads"
st = base / "state"
dl.mkdir(parents=True)
st.mkdir(parents=True)
os.environ["DOWNLOAD_DIR"] = str(dl)
os.environ["STATE_DIR"] = str(st)
os.environ["TEMP_DIR"] = str(dl)
os.environ["YTDL_OPTIONS"] = "{}"
os.environ["YTDL_OPTIONS_FILE"] = ""
os.environ["BASE_DIR"] = str(base)
os.environ["LOGLEVEL"] = "INFO"
os.environ["METUBE_TEST_ENV_READY"] = "1"
_ensure_test_env()
-654
View File
@@ -1,654 +0,0 @@
"""HTTP handler tests for ``main`` using mocked ``web.Request`` (no TestServer)."""
from __future__ import annotations
import json
import os
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
from urllib.parse import quote
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
import main
@pytest.fixture
def mock_dqueue(monkeypatch):
d = MagicMock()
d.initialize = AsyncMock(return_value=None)
d.add = AsyncMock(return_value={"status": "ok"})
d.retry = AsyncMock(return_value={"status": "ok"})
d.cancel = AsyncMock(return_value={"status": "ok"})
d.clear = AsyncMock(return_value={"status": "ok"})
d.start_pending = AsyncMock(return_value={"status": "ok"})
d.cancel_add = MagicMock()
d.queue = MagicMock()
d.done = MagicMock()
d.pending = MagicMock()
d.queue.saved_items = MagicMock(return_value=[])
d.done.saved_items = MagicMock(return_value=[])
d.pending.saved_items = MagicMock(return_value=[])
d.queue.items = MagicMock(return_value=[])
d.done.items = MagicMock(return_value=[])
d.pending.items = MagicMock(return_value=[])
d.get = MagicMock(return_value=([], []))
monkeypatch.setattr(main, "dqueue", d)
return d
def _valid_video_add_body(**kwargs):
base = {
"url": "https://example.com/watch?v=1",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_presets": [],
"ytdl_options_overrides": "",
}
base.update(kwargs)
return base
def _json_request(body: dict | None):
req = MagicMock(spec=web.Request)
req.json = AsyncMock(return_value=body)
return req
@pytest.mark.asyncio
async def test_add_ok(mock_dqueue):
req = _json_request(_valid_video_add_body())
resp = await main.add(req)
assert resp.status == 200
text = resp.text
data = json.loads(text)
assert data["status"] == "ok"
mock_dqueue.add.assert_awaited_once()
@pytest.mark.asyncio
async def test_retry_passes_failed_download_id(mock_dqueue):
req = _json_request({"id": "https://example.com/watch?v=1"})
resp = await main.retry(req)
assert resp.status == 200
mock_dqueue.retry.assert_awaited_once_with("https://example.com/watch?v=1")
@pytest.mark.asyncio
@pytest.mark.parametrize("body", [{}, {"id": ""}, {"id": ["a"]}, {"ids": ["a"]}])
async def test_retry_rejects_missing_or_non_string_id(mock_dqueue, body):
with pytest.raises(web.HTTPBadRequest):
await main.retry(_json_request(body))
mock_dqueue.retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_add_passes_preset_and_overrides(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset A": {"writesubtitles": True}})
monkeypatch.setattr(main.config, "ALLOW_YTDL_OPTIONS_OVERRIDES", True)
req = _json_request(
_valid_video_add_body(
ytdl_options_presets=["Preset A"],
ytdl_options_overrides='{"writesubtitles": true}',
)
)
resp = await main.add(req)
assert resp.status == 200
call = mock_dqueue.add.await_args
assert call is not None
assert call.args[13] == ["Preset A"]
assert call.args[14] == {"writesubtitles": True}
@pytest.mark.asyncio
async def test_add_legacy_string_preset_normalized(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Legacy": {}})
body = _valid_video_add_body()
del body["ytdl_options_presets"]
body["ytdl_options_preset"] = "Legacy"
req = _json_request(body)
resp = await main.add(req)
assert resp.status == 200
call = mock_dqueue.add.await_args
assert call.args[13] == ["Legacy"]
@pytest.mark.asyncio
async def test_add_missing_url_returns_400(mock_dqueue):
req = _json_request({"download_type": "video", "quality": "best", "format": "any"})
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
mock_dqueue.add.assert_not_called()
@pytest.mark.asyncio
async def test_add_invalid_download_type(mock_dqueue):
req = _json_request(_valid_video_add_body(download_type="invalid"))
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_add_invalid_video_quality(mock_dqueue):
req = _json_request(_valid_video_add_body(quality="9999"))
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_add_invalid_subtitle_language(mock_dqueue):
req = _json_request(
{
"url": "https://example.com/v",
"download_type": "captions",
"codec": "auto",
"format": "srt",
"quality": "best",
"subtitle_language": "bad language!",
}
)
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_add_invalid_json_body(mock_dqueue):
req = MagicMock(spec=web.Request)
req.json = AsyncMock(side_effect=json.JSONDecodeError("msg", "", 0))
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_add_invalid_ytdl_options_override_json(mock_dqueue):
req = _json_request(_valid_video_add_body(ytdl_options_overrides="{bad json}"))
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_add_rejects_ytdl_options_overrides_when_disabled(mock_dqueue):
req = _json_request(_valid_video_add_body(ytdl_options_overrides='{"exec": "rm -rf /"}'))
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_add_allows_any_ytdl_options_override_key_when_enabled(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.config, "ALLOW_YTDL_OPTIONS_OVERRIDES", True)
req = _json_request(_valid_video_add_body(ytdl_options_overrides='{"exec": "echo hi"}'))
resp = await main.add(req)
assert resp.status == 200
call = mock_dqueue.add.await_args
assert call is not None
assert call.args[14] == {"exec": "echo hi"}
@pytest.mark.asyncio
async def test_add_unknown_ytdl_preset(mock_dqueue):
req = _json_request(_valid_video_add_body(ytdl_options_presets=["Missing"]))
with pytest.raises(web.HTTPBadRequest):
await main.add(req)
@pytest.mark.asyncio
async def test_delete_missing_ids(mock_dqueue):
req = _json_request({"where": "queue"})
with pytest.raises(web.HTTPBadRequest):
await main.delete(req)
@pytest.mark.asyncio
async def test_delete_queue_calls_cancel(mock_dqueue):
req = _json_request({"where": "queue", "ids": ["http://x"]})
resp = await main.delete(req)
assert resp.status == 200
mock_dqueue.cancel.assert_awaited_once_with(["http://x"])
@pytest.mark.asyncio
async def test_start_pending(mock_dqueue):
req = _json_request({"ids": ["a"]})
resp = await main.start(req)
assert resp.status == 200
mock_dqueue.start_pending.assert_awaited_once_with(["a"])
@pytest.mark.asyncio
@pytest.mark.parametrize("body", [{}, {"ids": "abc"}, {"ids": []}, {"ids": [1, 2]}])
async def test_start_rejects_malformed_ids(mock_dqueue, body):
req = _json_request(body)
with pytest.raises(web.HTTPBadRequest):
await main.start(req)
mock_dqueue.start_pending.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body",
[
{"where": "queue"},
{"where": "queue", "ids": "abc"},
{"where": "queue", "ids": []},
{"where": "queue", "ids": [1, 2]},
],
)
async def test_delete_rejects_malformed_ids(mock_dqueue, body):
req = _json_request(body)
with pytest.raises(web.HTTPBadRequest):
await main.delete(req)
mock_dqueue.cancel.assert_not_awaited()
mock_dqueue.clear.assert_not_awaited()
@pytest.mark.asyncio
async def test_history_shape(mock_dqueue):
req = MagicMock(spec=web.Request)
resp = await main.history(req)
assert resp.status == 200
data = json.loads(resp.text)
assert set(data.keys()) == {"done", "queue", "pending"}
@pytest.mark.asyncio
async def test_history_reads_in_memory_queues_not_disk_state(mock_dqueue):
fake_queue_dl = MagicMock()
fake_queue_dl.info = {"id": "q1", "title": "Queued"}
fake_done_dl = MagicMock()
fake_done_dl.info = {"id": "d1", "title": "Done"}
fake_pending_dl = MagicMock()
fake_pending_dl.info = {"id": "p1", "title": "Pending"}
mock_dqueue.queue.items.return_value = [("q1", fake_queue_dl)]
mock_dqueue.done.items.return_value = [("d1", fake_done_dl)]
mock_dqueue.pending.items.return_value = [("p1", fake_pending_dl)]
req = MagicMock(spec=web.Request)
resp = await main.history(req)
assert resp.status == 200
data = json.loads(resp.text)
assert [item["id"] for item in data["queue"]] == ["q1"]
assert [item["id"] for item in data["done"]] == ["d1"]
assert [item["id"] for item in data["pending"]] == ["p1"]
mock_dqueue.queue.saved_items.assert_not_called()
mock_dqueue.done.saved_items.assert_not_called()
mock_dqueue.pending.saved_items.assert_not_called()
@pytest.mark.asyncio
async def test_version_json(mock_dqueue):
req = MagicMock(spec=web.Request)
resp = await main.version(req)
assert resp.status == 200
body = json.loads(resp.text)
assert "yt-dlp" in body and "version" in body
@pytest.mark.asyncio
async def test_presets_endpoint_returns_names(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.config, "YTDL_OPTIONS_PRESETS", {"Preset B": {}, "Preset A": {}})
req = MagicMock(spec=web.Request)
resp = await main.presets(req)
assert resp.status == 200
assert json.loads(resp.text) == {"presets": ["Preset A", "Preset B"]}
@pytest.mark.asyncio
async def test_cookie_status(mock_dqueue):
req = MagicMock(spec=web.Request)
resp = await main.cookie_status(req)
assert resp.status == 200
data = json.loads(resp.text)
assert data.get("status") == "ok"
assert "has_cookies" in data
@pytest.mark.asyncio
async def test_options_add_cors(mock_dqueue):
req = MagicMock(spec=web.Request)
resp = await main.add_cors(req)
assert resp.status == 200
@pytest.mark.asyncio
async def test_upload_cookies_missing_field(mock_dqueue):
req = MagicMock(spec=web.Request)
reader = MagicMock()
field = MagicMock()
field.name = "wrongname"
reader.next = AsyncMock(side_effect=[field, None])
req.multipart = AsyncMock(return_value=reader)
resp = await main.upload_cookies(req)
assert resp.status == 400
@pytest.mark.asyncio
async def test_add_legacy_format_migrated(mock_dqueue):
req = _json_request({"url": "https://example.com/v", "format": "m4a", "quality": "best"})
resp = await main.add(req)
assert resp.status == 200
call = mock_dqueue.add.await_args
assert call is not None
assert call.args[1] == "audio"
@pytest.mark.asyncio
async def test_add_passes_clip_bounds_to_queue(mock_dqueue):
req = _json_request(
_valid_video_add_body(clip_start="2:26", clip_end="3:24"),
)
resp = await main.add(req)
assert resp.status == 200
call = mock_dqueue.add.await_args
assert call is not None
assert call.args[15] == pytest.approx(146.0)
assert call.args[16] == pytest.approx(204.0)
@pytest.mark.asyncio
async def test_subscribe_passes_clip_bounds(mock_dqueue, monkeypatch):
"""Issue #1049: a subscription's options apply to every future download, and
clip bounds were the one option carved out of that."""
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
req = _json_request(
{
**_valid_video_add_body(clip_start="2:26", clip_end="3:24"),
"check_interval_minutes": 60,
}
)
resp = await main.subscribe(req)
assert resp.status == 200
kwargs = main.submgr.add_subscription.await_args.kwargs
assert kwargs["clip_start"] == pytest.approx(146.0)
assert kwargs["clip_end"] == pytest.approx(204.0)
@pytest.mark.asyncio
async def test_subscribe_passes_sponsorblock(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
req = _json_request(
{**_valid_video_add_body(), "check_interval_minutes": 60, "sponsorblock": True}
)
resp = await main.subscribe(req)
assert resp.status == 200
assert main.submgr.add_subscription.await_args.kwargs["sponsorblock"] is True
@pytest.mark.asyncio
async def test_subscribe_defaults_sponsorblock_off(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
req = _json_request({**_valid_video_add_body(), "check_interval_minutes": 60})
await main.subscribe(req)
assert main.submgr.add_subscription.await_args.kwargs["sponsorblock"] is False
@pytest.mark.asyncio
async def test_subscribe_without_clip_fields_stores_none(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
req = _json_request({**_valid_video_add_body(), "check_interval_minutes": 60})
await main.subscribe(req)
kwargs = main.submgr.add_subscription.await_args.kwargs
assert kwargs["clip_start"] is None
assert kwargs["clip_end"] is None
@pytest.mark.asyncio
async def test_subscribe_ignores_t_param_in_url(mock_dqueue, monkeypatch):
"""A t= timestamp means "start here" for a one-off download of that video.
On a channel or playlist URL it says nothing about the videos it yields, so
it must not silently clip every future download."""
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
body = _valid_video_add_body()
# t= is only honoured on YouTube hosts, so this must be one to exercise it.
body["url"] = "https://www.youtube.com/@somechannel?t=90"
req = _json_request({**body, "check_interval_minutes": 60})
await main.subscribe(req)
kwargs = main.submgr.add_subscription.await_args.kwargs
assert kwargs["clip_start"] is None
assert kwargs["clip_end"] is None
# The timestamp is still stripped from the URL that gets stored.
assert "t=90" not in main.submgr.add_subscription.await_args.args[0]
@pytest.mark.asyncio
async def test_subscribe_explicit_clip_wins_over_t_param(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
body = _valid_video_add_body(clip_start="30")
body["url"] = "https://www.youtube.com/@somechannel?t=90"
req = _json_request({**body, "check_interval_minutes": 60})
await main.subscribe(req)
kwargs = main.submgr.add_subscription.await_args.kwargs
assert kwargs["clip_start"] == pytest.approx(30.0)
@pytest.mark.asyncio
async def test_subscribe_still_rejects_clips_for_non_media(mock_dqueue, monkeypatch):
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock(return_value={"status": "ok"}))
body = _valid_video_add_body(clip_start="10")
body["download_type"] = "thumbnail"
req = _json_request({**body, "check_interval_minutes": 60})
with pytest.raises(web.HTTPBadRequest):
await main.subscribe(req)
@pytest.mark.asyncio
async def test_subscriptions_update_invalid_enabled_returns_error_not_500(mock_dqueue):
req = _json_request({"id": "nonexistent", "enabled": "maybe"})
resp = await main.subscriptions_update(req)
assert resp.status == 200
body = json.loads(resp.text)
assert body["status"] == "error"
@pytest.mark.asyncio
async def test_subscriptions_update_invalid_interval_returns_error_not_500(mock_dqueue):
req = _json_request({"id": "nonexistent", "check_interval_minutes": "abc"})
resp = await main.subscriptions_update(req)
assert resp.status == 200
body = json.loads(resp.text)
assert body["status"] == "error"
@pytest.mark.asyncio
async def test_subscriptions_update_accepts_folder(monkeypatch, mock_dqueue):
"""Issue #1052: folder was absent from the route's accepted fields, so a
folder-only update was rejected outright as having nothing to update."""
submgr = MagicMock()
submgr.update_subscription = AsyncMock(return_value={"status": "ok"})
monkeypatch.setattr(main, "submgr", submgr)
req = _json_request({"id": "abc", "folder": "channels/jane"})
resp = await main.subscriptions_update(req)
assert resp.status == 200
submgr.update_subscription.assert_awaited_once_with("abc", {"folder": "channels/jane"})
@pytest.mark.asyncio
async def test_subscriptions_update_still_drops_unknown_fields(monkeypatch, mock_dqueue):
submgr = MagicMock()
submgr.update_subscription = AsyncMock(return_value={"status": "ok"})
monkeypatch.setattr(main, "submgr", submgr)
req = _json_request({"id": "abc", "seen_ids": ["x"], "url": "https://evil.example"})
with pytest.raises(web.HTTPBadRequest):
await main.subscriptions_update(req)
submgr.update_subscription.assert_not_awaited()
def test_is_within_state_dir_blocks_state_subtree():
state_dir = main._STATE_DIR_REAL
assert main._is_within_state_dir(state_dir)
assert main._is_within_state_dir(os.path.join(state_dir, "cookies.txt"))
assert main._is_within_state_dir(os.path.join(state_dir, "queue", "item.json"))
def test_is_within_state_dir_allows_sibling_downloads():
download_dir = os.path.realpath(main.config.DOWNLOAD_DIR)
assert not main._is_within_state_dir(os.path.join(download_dir, "video.mp4"))
assert not main._is_within_state_dir("/tmp/unrelated/video.mp4")
@pytest.mark.asyncio
async def test_download_blocks_state_dir_files(monkeypatch):
download_dir = Path(main.config.DOWNLOAD_DIR)
state_dir = download_dir / ".metube"
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "cookies.txt").write_text("# Netscape HTTP Cookie File\n", encoding="utf-8")
(download_dir / "video.mp4").write_bytes(b"video")
# request.path is already percent-decoded by aiohttp; state_dir_guard must
# not decode it a second time, or a filename containing a literal '%'
# gets mangled into a false 404.
percent_filename = "100% done.mp4"
(download_dir / percent_filename).write_bytes(b"percent video")
monkeypatch.setattr(main.config, "STATE_DIR", str(state_dir))
monkeypatch.setattr(main, "_STATE_DIR_REAL", os.path.realpath(str(state_dir)))
try:
async with TestClient(TestServer(main.app)) as client:
blocked = await client.get("/download/.metube/cookies.txt")
assert blocked.status == 404
allowed = await client.get("/download/video.mp4")
assert allowed.status == 200
assert await allowed.read() == b"video"
percent_resp = await client.get("/download/" + quote(percent_filename))
assert percent_resp.status == 200
assert await percent_resp.read() == b"percent video"
finally:
(state_dir / "cookies.txt").unlink(missing_ok=True)
(download_dir / "video.mp4").unlink(missing_ok=True)
(download_dir / percent_filename).unlink(missing_ok=True)
state_dir.rmdir()
# --- CORS (issue #155) -------------------------------------------------------
#
# The security property under test: credentials are granted only to an origin
# the operator named explicitly, and never under the '*' wildcard. Each test
# builds a fresh Application because main.app binds to the first event loop
# that runs it; the logic under test lives entirely in main.on_prepare, and the
# real main.add_cors preflight handler is mounted so the preflight path is the
# production one.
async def _cors_version(request):
return web.Response(text="v")
def _cors_app():
app = web.Application()
app.router.add_route("OPTIONS", "/add", main.add_cors)
app.router.add_get("/version", _cors_version)
app.on_response_prepare.append(main.on_prepare)
return app
async def _cors_headers(monkeypatch, origins, origin, path="/add", method="OPTIONS"):
monkeypatch.setattr(main, "_cors_origins", origins)
async with TestClient(TestServer(_cors_app())) as client:
resp = await client.request(
method, path,
headers={
"Origin": origin,
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type,authorization",
},
)
return resp.headers
@pytest.mark.asyncio
async def test_cors_listed_origin_gets_credentials(monkeypatch):
h = await _cors_headers(monkeypatch, ["https://www.youtube.com"], "https://www.youtube.com")
assert h["Access-Control-Allow-Origin"] == "https://www.youtube.com"
assert h["Access-Control-Allow-Credentials"] == "true"
assert "Authorization" in h["Access-Control-Allow-Headers"]
assert "Origin" in h["Vary"]
@pytest.mark.asyncio
async def test_cors_wildcard_never_grants_credentials(monkeypatch):
h = await _cors_headers(monkeypatch, ["*"], "https://evil.example")
# The wildcard still reflects the origin, exactly as before...
assert h["Access-Control-Allow-Origin"] == "https://evil.example"
# ...but must not hand out the user's session, nor let a page attempt
# credentials of its own.
assert "Access-Control-Allow-Credentials" not in h
assert h["Access-Control-Allow-Headers"] == "Content-Type"
@pytest.mark.asyncio
async def test_cors_wildcard_mixed_with_named_origin_still_denies_credentials(monkeypatch):
# '*' anywhere in the list disables credentials for everyone in it, so a
# stray wildcard cannot silently widen a named grant.
h = await _cors_headers(monkeypatch, ["*", "https://www.youtube.com"], "https://www.youtube.com")
assert h["Access-Control-Allow-Origin"] == "https://www.youtube.com"
assert "Access-Control-Allow-Credentials" not in h
assert h["Access-Control-Allow-Headers"] == "Content-Type"
@pytest.mark.asyncio
async def test_cors_unlisted_origin_gets_nothing(monkeypatch):
h = await _cors_headers(monkeypatch, ["https://www.youtube.com"], "https://evil.example")
assert "Access-Control-Allow-Origin" not in h
assert "Access-Control-Allow-Credentials" not in h
@pytest.mark.asyncio
async def test_cors_disabled_by_default(monkeypatch):
h = await _cors_headers(monkeypatch, [], "https://www.youtube.com")
assert "Access-Control-Allow-Origin" not in h
assert "Access-Control-Allow-Credentials" not in h
@pytest.mark.asyncio
async def test_cors_credentials_apply_to_actual_response_not_just_preflight(monkeypatch):
# The browser checks Allow-Credentials on the real response too, so a
# preflight-only grant would still fail.
h = await _cors_headers(
monkeypatch, ["https://www.youtube.com"], "https://www.youtube.com",
path="/version", method="GET")
assert h["Access-Control-Allow-Credentials"] == "true"
@pytest.mark.asyncio
async def test_cors_origin_match_is_exact(monkeypatch):
# Substring or suffix matching here would be a bypass.
for impostor in (
"https://www.youtube.com.evil.example",
"https://evilwww.youtube.com",
"http://www.youtube.com",
"https://www.youtube.com:8443",
):
h = await _cors_headers(monkeypatch, ["https://www.youtube.com"], impostor)
assert "Access-Control-Allow-Origin" not in h, impostor
assert "Access-Control-Allow-Credentials" not in h, impostor
@pytest.mark.asyncio
async def test_cors_null_origin_is_not_trusted(monkeypatch):
# Sandboxed iframes and some file:// contexts send Origin: null.
h = await _cors_headers(monkeypatch, ["https://www.youtube.com"], "null")
assert "Access-Control-Allow-Origin" not in h
assert "Access-Control-Allow-Credentials" not in h
@pytest.mark.asyncio
async def test_cors_vary_appends_to_existing_value(monkeypatch):
# Static responses can already carry a Vary; clobbering it would break
# content negotiation.
monkeypatch.setattr(main, "_cors_origins", ["https://www.youtube.com"])
async def handler(request):
return web.Response(text="x", headers={"Vary": "Accept-Encoding"})
app = web.Application()
app.router.add_get("/v", handler)
app.on_response_prepare.append(main.on_prepare)
async with TestClient(TestServer(app)) as client:
resp = await client.get("/v", headers={"Origin": "https://www.youtube.com"})
assert resp.headers["Vary"] == "Accept-Encoding, Origin"
-55
View File
@@ -1,55 +0,0 @@
"""Tests for the ``bg_tasks.create_task`` strong-reference/logging helper."""
from __future__ import annotations
import asyncio
import logging
import pytest
import bg_tasks
@pytest.mark.asyncio
async def test_create_task_removes_itself_from_registry_on_success():
async def _ok():
return 42
task = bg_tasks.create_task(_ok(), name="ok_task")
assert task in bg_tasks._TASKS
result = await task
assert result == 42
assert task not in bg_tasks._TASKS
@pytest.mark.asyncio
async def test_create_task_logs_unhandled_exception(caplog):
async def _boom():
raise ValueError("kaboom")
with caplog.at_level(logging.ERROR, logger="bg_tasks"):
task = bg_tasks.create_task(_boom(), name="boom_task")
with pytest.raises(ValueError):
await task
# Let the done-callback (scheduled via call_soon) run.
await asyncio.sleep(0)
assert task not in bg_tasks._TASKS
assert any("boom_task" in record.message for record in caplog.records)
@pytest.mark.asyncio
async def test_create_task_does_not_log_on_cancellation(caplog):
async def _sleep_forever():
await asyncio.sleep(10)
with caplog.at_level(logging.ERROR, logger="bg_tasks"):
task = bg_tasks.create_task(_sleep_forever(), name="cancel_task")
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await asyncio.sleep(0)
assert task not in bg_tasks._TASKS
assert not any("cancel_task" in record.message for record in caplog.records)
-299
View File
@@ -1,299 +0,0 @@
"""Tests for ``Config`` (env parsing, yt-dlp options, frontend_safe)."""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from unittest.mock import patch
from main import Config
def _base_env(**overrides: str) -> dict[str, str]:
env = {k: str(v) for k, v in Config._DEFAULTS.items()}
env.update(overrides)
return env
class ConfigTests(unittest.TestCase):
def test_url_prefix_gets_trailing_slash(self):
with patch.dict(os.environ, _base_env(URL_PREFIX="foo"), clear=False):
c = Config()
self.assertEqual(c.URL_PREFIX, "foo/")
def test_public_host_url_gets_trailing_slash(self):
with patch.dict(
os.environ,
_base_env(PUBLIC_HOST_URL="https://ytdl.example.com"),
clear=False,
):
c = Config()
self.assertEqual(c.PUBLIC_HOST_URL, "https://ytdl.example.com/")
def test_public_host_audio_url_gets_trailing_slash(self):
with patch.dict(
os.environ,
_base_env(PUBLIC_HOST_AUDIO_URL="https://audio.example.com"),
clear=False,
):
c = Config()
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "https://audio.example.com/")
def test_public_host_url_empty_stays_empty(self):
with patch.dict(
os.environ,
_base_env(PUBLIC_HOST_URL="", PUBLIC_HOST_AUDIO_URL=""),
clear=False,
):
c = Config()
self.assertEqual(c.PUBLIC_HOST_URL, "")
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "")
def test_host_wildcard_becomes_empty_for_dual_stack(self):
# Regression: aiohttp passes HOST to getaddrinfo, which does not resolve
# '*' -- the server died at startup on a DNS error. '*' now selects the
# empty string, the only value asyncio expands to a listening socket per
# address family.
for raw in ("*", " * "):
with self.subTest(raw=raw):
with patch.dict(os.environ, _base_env(HOST=raw), clear=False):
c = Config()
self.assertEqual(c.HOST, "")
def test_host_literal_addresses_are_untouched(self):
for raw in ("0.0.0.0", "::", "127.0.0.1", ""):
with self.subTest(raw=raw):
with patch.dict(os.environ, _base_env(HOST=raw), clear=False):
c = Config()
self.assertEqual(c.HOST, raw)
def test_blank_audio_host_falls_back_to_audio_download_route(self):
# Regression: a present-but-blank PUBLIC_HOST_AUDIO_URL must not stay empty
# (which produced root-relative, 404ing audio links). It falls back to the
# 'audio_download/' route that serves AUDIO_DOWNLOAD_DIR.
with patch.dict(
os.environ,
_base_env(PUBLIC_HOST_URL="https://ytdl.example.com", PUBLIC_HOST_AUDIO_URL=""),
clear=False,
):
c = Config()
self.assertEqual(c.PUBLIC_HOST_URL, "https://ytdl.example.com/")
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "audio_download/")
def test_public_host_url_already_slashed_unchanged(self):
with patch.dict(
os.environ,
_base_env(
PUBLIC_HOST_URL="https://ytdl.example.com/",
PUBLIC_HOST_AUDIO_URL="https://audio.example.com/",
),
clear=False,
):
c = Config()
self.assertEqual(c.PUBLIC_HOST_URL, "https://ytdl.example.com/")
self.assertEqual(c.PUBLIC_HOST_AUDIO_URL, "https://audio.example.com/")
def test_download_dirs_lose_trailing_slash(self):
# get_custom_dirs strips the base path as a prefix from each subdirectory,
# and the base directory's own path has no trailing slash -- so a trailing
# slash here leaked the absolute path into the folder dropdown.
with patch.dict(os.environ, _base_env(
DOWNLOAD_DIR="/downloads/",
AUDIO_DOWNLOAD_DIR="/audio/",
TEMP_DIR="/tmp/",
STATE_DIR="/state/",
), clear=False):
c = Config()
self.assertEqual(c.DOWNLOAD_DIR, "/downloads")
self.assertEqual(c.AUDIO_DOWNLOAD_DIR, "/audio")
self.assertEqual(c.TEMP_DIR, "/tmp")
self.assertEqual(c.STATE_DIR, "/state")
def test_root_download_dir_survives_normalisation(self):
with patch.dict(os.environ, _base_env(DOWNLOAD_DIR="/", AUDIO_DOWNLOAD_DIR="///"), clear=False):
c = Config()
self.assertEqual(c.DOWNLOAD_DIR, "/")
self.assertEqual(c.AUDIO_DOWNLOAD_DIR, "/")
def test_download_dirs_without_trailing_slash_unchanged(self):
with patch.dict(os.environ, _base_env(DOWNLOAD_DIR="/downloads", AUDIO_DOWNLOAD_DIR="."), clear=False):
c = Config()
self.assertEqual(c.DOWNLOAD_DIR, "/downloads")
self.assertEqual(c.AUDIO_DOWNLOAD_DIR, ".")
def test_ytdl_options_json_loaded(self):
opts = {"quiet": True, "no_warnings": True}
with patch.dict(
os.environ,
_base_env(YTDL_OPTIONS=json.dumps(opts)),
clear=False,
):
c = Config()
self.assertEqual(c.YTDL_OPTIONS["quiet"], True)
def test_ytdl_option_presets_json_loaded(self):
presets = {"Audio extras": {"embed_thumbnail": True}}
with patch.dict(
os.environ,
_base_env(YTDL_OPTIONS_PRESETS=json.dumps(presets)),
clear=False,
):
c = Config()
self.assertEqual(c.YTDL_OPTIONS_PRESETS["Audio extras"]["embed_thumbnail"], True)
def test_invalid_ytdl_options_exits(self):
with patch.dict(os.environ, _base_env(YTDL_OPTIONS="not-json"), clear=False):
with self.assertRaises(SystemExit):
Config()
def test_invalid_boolean_env_exits(self):
with patch.dict(os.environ, _base_env(CUSTOM_DIRS="maybe"), clear=False):
with self.assertRaises(SystemExit):
Config()
def test_frontend_safe_excludes_secrets(self):
with patch.dict(os.environ, _base_env(), clear=False):
c = Config()
safe = c.frontend_safe()
self.assertNotIn("YTDL_OPTIONS", safe)
self.assertNotIn("HOST", safe)
self.assertEqual(safe["ALLOW_YTDL_OPTIONS_OVERRIDES"], False)
def test_default_folder_empty_by_default(self):
with patch.dict(os.environ, _base_env(), clear=False):
c = Config()
self.assertEqual(c.DEFAULT_FOLDER, "")
def test_default_folder_is_trimmed_and_reaches_the_frontend(self):
with patch.dict(os.environ, _base_env(DEFAULT_FOLDER=" /youtube/ "), clear=False):
c = Config()
self.assertEqual(c.DEFAULT_FOLDER, "youtube")
self.assertEqual(c.frontend_safe()["DEFAULT_FOLDER"], "youtube")
def test_default_folder_ignored_without_custom_dirs(self):
# The folder field is not shown at all without CUSTOM_DIRS, and sending
# a folder anyway is rejected by the download path check.
with patch.dict(
os.environ,
_base_env(DEFAULT_FOLDER="youtube", CUSTOM_DIRS="false"),
clear=False,
):
c = Config()
self.assertEqual(c.DEFAULT_FOLDER, "")
def test_allow_ytdl_options_overrides_boolean_loaded(self):
with patch.dict(os.environ, _base_env(ALLOW_YTDL_OPTIONS_OVERRIDES="true"), clear=False):
c = Config()
self.assertTrue(c.ALLOW_YTDL_OPTIONS_OVERRIDES)
def test_ytdl_nightly_update_time_empty_default(self):
with patch.dict(os.environ, _base_env(YTDL_NIGHTLY_UPDATE_TIME=""), clear=False):
c = Config()
self.assertEqual(c.YTDL_NIGHTLY_UPDATE_TIME, "")
def test_ytdl_nightly_update_time_valid(self):
with patch.dict(os.environ, _base_env(YTDL_NIGHTLY_UPDATE_TIME="04:00"), clear=False):
c = Config()
self.assertEqual(c.YTDL_NIGHTLY_UPDATE_TIME, "04:00")
def test_ytdl_nightly_update_time_invalid_exits(self):
for bad in ("25:00", "4am", "12:60"):
with patch.dict(os.environ, _base_env(YTDL_NIGHTLY_UPDATE_TIME=bad), clear=False):
with self.assertRaises(SystemExit):
Config()
def test_invalid_max_concurrent_downloads_exits(self):
for bad in ("0", "-1", "abc"):
with patch.dict(os.environ, _base_env(MAX_CONCURRENT_DOWNLOADS=bad), clear=False):
with self.assertRaises(SystemExit):
Config()
def test_invalid_port_exits(self):
for bad in ("0", "70000", "notaport"):
with patch.dict(os.environ, _base_env(PORT=bad), clear=False):
with self.assertRaises(SystemExit):
Config()
def test_invalid_clear_completed_after_exits(self):
for bad in ("-5", "soon"):
with patch.dict(os.environ, _base_env(CLEAR_COMPLETED_AFTER=bad), clear=False):
with self.assertRaises(SystemExit):
Config()
def test_clear_completed_after_zero_allowed(self):
with patch.dict(os.environ, _base_env(CLEAR_COMPLETED_AFTER="0"), clear=False):
c = Config()
self.assertEqual(c.CLEAR_COMPLETED_AFTER, "0")
def test_invalid_default_option_playlist_item_limit_exits(self):
for bad in ("-1", "many"):
with patch.dict(os.environ, _base_env(DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT=bad), clear=False):
with self.assertRaises(SystemExit):
Config()
def test_default_option_playlist_item_limit_zero_allowed(self):
with patch.dict(os.environ, _base_env(DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT="0"), clear=False):
c = Config()
self.assertEqual(c.DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT, "0")
def test_invalid_subscription_default_check_interval_exits(self):
for bad in ("0", "-1", "often"):
with patch.dict(os.environ, _base_env(SUBSCRIPTION_DEFAULT_CHECK_INTERVAL=bad), clear=False):
with self.assertRaises(SystemExit):
Config()
def test_invalid_subscription_scan_playlist_end_exits(self):
for bad in ("0", "-1", "all"):
with patch.dict(os.environ, _base_env(SUBSCRIPTION_SCAN_PLAYLIST_END=bad), clear=False):
with self.assertRaises(SystemExit):
Config()
def test_invalid_subscription_max_seen_ids_exits(self):
for bad in ("0", "-1", "unlimited"):
with patch.dict(os.environ, _base_env(SUBSCRIPTION_MAX_SEEN_IDS=bad), clear=False):
with self.assertRaises(SystemExit):
Config()
def test_runtime_override_roundtrip(self):
with patch.dict(os.environ, _base_env(), clear=False):
c = Config()
c.set_runtime_override("cookiefile", "/tmp/c.txt")
self.assertEqual(c.YTDL_OPTIONS.get("cookiefile"), "/tmp/c.txt")
c.remove_runtime_override("cookiefile")
self.assertIsNone(c.YTDL_OPTIONS.get("cookiefile"))
def test_ytdl_options_file_merges(self):
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
json.dump({"extractor_args": {"youtube": {"player_client": ["web"]}}}, f)
path = f.name
try:
with patch.dict(
os.environ,
_base_env(YTDL_OPTIONS="{}", YTDL_OPTIONS_FILE=path),
clear=False,
):
c = Config()
self.assertIn("extractor_args", c.YTDL_OPTIONS)
finally:
os.unlink(path)
def test_ytdl_option_presets_file_merges(self):
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
json.dump({"With subtitles": {"writesubtitles": True}}, f)
path = f.name
try:
with patch.dict(
os.environ,
_base_env(YTDL_OPTIONS_PRESETS="{}", YTDL_OPTIONS_PRESETS_FILE=path),
clear=False,
):
c = Config()
self.assertIn("With subtitles", c.YTDL_OPTIONS_PRESETS)
finally:
os.unlink(path)
if __name__ == "__main__":
unittest.main()
-182
View File
@@ -1,182 +0,0 @@
"""Tests for ``app.dl_formats`` format selectors and yt-dlp option mapping."""
from __future__ import annotations
import copy
import unittest
from app.dl_formats import (
_normalize_caption_mode,
_normalize_subtitle_language,
get_format,
get_opts,
merge_ytdl_option_layers,
)
class DlFormatsTests(unittest.TestCase):
def test_audio_unknown_format_raises_value_error(self):
with self.assertRaises(ValueError):
get_format("audio", "auto", "invalid", "best")
def test_wav_does_not_enable_thumbnail_postprocessing(self):
opts = get_opts("audio", "auto", "wav", "best", {})
self.assertNotIn("writethumbnail", opts)
def test_mp3_enables_thumbnail_postprocessing(self):
opts = get_opts("audio", "auto", "mp3", "best", {})
self.assertTrue(opts.get("writethumbnail"))
def test_custom_format_passthrough(self):
self.assertEqual(get_format("video", "auto", "custom:bestvideo+bestaudio", "best"), "bestvideo+bestaudio")
def test_thumbnail_and_captions_format_strings(self):
self.assertEqual(get_format("thumbnail", "auto", "jpg", "best"), "bestaudio/best")
self.assertEqual(get_format("captions", "auto", "srt", "best"), "bestaudio/best")
def test_audio_formats(self):
for fmt in ("m4a", "mp3", "opus", "wav", "flac"):
with self.subTest(fmt=fmt):
self.assertIn(f"ext={fmt}", get_format("audio", "auto", fmt, "best"))
def test_video_unknown_format_raises(self):
with self.assertRaises(ValueError):
get_format("video", "auto", "mkv", "best")
def test_unknown_download_type_raises(self):
with self.assertRaises(ValueError):
get_format("unknown", "auto", "any", "best")
def test_video_any_mp4_ios_with_height_quality(self):
self.assertIn("height<=1080", get_format("video", "auto", "any", "1080"))
self.assertNotIn("height<=", get_format("video", "auto", "any", "best"))
self.assertNotIn("height<=", get_format("video", "auto", "any", "worst"))
def test_video_codec_filters(self):
self.assertIn("h264", get_format("video", "h264", "any", "best"))
self.assertIn("hevc", get_format("video", "h265", "any", "best"))
self.assertIn("av0?1", get_format("video", "av1", "any", "best"))
self.assertIn("vp0?9", get_format("video", "vp9", "any", "best"))
def test_video_mp4_includes_m4a_audio(self):
s = get_format("video", "auto", "mp4", "720")
self.assertIn("[ext=m4a]", s)
def test_video_ios_selector_contains_avc_pattern(self):
s = get_format("video", "auto", "ios", "best")
self.assertIn("h26[45]", s)
def test_get_opts_deepcopy_does_not_mutate_input(self):
base = {"postprocessors": [{"key": "Existing"}]}
orig = copy.deepcopy(base)
get_opts("audio", "auto", "mp3", "best", base)
self.assertEqual(base, orig)
def test_get_opts_audio_m4a_postprocessors(self):
opts = get_opts("audio", "auto", "m4a", "best", {})
keys = [p["key"] for p in opts["postprocessors"]]
self.assertIn("FFmpegExtractAudio", keys)
def test_get_opts_audio_mp3_quality_not_best(self):
opts = get_opts("audio", "auto", "mp3", "192", {})
ext = next(p for p in opts["postprocessors"] if p["key"] == "FFmpegExtractAudio")
self.assertEqual(ext["preferredquality"], "192")
def test_get_opts_thumbnail_skip_download(self):
opts = get_opts("thumbnail", "auto", "jpg", "best", {})
self.assertTrue(opts.get("skip_download"))
self.assertTrue(opts.get("writethumbnail"))
def test_get_opts_captions_manual_only(self):
opts = get_opts(
"captions", "auto", "vtt", "best", {}, subtitle_language="fr", subtitle_mode="manual_only"
)
self.assertTrue(opts.get("writesubtitles"))
self.assertFalse(opts.get("writeautomaticsub"))
self.assertEqual(opts["subtitleslangs"], ["fr"])
def test_get_opts_captions_auto_only(self):
opts = get_opts(
"captions", "auto", "srt", "best", {}, subtitle_language="de", subtitle_mode="auto_only"
)
self.assertFalse(opts.get("writesubtitles"))
self.assertTrue(opts.get("writeautomaticsub"))
self.assertEqual(opts["subtitleslangs"], ["de-orig", "de"])
def test_get_opts_captions_prefer_auto(self):
opts = get_opts(
"captions", "auto", "srt", "best", {}, subtitle_language="es", subtitle_mode="prefer_auto"
)
self.assertTrue(opts.get("writesubtitles"))
self.assertTrue(opts.get("writeautomaticsub"))
self.assertEqual(opts["subtitleslangs"], ["es-orig", "es"])
def test_get_opts_captions_prefer_manual_default_branch(self):
opts = get_opts(
"captions", "auto", "srt", "best", {}, subtitle_language="it", subtitle_mode="prefer_manual"
)
self.assertEqual(opts["subtitleslangs"], ["it", "it-orig"])
def test_get_opts_captions_txt_maps_to_srt_format(self):
opts = get_opts("captions", "auto", "txt", "best", {})
self.assertEqual(opts["subtitlesformat"], "srt/best")
keys = [p["key"] for p in opts["postprocessors"]]
self.assertIn("FFmpegSubtitlesConvertor", keys)
convertor = next(p for p in opts["postprocessors"] if p["key"] == "FFmpegSubtitlesConvertor")
self.assertEqual(convertor["format"], "srt")
def test_get_opts_captions_srt_guarantees_convertor(self):
opts = get_opts("captions", "auto", "srt", "best", {})
self.assertEqual(opts["subtitlesformat"], "srt/best")
keys = [p["key"] for p in opts["postprocessors"]]
self.assertIn("FFmpegSubtitlesConvertor", keys)
def test_get_opts_captions_vtt_guarantees_convertor(self):
opts = get_opts("captions", "auto", "vtt", "best", {})
self.assertEqual(opts["subtitlesformat"], "vtt/best")
keys = [p["key"] for p in opts["postprocessors"]]
self.assertIn("FFmpegSubtitlesConvertor", keys)
convertor = next(p for p in opts["postprocessors"] if p["key"] == "FFmpegSubtitlesConvertor")
self.assertEqual(convertor["format"], "vtt")
def test_get_opts_captions_ttml_has_no_convertor(self):
opts = get_opts("captions", "auto", "ttml", "best", {})
self.assertEqual(opts["subtitlesformat"], "ttml/best")
keys = [p["key"] for p in opts["postprocessors"]]
self.assertNotIn("FFmpegSubtitlesConvertor", keys)
def test_get_opts_merges_existing_postprocessors(self):
opts = get_opts("audio", "auto", "opus", "best", {"postprocessors": [{"key": "SponsorBlock"}]})
keys = [p["key"] for p in opts["postprocessors"]]
self.assertIn("SponsorBlock", keys)
self.assertIn("FFmpegExtractAudio", keys)
def test_normalize_caption_mode_invalid_defaults(self):
self.assertEqual(_normalize_caption_mode(""), "prefer_manual")
self.assertEqual(_normalize_caption_mode("not_a_mode"), "prefer_manual")
def test_normalize_subtitle_language_empty_defaults_en(self):
self.assertEqual(_normalize_subtitle_language(""), "en")
self.assertEqual(_normalize_subtitle_language(" "), "en")
class MergeYtdlOptionLayersTests(unittest.TestCase):
def test_presets_applied_in_order_then_overrides(self):
presets_config = {
"a": {"x": 1, "y": 1},
"b": {"y": 2, "z": 2},
}
merged = merge_ytdl_option_layers(["a", "b"], {"z": 3, "w": 4}, presets_config)
# b overrides a's y; explicit overrides win over presets.
self.assertEqual(merged, {"x": 1, "y": 2, "z": 3, "w": 4})
def test_no_base_options_included(self):
# The helper only produces the preset/override layer, never base opts.
self.assertEqual(merge_ytdl_option_layers(None, None, {}), {})
def test_unknown_preset_names_ignored(self):
self.assertEqual(merge_ytdl_option_layers(["missing"], {"a": 1}, {}), {"a": 1})
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
-352
View File
@@ -1,352 +0,0 @@
"""Tests for pure helpers in ``main`` (legacy API migration, logging, JSON serializer)."""
from __future__ import annotations
import json
import logging
import unittest
import main
class MigrateLegacyRequestTests(unittest.TestCase):
def test_already_new_schema_unchanged(self):
post = {"download_type": "video", "codec": "h264", "format": "mp4", "quality": "1080"}
before = post.copy()
self.assertIs(main._migrate_legacy_request(post), post)
self.assertEqual(post, before)
def test_legacy_audio_m4a(self):
post = {"format": "m4a", "quality": "best"}
main._migrate_legacy_request(post)
self.assertEqual(post["download_type"], "audio")
self.assertEqual(post["codec"], "auto")
self.assertEqual(post["format"], "m4a")
def test_legacy_thumbnail(self):
post = {"format": "thumbnail", "quality": "best"}
main._migrate_legacy_request(post)
self.assertEqual(post["download_type"], "thumbnail")
self.assertEqual(post["format"], "jpg")
self.assertEqual(post["quality"], "best")
def test_legacy_captions_with_subtitle_format(self):
post = {"format": "captions", "subtitle_format": "vtt", "quality": "best"}
main._migrate_legacy_request(post)
self.assertEqual(post["download_type"], "captions")
self.assertEqual(post["format"], "vtt")
def test_legacy_video_best_ios(self):
post = {"format": "any", "quality": "best_ios", "video_codec": "auto"}
main._migrate_legacy_request(post)
self.assertEqual(post["download_type"], "video")
self.assertEqual(post["format"], "ios")
self.assertEqual(post["quality"], "best")
def test_legacy_video_quality_audio_maps_to_m4a(self):
post = {"format": "mp4", "quality": "audio", "video_codec": "h264"}
main._migrate_legacy_request(post)
self.assertEqual(post["download_type"], "audio")
self.assertEqual(post["format"], "m4a")
self.assertEqual(post["quality"], "best")
def test_legacy_video_default(self):
post = {"format": "mp4", "quality": "1080", "video_codec": "h265"}
main._migrate_legacy_request(post)
self.assertEqual(post["download_type"], "video")
self.assertEqual(post["codec"], "h265")
self.assertEqual(post["format"], "mp4")
self.assertEqual(post["quality"], "1080")
class ParseLogLevelTests(unittest.TestCase):
def test_valid_levels(self):
self.assertEqual(main.parseLogLevel("INFO"), logging.INFO)
self.assertEqual(main.parseLogLevel("debug"), logging.DEBUG)
def test_invalid_returns_none(self):
self.assertIsNone(main.parseLogLevel("not_a_level"))
self.assertIsNone(main.parseLogLevel(123))
class ObjectSerializerTests(unittest.TestCase):
def test_dict_like_object(self):
class Obj:
def __init__(self):
self.a = 1
ser = main.ObjectSerializer()
self.assertEqual(json.loads(ser.encode(Obj())), {"a": 1})
def test_generator_becomes_list(self):
ser = main.ObjectSerializer()
def gen():
yield 1
yield 2
self.assertEqual(json.loads(ser.encode(gen())), [1, 2])
def test_string_not_split_to_chars(self):
ser = main.ObjectSerializer()
self.assertEqual(json.loads(ser.encode("hello")), "hello")
class FrontendSafeTests(unittest.TestCase):
def test_only_expected_keys(self):
safe = main.config.frontend_safe()
for key in main.Config._FRONTEND_KEYS:
self.assertIn(key, safe)
self.assertNotIn("YTDL_OPTIONS", safe)
self.assertNotIn("DOWNLOAD_DIR", safe)
self.assertIn("ALLOW_YTDL_OPTIONS_OVERRIDES", safe)
class ParseYtdlOverridesTests(unittest.TestCase):
def test_empty_override_string_returns_empty_dict(self):
self.assertEqual(main._parse_ytdl_options_overrides("", enabled=False), {})
def test_rejects_non_object_json(self):
with self.assertRaises(main.web.HTTPBadRequest):
main._parse_ytdl_options_overrides('["bad"]', enabled=True)
def test_rejects_non_empty_overrides_when_disabled(self):
with self.assertRaises(main.web.HTTPBadRequest):
main._parse_ytdl_options_overrides('{"exec": "rm -rf /"}', enabled=False)
def test_allows_any_keys_when_enabled(self):
self.assertEqual(
main._parse_ytdl_options_overrides('{"exec": "rm -rf /"}', enabled=True),
{"exec": "rm -rf /"},
)
class ParseDownloadOptionsTests(unittest.TestCase):
def test_accepts_known_preset_and_overrides(self):
previous = dict(main.config.YTDL_OPTIONS_PRESETS)
previous_allow = main.config.ALLOW_YTDL_OPTIONS_OVERRIDES
main.config.YTDL_OPTIONS_PRESETS = {"With subtitles": {"writesubtitles": True}}
main.config.ALLOW_YTDL_OPTIONS_OVERRIDES = True
try:
parsed = main.parse_download_options({
"url": "https://example.com/v",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_preset": "With subtitles",
"ytdl_options_overrides": '{"writesubtitles": true}',
})
finally:
main.config.YTDL_OPTIONS_PRESETS = previous
main.config.ALLOW_YTDL_OPTIONS_OVERRIDES = previous_allow
self.assertEqual(parsed["ytdl_options_presets"], ["With subtitles"])
self.assertEqual(parsed["ytdl_options_overrides"], {"writesubtitles": True})
def test_accepts_multiple_presets_in_order(self):
previous = dict(main.config.YTDL_OPTIONS_PRESETS)
main.config.YTDL_OPTIONS_PRESETS = {
"A": {"writesubtitles": True},
"B": {"writesubtitles": False},
}
try:
parsed = main.parse_download_options({
"url": "https://example.com/v",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_presets": ["A", "B"],
})
finally:
main.config.YTDL_OPTIONS_PRESETS = previous
self.assertEqual(parsed["ytdl_options_presets"], ["A", "B"])
def test_legacy_singular_preset_string_normalized_to_list(self):
previous = dict(main.config.YTDL_OPTIONS_PRESETS)
main.config.YTDL_OPTIONS_PRESETS = {"Solo": {}}
try:
parsed = main.parse_download_options({
"url": "https://example.com/v",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_preset": "Solo",
})
finally:
main.config.YTDL_OPTIONS_PRESETS = previous
self.assertEqual(parsed["ytdl_options_presets"], ["Solo"])
def test_rejects_unknown_preset(self):
with self.assertRaises(main.web.HTTPBadRequest):
main.parse_download_options({
"url": "https://example.com/v",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_presets": ["Missing preset"],
})
def test_rejects_unknown_preset_in_list(self):
previous = dict(main.config.YTDL_OPTIONS_PRESETS)
main.config.YTDL_OPTIONS_PRESETS = {"Known": {}}
try:
with self.assertRaises(main.web.HTTPBadRequest):
main.parse_download_options({
"url": "https://example.com/v",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"ytdl_options_presets": ["Known", "Nope"],
})
finally:
main.config.YTDL_OPTIONS_PRESETS = previous
def test_clip_start_end_seconds_and_clock(self):
parsed = main.parse_download_options({
"url": "https://example.com/watch?v=1",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"clip_start": "2:26",
"clip_end": "3:24",
})
self.assertEqual(parsed["clip_start"], 146.0)
self.assertEqual(parsed["clip_end"], 204.0)
def test_clip_url_t_param_strips_query_and_sets_start(self):
parsed = main.parse_download_options({
"url": "https://www.youtube.com/watch?v=1&t=855s",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
})
self.assertEqual(parsed["url"], "https://www.youtube.com/watch?v=1")
self.assertEqual(parsed["clip_start"], 855.0)
self.assertIsNone(parsed["clip_end"])
def test_clip_explicit_start_wins_over_url_t(self):
parsed = main.parse_download_options({
"url": "https://www.youtube.com/watch?v=1&t=100",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"clip_start": "50",
})
self.assertEqual(parsed["url"], "https://www.youtube.com/watch?v=1")
self.assertEqual(parsed["clip_start"], 50.0)
self.assertIsNone(parsed["clip_end"])
def test_clip_end_only_sets_start_zero_and_strips_url_t(self):
parsed = main.parse_download_options({
"url": "https://www.youtube.com/watch?v=1&t=999",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"clip_end": "60",
})
self.assertEqual(parsed["url"], "https://www.youtube.com/watch?v=1")
self.assertEqual(parsed["clip_start"], 0.0)
self.assertEqual(parsed["clip_end"], 60.0)
def test_clip_url_t_param_ignored_on_non_youtube_host(self):
# 't' is a generic query param name; only rewrite it on YouTube hosts
# so an unrelated site's URL isn't silently mutated with a bogus clip.
parsed = main.parse_download_options({
"url": "https://example.com/watch?v=1&t=855s",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
})
self.assertEqual(parsed["url"], "https://example.com/watch?v=1&t=855s")
self.assertIsNone(parsed["clip_start"])
self.assertIsNone(parsed["clip_end"])
def test_extract_t_query_youtu_be_short_host(self):
cleaned, start = main._extract_t_query_from_url("https://youtu.be/abc123?t=90")
self.assertEqual(cleaned, "https://youtu.be/abc123")
self.assertEqual(start, 90.0)
def test_clip_rejects_end_before_start(self):
with self.assertRaises(main.web.HTTPBadRequest):
main.parse_download_options({
"url": "https://example.com/watch?v=1",
"download_type": "video",
"codec": "auto",
"format": "any",
"quality": "best",
"clip_start": "100",
"clip_end": "50",
})
def test_clip_rejected_for_captions(self):
with self.assertRaises(main.web.HTTPBadRequest):
main.parse_download_options({
"url": "https://example.com/watch?v=1",
"download_type": "captions",
"codec": "auto",
"format": "srt",
"quality": "best",
"clip_start": "1",
})
class GetCustomDirsTests(unittest.TestCase):
def test_works_without_a_running_event_loop(self):
# get_custom_dirs() used to time its cache via
# asyncio.get_running_loop().time(), which raises RuntimeError outside
# a running loop (e.g. when called from a plain executor thread). It
# must work from a synchronous context too.
result = main.get_custom_dirs()
self.assertIn("download_dir", result)
self.assertIn("audio_download_dir", result)
self.assertIn("", result["download_dir"])
if __name__ == "__main__":
unittest.main()
class WarnIfCookiefileShadowedTests(unittest.TestCase):
"""Issue #881: an uploaded cookies file wins over an operator-configured
cookiefile, and used to do so with no way for anyone to notice."""
def setUp(self):
self._saved = main.config.YTDL_OPTIONS
main.config.YTDL_OPTIONS = dict(self._saved)
def tearDown(self):
main.config.YTDL_OPTIONS = self._saved
def test_warns_when_a_different_cookiefile_is_configured(self):
main.config.YTDL_OPTIONS["cookiefile"] = "/cookies/cookies.txt"
with self.assertLogs("main", level="WARNING") as cm:
main.warn_if_cookiefile_shadowed()
joined = "\n".join(cm.output)
self.assertIn("/cookies/cookies.txt", joined)
self.assertIn(main.COOKIES_PATH, joined)
def test_silent_when_no_cookiefile_configured(self):
main.config.YTDL_OPTIONS.pop("cookiefile", None)
with self.assertNoLogs("main", level="WARNING"):
main.warn_if_cookiefile_shadowed()
def test_silent_when_configured_file_is_the_uploaded_one(self):
# The steady state after an upload: re-running must not nag.
main.config.YTDL_OPTIONS["cookiefile"] = main.COOKIES_PATH
with self.assertNoLogs("main", level="WARNING"):
main.warn_if_cookiefile_shadowed()
def test_silent_on_non_string_or_empty_values(self):
for value in (None, "", 0, [], {}):
main.config.YTDL_OPTIONS["cookiefile"] = value
with self.assertNoLogs("main", level="WARNING"):
main.warn_if_cookiefile_shadowed()
-119
View File
@@ -1,119 +0,0 @@
"""Tests for conservative audio metadata enrichment."""
from __future__ import annotations
from music_metadata import MusicMetadataPreProcessor
def _preprocess(source_entry, info):
processor = MusicMetadataPreProcessor(source_entry=source_entry)
_, result = processor.run(info)
return result
def test_album_uses_existing_order_and_total_when_track_number_is_missing():
result = _preprocess(
{
'playlist_index': '03',
'playlist_count': 12,
'playlist_title': 'Example Album',
},
{'title': 'Track', 'album': 'Example Album'},
)
assert result['track_number'] == '3/12'
assert result['album'] == 'Example Album'
def test_official_track_number_wins_over_album_order():
result = _preprocess(
{'playlist_index': 3, 'playlist_count': 12},
{'track_number': 7, 'album': 'Official Album'},
)
assert result['track_number'] == '7/12'
assert result['album'] == 'Official Album'
def test_inline_official_track_total_is_preserved():
result = _preprocess(
{'playlist_count': 12},
{'track_number': '4/10', 'album': 'Official Album'},
)
assert result['track_number'] == '4/10'
def test_source_track_number_and_total_are_retained_from_flat_extraction():
result = _preprocess(
{'track_number': 2, 'track_count': 9, 'playlist_index': 4},
{'title': 'Track'},
)
assert result['track_number'] == '2/9'
def test_album_title_falls_back_to_source_playlist_title():
result = _preprocess(
{'playlist_title': 'Example Album'},
{'track_number': 4},
)
assert result['album'] == 'Example Album'
assert result['track_number'] == 4
def test_playlist_without_extractor_album_signals_is_not_changed():
result = _preprocess(
{
'playlist_index': 3,
'playlist_count': 12,
'playlist_title': 'Example Playlist',
},
{'title': 'Track'},
)
assert 'album' not in result
assert 'track_number' not in result
def test_regular_video_artwork_is_not_changed():
thumbnails = [
{'url': 'square.jpg', 'width': 500, 'height': 500},
{'url': 'landscape.jpg', 'width': 1280, 'height': 720},
]
result = _preprocess({}, {'title': 'Regular Video', 'thumbnails': thumbnails.copy()})
assert result['thumbnails'] == thumbnails
assert 'thumbnail' not in result
def test_music_audio_prefers_largest_existing_square_thumbnail():
result = _preprocess(
{},
{
'track': 'Track',
'thumbnails': [
{'url': 'small-square.jpg', 'width': 200, 'height': 200},
{'url': 'large-square.jpg', 'width': 1000, 'height': 1000},
{'url': 'landscape.jpg', 'width': 1280, 'height': 720},
],
},
)
assert result['thumbnails'][-1]['url'] == 'large-square.jpg'
assert result['thumbnail'] == 'large-square.jpg'
def test_landscape_only_music_artwork_keeps_existing_order():
thumbnails = [
{'url': 'small.jpg', 'width': 640, 'height': 360},
{'url': 'large.jpg', 'width': 1280, 'height': 720},
]
result = _preprocess(
{},
{'track': 'Track', 'thumbnails': thumbnails.copy()},
)
assert result['thumbnails'] == thumbnails
assert 'thumbnail' not in result
-29
View File
@@ -1,29 +0,0 @@
"""Tests for nightly yt-dlp update scheduling helpers."""
from __future__ import annotations
import unittest
from datetime import datetime
from main import seconds_until_next_daily_time
class NightlyUpdateTests(unittest.TestCase):
def test_seconds_until_later_today(self):
now = datetime(2026, 6, 4, 10, 0, 0)
delay = seconds_until_next_daily_time("15:30", now)
self.assertEqual(delay, 5 * 3600 + 30 * 60)
def test_seconds_until_wraps_to_next_day(self):
now = datetime(2026, 6, 4, 18, 0, 0)
delay = seconds_until_next_daily_time("04:00", now)
self.assertEqual(delay, 10 * 3600)
def test_seconds_until_same_minute_is_next_day(self):
now = datetime(2026, 6, 4, 4, 0, 30)
delay = seconds_until_next_daily_time("04:00", now)
self.assertAlmostEqual(delay, 24 * 3600 - 30, delta=1)
if __name__ == "__main__":
unittest.main()
-364
View File
@@ -1,364 +0,0 @@
"""Integration tests for ``PersistentQueue`` using the JSON state store."""
from __future__ import annotations
import asyncio
import json
import os
import threading
import time
import shelve
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
fake_yt_dlp = types.ModuleType("yt_dlp")
fake_networking = types.ModuleType("yt_dlp.networking")
fake_impersonate = types.ModuleType("yt_dlp.networking.impersonate")
fake_utils = types.ModuleType("yt_dlp.utils")
class _ImpersonateTarget:
@staticmethod
def from_str(value):
return value
fake_impersonate.ImpersonateTarget = _ImpersonateTarget
fake_networking.impersonate = fake_impersonate
fake_utils.STR_FORMAT_RE_TMPL = r"(?P<prefix>)%\((?P<has_key>{})\)(?P<format>[-0-9.]*{})"
fake_utils.STR_FORMAT_TYPES = "diouxXeEfFgGcrsa"
fake_yt_dlp.networking = fake_networking
fake_yt_dlp.utils = fake_utils
sys.modules.setdefault("yt_dlp", fake_yt_dlp)
sys.modules.setdefault("yt_dlp.networking", fake_networking)
sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate)
sys.modules.setdefault("yt_dlp.utils", fake_utils)
from ytdl import DownloadInfo, PersistentQueue
class _FakeDownload:
__slots__ = ("info",)
def __init__(self, info: DownloadInfo):
self.info = info
def _make_info(url: str = "https://example.com/v") -> DownloadInfo:
return DownloadInfo(
id="id1",
title="Title",
url=url,
quality="best",
download_type="video",
codec="auto",
format="any",
folder="",
custom_name_prefix="",
error=None,
entry=None,
playlist_item_limit=0,
split_by_chapters=False,
chapter_template="",
)
def _create_legacy_shelf(path: str, *infos: DownloadInfo) -> None:
with shelve.open(path, "c") as shelf:
for info in infos:
shelf[info.url] = info
class PersistentQueueTests(unittest.IsolatedAsyncioTestCase):
async def test_put_get_delete_roundtrip(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
pq = PersistentQueue("queue", path)
dl = _FakeDownload(_make_info("http://a.example"))
await pq.put(dl)
self.assertTrue(os.path.exists(path + ".json"))
self.assertTrue(pq.exists("http://a.example"))
self.assertFalse(pq.empty())
got = pq.get("http://a.example")
self.assertEqual(got.info.url, "http://a.example")
await pq.delete("http://a.example")
self.assertFalse(pq.exists("http://a.example"))
async def test_saved_items_sorted_by_timestamp(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
pq = PersistentQueue("queue", path)
a = _FakeDownload(_make_info("http://first.example"))
b = _FakeDownload(_make_info("http://second.example"))
a.info.timestamp = 100
b.info.timestamp = 200
await pq.put(a)
await pq.put(b)
keys = [k for k, _ in pq.saved_items()]
self.assertEqual(keys, ["http://first.example", "http://second.example"])
async def test_load_restores_from_json(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
pq1 = PersistentQueue("queue", path)
await pq1.put(_FakeDownload(_make_info("http://load.example")))
pq2 = PersistentQueue("queue", path)
pq2.load()
self.assertTrue(pq2.exists("http://load.example"))
def test_load_imports_legacy_shelve(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
_create_legacy_shelf(path, _make_info("http://legacy.example"))
pq = PersistentQueue("queue", path)
pq.load()
self.assertTrue(pq.exists("http://legacy.example"))
self.assertTrue(os.path.exists(path + ".json"))
async def test_queue_persists_only_compact_entry_subset(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
pq = PersistentQueue("queue", path)
info = _make_info("http://entry.example")
info.entry = {
"playlist_index": "01",
"playlist_title": "Playlist",
"channel_index": "02",
"channel_title": "Channel",
"formats": [{"id": "huge"}],
"description": "very large payload",
}
await pq.put(_FakeDownload(info))
with open(path + ".json", encoding="utf-8") as f:
payload = json.load(f)
record = payload["items"][0]["info"]
self.assertEqual(
record["entry"],
{
"playlist_index": "01",
"playlist_title": "Playlist",
"channel_index": "02",
"channel_title": "Channel",
},
)
self.assertNotIn("formats", record["entry"])
self.assertNotIn("description", record["entry"])
async def test_completed_queue_persists_only_failed_retry_context(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "completed")
pq = PersistentQueue("completed", path)
info = _make_info("http://done.example")
info.status = "error"
info.percent = 88
info.speed = 123
info.eta = 9
info.entry = {
"playlist_index": "01",
"playlist_title": "Playlist",
"formats": [{"id": "huge"}],
}
info.filename = "done.mp4"
await pq.put(_FakeDownload(info))
with open(path + ".json", encoding="utf-8") as f:
payload = json.load(f)
record = payload["items"][0]["info"]
self.assertEqual(
record["entry"],
{
"playlist_index": "01",
"playlist_title": "Playlist",
},
)
self.assertNotIn("percent", record)
self.assertNotIn("speed", record)
self.assertNotIn("eta", record)
self.assertEqual(record["filename"], "done.mp4")
info.status = "finished"
await pq.put(_FakeDownload(info))
with open(path + ".json", encoding="utf-8") as f:
payload = json.load(f)
self.assertNotIn("entry", payload["items"][0]["info"])
def test_invalid_json_is_quarantined_and_legacy_is_imported(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
_create_legacy_shelf(path, _make_info("http://legacy.example"))
with open(path + ".json", "w", encoding="utf-8") as f:
f.write("{not valid json")
pq = PersistentQueue("queue", path)
pq.load()
self.assertTrue(pq.exists("http://legacy.example"))
self.assertTrue(
any(name.startswith("queue.json.invalid.") for name in os.listdir(tmp))
)
def test_loading_old_json_rewrites_to_compact_format(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
with open(path + ".json", "w", encoding="utf-8") as f:
json.dump(
{
"schema_version": 1,
"kind": "persistent_queue:queue",
"items": [
{
"key": "http://legacy-json.example",
"info": {
"id": "id1",
"title": "Title",
"url": "http://legacy-json.example",
"quality": "best",
"download_type": "video",
"codec": "auto",
"format": "any",
"folder": "",
"custom_name_prefix": "",
"playlist_item_limit": 0,
"split_by_chapters": False,
"chapter_template": "",
"subtitle_language": "en",
"subtitle_mode": "prefer_manual",
"status": "pending",
"timestamp": 1,
"entry": {
"playlist_index": "01",
"playlist_title": "Playlist",
"formats": [{"id": "huge"}],
},
"percent": 15,
"speed": 20,
"eta": 30,
},
}
],
},
f,
)
pq = PersistentQueue("queue", path)
pq.load()
with open(path + ".json", encoding="utf-8") as f:
payload = json.load(f)
record = payload["items"][0]["info"]
self.assertEqual(payload["schema_version"], 2)
self.assertEqual(record["entry"], {"playlist_index": "01", "playlist_title": "Playlist"})
self.assertNotIn("percent", record)
self.assertNotIn("speed", record)
self.assertNotIn("eta", record)
async def test_put_rollbacks_in_memory_queue_when_state_write_fails(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
pq = PersistentQueue("queue", path)
dl = _FakeDownload(_make_info("http://rollback.example"))
self.assertFalse(pq.exists("http://rollback.example"))
orig_save = __import__("state_store").AtomicJsonStore.save
def bad_save(store, data):
if store.path == path + ".json":
raise OSError("simulated shelf failure")
return orig_save(store, data)
with patch("ytdl.AtomicJsonStore.save", bad_save):
with self.assertRaises(OSError):
await pq.put(dl)
self.assertFalse(pq.exists("http://rollback.example"))
async def test_put_rollbacks_to_previous_download_when_replace_fails(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue")
pq = PersistentQueue("queue", path)
first = _FakeDownload(_make_info("http://same.example"))
second = _FakeDownload(_make_info("http://same.example"))
second.info.title = "Replaced title"
await pq.put(first)
orig_save = __import__("state_store").AtomicJsonStore.save
def bad_save(store, data):
if store.path == path + ".json":
raise OSError("simulated shelf failure")
return orig_save(store, data)
with patch("ytdl.AtomicJsonStore.save", bad_save):
with self.assertRaises(OSError):
await pq.put(second)
self.assertEqual(pq.get("http://same.example").info.title, "Title")
class StateWriteOffEventLoopTests(unittest.IsolatedAsyncioTestCase):
"""State writes fsync twice; on a slow disk that must not stall the loop.
Before this, put()/delete() wrote inline, so a queue mutation blocked every
other request the server was serving for as long as the filesystem took.
See issue #980.
"""
async def test_save_runs_off_the_event_loop_thread(self):
with tempfile.TemporaryDirectory() as tmp:
pq = PersistentQueue("queue", os.path.join(tmp, "queue"))
self.addCleanup(pq.close)
loop_thread = threading.get_ident()
save_threads = []
orig_save = __import__("state_store").AtomicJsonStore.save
def recording_save(store, data):
save_threads.append(threading.get_ident())
return orig_save(store, data)
with patch("ytdl.AtomicJsonStore.save", recording_save):
await pq.put(_FakeDownload(_make_info("http://a.example")))
self.assertEqual(len(save_threads), 1)
self.assertNotEqual(save_threads[0], loop_thread)
async def test_a_slow_write_does_not_stall_other_coroutines(self):
with tempfile.TemporaryDirectory() as tmp:
pq = PersistentQueue("queue", os.path.join(tmp, "queue"))
self.addCleanup(pq.close)
orig_save = __import__("state_store").AtomicJsonStore.save
def slow_save(store, data):
time.sleep(0.3)
return orig_save(store, data)
ticks = 0
async def ticker():
nonlocal ticks
while True:
await asyncio.sleep(0.01)
ticks += 1
ticking = asyncio.create_task(ticker())
try:
with patch("ytdl.AtomicJsonStore.save", slow_save):
await pq.put(_FakeDownload(_make_info("http://a.example")))
finally:
ticking.cancel()
# An inline write would have starved the loop for the whole 0.3s and
# left ticks at 0.
self.assertGreater(ticks, 5)
self.assertTrue(pq.exists("http://a.example"))
if __name__ == "__main__":
unittest.main()
-183
View File
@@ -1,183 +0,0 @@
from __future__ import annotations
import os
import tempfile
import unittest
from datetime import datetime
from unittest.mock import patch
from state_store import AtomicJsonStore, from_json_compatible, to_json_compatible
class StateStoreTests(unittest.TestCase):
def test_save_and_load_roundtrip(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
store = AtomicJsonStore(path, kind="persistent_queue:queue")
store.save({"items": [{"key": "a", "info": {"title": "hello"}}]})
payload = store.load()
self.assertEqual(payload["kind"], "persistent_queue:queue")
self.assertEqual(payload["schema_version"], 2)
self.assertEqual(payload["items"][0]["info"]["title"], "hello")
def test_save_falls_back_to_direct_write_when_mkstemp_fails(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
store = AtomicJsonStore(path, kind="persistent_queue:queue")
with self.assertLogs("state_store", level="WARNING") as logs:
with patch(
"state_store.tempfile.mkstemp",
side_effect=PermissionError(1, "Operation not permitted"),
):
store.save({"items": [{"key": "a"}]})
self.assertTrue(os.path.exists(path))
self.assertTrue(any(path in message for message in logs.output))
# Fallback keeps owner-only permissions, matching the atomic path.
self.assertEqual(os.stat(path).st_mode & 0o777, 0o600)
payload = store.load()
self.assertEqual(payload["items"], [{"key": "a"}])
def test_fallback_tightens_permissions_on_existing_file(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
with open(path, "w", encoding="utf-8") as f:
f.write("{}")
os.chmod(path, 0o644)
store = AtomicJsonStore(path, kind="persistent_queue:queue")
with patch(
"state_store.tempfile.mkstemp",
side_effect=PermissionError(1, "Operation not permitted"),
):
store.save({"items": [{"key": "a"}]})
self.assertEqual(os.stat(path).st_mode & 0o777, 0o600)
self.assertEqual(store.load()["items"], [{"key": "a"}])
def test_save_falls_back_to_direct_write_when_replace_fails(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
store = AtomicJsonStore(path, kind="persistent_queue:queue")
with patch(
"state_store.os.replace",
side_effect=PermissionError(1, "Operation not permitted"),
):
store.save({"items": [{"key": "a"}]})
self.assertTrue(os.path.exists(path))
payload = store.load()
self.assertEqual(payload["items"], [{"key": "a"}])
self.assertEqual([], [name for name in os.listdir(tmp) if name.endswith(".tmp")])
def test_save_reraises_when_atomic_and_direct_write_fail(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
store = AtomicJsonStore(path, kind="persistent_queue:queue")
with patch(
"state_store.tempfile.mkstemp",
side_effect=PermissionError(1, "Operation not permitted"),
):
with patch(
"state_store.os.open",
side_effect=PermissionError(13, "Permission denied"),
):
with self.assertRaises(PermissionError) as ctx:
store.save({"items": [{"key": "a"}]})
self.assertEqual(ctx.exception.errno, 13)
self.assertFalse(os.path.exists(path))
def test_unsupported_fsync_keeps_atomic_path(self):
# fsync being unsupported (EINVAL/ENOSYS) must not by itself trigger the
# direct-write fallback; the atomic temp-file + rename path still runs.
import errno as _errno
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
store = AtomicJsonStore(path, kind="persistent_queue:queue")
with patch(
"state_store.os.fsync",
side_effect=OSError(_errno.EINVAL, "Invalid argument"),
):
with self.assertNoLogs("state_store", level="WARNING"):
store.save({"items": [{"key": "a"}]})
self.assertEqual(store.load()["items"], [{"key": "a"}])
self.assertEqual([], [name for name in os.listdir(tmp) if name.endswith(".tmp")])
def test_save_reraises_and_preserves_state_on_non_atomic_errno(self):
# A storage failure such as ENOSPC is not an "atomic unavailable"
# signal, so it must surface instead of falling back to a direct write
# that would truncate the existing good state file.
import errno as _errno
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
store = AtomicJsonStore(path, kind="persistent_queue:queue")
store.save({"items": [{"key": "good"}]})
with patch(
"state_store.tempfile.mkstemp",
side_effect=OSError(_errno.ENOSPC, "No space left on device"),
):
with self.assertRaises(OSError) as ctx:
store.save({"items": [{"key": "new"}]})
self.assertEqual(ctx.exception.errno, _errno.ENOSPC)
# Existing state is untouched.
self.assertEqual(store.load()["items"], [{"key": "good"}])
def test_serialization_failure_preserves_existing_state(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
store = AtomicJsonStore(path, kind="persistent_queue:queue")
store.save({"items": [{"key": "good"}]})
# Even on the fallback path, a non-serializable payload must raise
# before the existing good state file is touched.
with patch(
"state_store.tempfile.mkstemp",
side_effect=PermissionError(1, "Operation not permitted"),
):
with self.assertRaises(TypeError):
store.save({"items": object()})
self.assertEqual(store.load()["items"], [{"key": "good"}])
def test_invalid_file_is_quarantined(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
with open(path, "w", encoding="utf-8") as f:
f.write("{broken")
store = AtomicJsonStore(path, kind="persistent_queue:queue")
payload = store.load()
self.assertIsNone(payload)
self.assertTrue(
any(name.startswith("queue.json.invalid.") for name in os.listdir(tmp))
)
def test_json_compat_helpers_roundtrip_bytes_and_datetime(self):
raw = {
"payload": b"abc",
"timestamp": datetime(2024, 1, 2, 3, 4, 5),
"items": (1, 2, 3),
}
restored = from_json_compatible(to_json_compatible(raw))
self.assertEqual(restored["payload"], b"abc")
self.assertEqual(restored["timestamp"], datetime(2024, 1, 2, 3, 4, 5))
self.assertEqual(restored["items"], [1, 2, 3])
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
-495
View File
@@ -1,495 +0,0 @@
"""Tests for the SSRF URL guard (``url_guard.validate_url``)."""
from __future__ import annotations
import socket
import unittest
from unittest import mock
import url_guard
from url_guard import (
validate_url,
_address_allowed_at_connect,
_address_is_global,
_guarded_getaddrinfo,
_url_endpoint,
download_proxies,
install_socket_guard,
)
def _addrinfo(*addrs, family=socket.AF_INET):
return [(family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (addr, 0)) for addr in addrs]
class NonUrlInputTests(unittest.TestCase):
"""Bare IDs and yt-dlp search/extractor prefixes must pass untouched."""
def test_bare_video_id_allowed(self):
self.assertIsNone(validate_url("dQw4w9WgXcQ"))
def test_ytsearch_prefix_allowed(self):
self.assertIsNone(validate_url("ytsearch:some song"))
def test_empty_string_allowed(self):
self.assertIsNone(validate_url(""))
def test_non_string_rejected(self):
self.assertIsNotNone(validate_url(None))
class SchemeTests(unittest.TestCase):
def test_file_scheme_blocked(self):
self.assertIsNotNone(validate_url("file:///etc/passwd"))
def test_ftp_scheme_blocked(self):
self.assertIsNotNone(validate_url("ftp://example.com/x"))
def test_data_scheme_blocked(self):
self.assertIsNotNone(validate_url("data://text/plain;base64,AAAA"))
class HostnameBlocklistTests(unittest.TestCase):
def test_localhost_blocked_without_lookup(self):
with mock.patch("url_guard.socket.getaddrinfo") as gai:
self.assertIsNotNone(validate_url("http://localhost:8080/x"))
gai.assert_not_called()
def test_localhost_subdomain_blocked(self):
self.assertIsNotNone(validate_url("http://foo.localhost/x"))
def test_gcp_metadata_name_blocked(self):
self.assertIsNotNone(validate_url("http://metadata.google.internal/x"))
class AddressResolutionTests(unittest.TestCase):
def _validate_with_addrs(self, url, *addrs, family=socket.AF_INET):
with mock.patch("url_guard.socket.getaddrinfo", return_value=_addrinfo(*addrs, family=family)):
return validate_url(url)
def test_public_https_allowed(self):
self.assertIsNone(self._validate_with_addrs("https://youtube.com/watch?v=x", "142.250.1.1"))
def test_public_http_allowed(self):
self.assertIsNone(self._validate_with_addrs("http://example.com/x", "93.184.216.34"))
def test_link_local_metadata_blocked(self):
self.assertIsNotNone(self._validate_with_addrs("http://metadata/x", "169.254.169.254"))
def test_loopback_ipv4_blocked(self):
self.assertIsNotNone(self._validate_with_addrs("http://127.0.0.1/x", "127.0.0.1"))
def test_private_rfc1918_blocked(self):
self.assertIsNotNone(self._validate_with_addrs("http://intranet/x", "10.0.0.5"))
def test_decimal_ip_form_blocked(self):
# 2852039166 == 169.254.169.254; the OS resolver normalizes it.
self.assertIsNotNone(self._validate_with_addrs("http://2852039166/x", "169.254.169.254"))
def test_ipv6_loopback_blocked(self):
self.assertIsNotNone(
self._validate_with_addrs("http://[::1]/x", "::1", family=socket.AF_INET6)
)
def test_ipv4_mapped_ipv6_metadata_blocked(self):
self.assertIsNotNone(
self._validate_with_addrs(
"http://evil/x", "::ffff:169.254.169.254", family=socket.AF_INET6
)
)
def test_mixed_public_and_private_blocked(self):
# If any resolved address is internal, reject the whole URL.
self.assertIsNotNone(self._validate_with_addrs("http://mixed/x", "142.250.1.1", "127.0.0.1"))
def test_resolution_failure_is_rejected(self):
# Fail closed: an unresolvable host cannot be verified as non-internal.
with mock.patch("url_guard.socket.getaddrinfo", side_effect=socket.gaierror):
self.assertIsNotNone(validate_url("http://does-not-resolve.example/x"))
class ConnectAddressPolicyTests(unittest.TestCase):
"""Connect-time policy: allow global, plus anything at a destination the
caller has established is the operator's configured proxy."""
def test_global_allowed(self):
self.assertTrue(_address_allowed_at_connect("142.250.1.1"))
def test_loopback_blocked_by_default(self):
# A blanket loopback allowance is what let manifest-derived media URLs
# reach services on the server's own loopback interface.
self.assertFalse(_address_allowed_at_connect("127.0.0.1"))
self.assertFalse(_address_allowed_at_connect("::1"))
def test_loopback_allowed_only_when_opted_in(self):
self.assertTrue(_address_allowed_at_connect("127.0.0.1", is_allowed_endpoint=True))
self.assertTrue(_address_allowed_at_connect("::1", is_allowed_endpoint=True))
def test_proxy_opt_in_covers_any_internal_range(self):
# A proxy is just as legitimately on the LAN or a VPN range as on
# loopback (#1055): the allowance follows the operator's configured
# endpoint, not a particular address family.
self.assertTrue(_address_allowed_at_connect("10.1.20.30", is_allowed_endpoint=True))
self.assertTrue(_address_allowed_at_connect("192.168.1.10", is_allowed_endpoint=True))
self.assertTrue(_address_allowed_at_connect("fd00::1", is_allowed_endpoint=True))
def test_opt_in_still_rejects_non_addresses(self):
self.assertFalse(_address_allowed_at_connect("not-an-ip", is_allowed_endpoint=True))
def test_link_local_metadata_blocked(self):
self.assertFalse(_address_allowed_at_connect("169.254.169.254"))
def test_private_blocked(self):
self.assertFalse(_address_allowed_at_connect("10.0.0.5"))
self.assertFalse(_address_allowed_at_connect("192.168.1.10"))
def test_ipv4_mapped_metadata_blocked(self):
self.assertFalse(_address_allowed_at_connect("::ffff:169.254.169.254"))
class TunnelledIPv4Tests(unittest.TestCase):
"""IPv6 transition forms that carry an IPv4 address the outer address hides.
``is_global`` looks only at the outer address, so a form that tunnels an
internal IPv4 has to be unwrapped before it is judged (GHSA-5mq5-qr7m-f4wx).
"""
def test_nat64_well_known_prefix_blocked(self):
# 2000::/3 global unicast on its face; carries the metadata address.
self.assertFalse(_address_is_global("64:ff9b::a9fe:a9fe"))
self.assertFalse(_address_is_global("64:ff9b::7f00:1"))
self.assertFalse(_address_allowed_at_connect("64:ff9b::a9fe:a9fe"))
def test_nat64_carrying_a_public_address_allowed(self):
self.assertTrue(_address_is_global("64:ff9b::8.8.8.8"))
def test_ipv4_compatible_form_blocked(self):
# The deprecated ::/96 form, likewise global-looking to is_global.
self.assertFalse(_address_is_global("::a9fe:a9fe"))
self.assertFalse(_address_allowed_at_connect("::a9fe:a9fe"))
def test_sixtofour_and_teredo_stay_blocked(self):
# Python rejects these ranges wholesale. Unwrapping must not promote a
# blocked address to an allowed one just because the payload is global.
self.assertFalse(_address_is_global("2002:a9fe:a9fe::"))
self.assertFalse(_address_is_global("2002:0808:0808::"))
self.assertFalse(_address_is_global("2001:0:4136:e378:8000:63bf:3fff:fdd2"))
def test_plain_addresses_unaffected(self):
self.assertTrue(_address_is_global("142.250.1.1"))
self.assertTrue(_address_is_global("2607:f8b0:4004:c07::64"))
self.assertFalse(_address_is_global("not-an-ip"))
def test_tunnelled_form_blocked_at_ingress(self):
with mock.patch(
"url_guard.socket.getaddrinfo",
return_value=_addrinfo("64:ff9b::a9fe:a9fe", family=socket.AF_INET6),
):
self.assertIsNotNone(validate_url("http://nat64.example/x"))
class EndpointParsingTests(unittest.TestCase):
def test_explicit_port(self):
self.assertEqual(_url_endpoint("http://127.0.0.1:9050"), ("127.0.0.1", 9050))
def test_default_port_per_scheme(self):
self.assertEqual(_url_endpoint("socks5://127.0.0.1"), ("127.0.0.1", 1080))
self.assertEqual(_url_endpoint("http://127.0.0.1"), ("127.0.0.1", 80))
def test_bare_host_port(self):
self.assertEqual(_url_endpoint("127.0.0.1:8080"), ("127.0.0.1", 8080))
def test_hostname_lowercased(self):
self.assertEqual(_url_endpoint("http://LocalHost.:9050"), ("localhost", 9050))
def test_ipv6_literal(self):
self.assertEqual(_url_endpoint("http://[::1]:9050"), ("::1", 9050))
def test_empty_and_invalid(self):
self.assertIsNone(_url_endpoint(""))
self.assertIsNone(_url_endpoint(" "))
self.assertIsNone(_url_endpoint(None))
self.assertIsNone(_url_endpoint("http://"))
class GuardedGetaddrinfoTests(unittest.TestCase):
def setUp(self):
# Default state: no proxy configured, so no loopback destination allowed.
saved = set(url_guard._allowed_endpoints)
url_guard._allowed_endpoints = set()
self.addCleanup(lambda: setattr(url_guard, "_allowed_endpoints", saved))
def test_internal_only_raises(self):
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("169.254.169.254")):
with self.assertRaises(socket.gaierror):
_guarded_getaddrinfo("metadata", 80)
def test_filters_internal_keeps_global(self):
# Split-horizon rebinding: keep the public address, drop the internal one.
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("142.250.1.1", "10.0.0.1")):
results = _guarded_getaddrinfo("mixed", 80)
self.assertEqual([r[4][0] for r in results], ["142.250.1.1"])
def test_loopback_blocked_without_matching_proxy(self):
# The advisory case: an m3u8 segment URL pointing at a loopback service.
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
with self.assertRaises(socket.gaierror):
_guarded_getaddrinfo("127.0.0.1", 9999)
def test_loopback_allowed_at_configured_url_endpoint(self):
url_guard._allowed_endpoints = {("127.0.0.1", 9050)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
results = _guarded_getaddrinfo("127.0.0.1", 9050)
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
def test_loopback_blocked_at_other_port_on_proxy_host(self):
# Same host as the proxy, different port: still off limits.
url_guard._allowed_endpoints = {("127.0.0.1", 9050)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
with self.assertRaises(socket.gaierror):
_guarded_getaddrinfo("127.0.0.1", 9999)
def test_proxy_reachable_by_hostname(self):
url_guard._allowed_endpoints = {("localhost", 9050)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
results = _guarded_getaddrinfo("localhost", 9050)
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
def test_string_port_is_normalised(self):
url_guard._allowed_endpoints = {("127.0.0.1", 9050)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
results = _guarded_getaddrinfo("127.0.0.1", "9050")
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
def test_lan_proxy_reachable(self):
# #1055: a socks5 proxy on the LAN, refused while the allowance was
# loopback-only, which pushed operators to ALLOW_PRIVATE_ADDRESSES.
url_guard._allowed_endpoints = {("10.1.20.30", 1080)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")):
results = _guarded_getaddrinfo("10.1.20.30", 1080)
self.assertEqual([r[4][0] for r in results], ["10.1.20.30"])
def test_other_lan_host_still_blocked(self):
# The allowance is the proxy's endpoint, not its subnet.
url_guard._allowed_endpoints = {("10.1.20.30", 1080)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.31")):
with self.assertRaises(socket.gaierror):
_guarded_getaddrinfo("10.1.20.31", 1080)
def test_pot_provider_reachable_on_loopback(self):
# #1064: the bundled PO token provider listens on loopback, and blocking
# it left every default install downloading YouTube without a token.
url_guard._allowed_endpoints = {("127.0.0.1", 4416)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
results = _guarded_getaddrinfo("127.0.0.1", 4416)
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
def test_other_loopback_service_still_blocked(self):
# MeTube's own port is one hop away on the same interface: allowing the
# token provider must not allow the rest of loopback.
url_guard._allowed_endpoints = {("127.0.0.1", 4416)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
with self.assertRaises(socket.gaierror):
_guarded_getaddrinfo("127.0.0.1", 8081)
def test_proxy_address_not_borrowable_by_another_host(self):
# Matching is on the configured host string: a manifest URL that resolves
# to the proxy's address under its own name gets no allowance.
url_guard._allowed_endpoints = {("10.1.20.30", 1080)}
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")):
with self.assertRaises(socket.gaierror):
_guarded_getaddrinfo("evil.example", 1080)
class DownloadProxiesTests(unittest.TestCase):
"""The proxy map is assembled the way YoutubeDL.proxies assembles it."""
def test_explicit_proxy_option_replaces_environment(self):
with mock.patch("url_guard.urllib.request.getproxies",
return_value={"http": "http://env:3128", "no": "example.com"}):
self.assertEqual(download_proxies({"proxy": "socks5h://tor:9050"}),
{"all": "socks5h://tor:9050"})
def test_empty_proxy_option_means_no_proxy(self):
self.assertEqual(download_proxies({"proxy": ""}), {"all": "__noproxy__"})
def test_environment_used_when_no_proxy_option(self):
with mock.patch("url_guard.urllib.request.getproxies",
return_value={"http": "http://env:3128"}):
# http_proxy alone also covers https, as in yt-dlp.
self.assertEqual(download_proxies({}),
{"http": "http://env:3128", "https": "http://env:3128"})
def test_no_options_at_all(self):
with mock.patch("url_guard.urllib.request.getproxies", return_value={}):
self.assertEqual(download_proxies(), {})
class ProxiedHostnameTests(unittest.TestCase):
"""A proxy that resolves hostnames itself makes a local lookup both wrong
and harmful, so the address check is skipped for hostnames behind one."""
def _validate(self, url, proxies):
with mock.patch("url_guard.socket.getaddrinfo") as gai:
gai.side_effect = AssertionError("resolved a hostname that the proxy resolves")
return validate_url(url, proxies=proxies)
def test_socks5h_hostname_not_resolved(self):
self.assertIsNone(self._validate("https://youtube.com/x", {"all": "socks5h://tor:9050"}))
def test_plain_socks5_treated_as_remote_dns(self):
# yt-dlp rewrites socks5 to socks5h on every request for compatibility.
self.assertIsNone(self._validate("https://youtube.com/x", {"all": "socks5://tor:9050"}))
def test_socks4a_hostname_not_resolved(self):
self.assertIsNone(self._validate("https://youtube.com/x", {"all": "socks4a://tor:9050"}))
def test_http_proxy_hostname_not_resolved(self):
self.assertIsNone(self._validate("https://youtube.com/x", {"https": "http://squid:3128"}))
def test_scheme_less_proxy_treated_as_http(self):
self.assertIsNone(self._validate("https://youtube.com/x", {"all": "squid:3128"}))
def test_per_scheme_entry_selected(self):
# Only http is proxied here, so an https URL keeps the check.
proxies = {"http": "http://squid:3128"}
self.assertIsNone(self._validate("http://youtube.com/x", proxies))
with mock.patch("url_guard.socket.getaddrinfo", return_value=_addrinfo("10.0.0.5")):
self.assertIsNotNone(validate_url("https://youtube.com/x", proxies=proxies))
def test_socks4_still_resolved_locally(self):
# SOCKS4 resolves in this process, so the address check still applies.
with mock.patch("url_guard.socket.getaddrinfo", return_value=_addrinfo("10.0.0.5")):
self.assertIsNotNone(validate_url("https://youtube.com/x",
proxies={"all": "socks4://tor:9050"}))
def test_noproxy_host_still_resolved(self):
# A host excluded from the proxy is fetched directly, so it is checked.
with mock.patch("url_guard.socket.getaddrinfo", return_value=_addrinfo("169.254.169.254")):
self.assertIsNotNone(validate_url(
"http://metadata.internal/x",
proxies={"all": "socks5h://tor:9050", "no": "metadata.internal"}))
def test_noproxy_marker_keeps_check(self):
with mock.patch("url_guard.socket.getaddrinfo", return_value=_addrinfo("10.0.0.5")):
self.assertIsNotNone(validate_url("https://youtube.com/x",
proxies={"all": "__noproxy__"}))
def test_ip_literal_still_checked_behind_a_proxy(self):
# An IP literal needs no name resolution, so nothing leaks by judging it
# and the proxy changes nothing: the request names this address either
# way. Unmocked on purpose — getaddrinfo on a literal never hits DNS.
self.assertIsNotNone(validate_url("http://169.254.169.254/latest/meta-data/",
proxies={"all": "socks5h://tor:9050"}))
def test_ipv6_literal_still_checked_behind_a_proxy(self):
self.assertIsNotNone(validate_url("http://[::1]:8080/x",
proxies={"all": "socks5h://tor:9050"}))
def test_blocked_hostname_still_blocked_behind_a_proxy(self):
self.assertIsNotNone(validate_url("http://localhost:8080/x",
proxies={"all": "socks5h://tor:9050"}))
def test_scheme_still_enforced_behind_a_proxy(self):
self.assertIsNotNone(validate_url("file:///etc/passwd",
proxies={"all": "socks5h://tor:9050"}))
def test_unparseable_proxy_keeps_check(self):
with mock.patch("url_guard.socket.getaddrinfo", return_value=_addrinfo("10.0.0.5")):
self.assertIsNotNone(validate_url("https://youtube.com/x",
proxies={"all": "gopher://weird:70"}))
def test_no_proxies_argument_keeps_check(self):
# The default: callers that pass nothing get today's behaviour.
with mock.patch("url_guard.socket.getaddrinfo", return_value=_addrinfo("10.0.0.5")):
self.assertIsNotNone(validate_url("https://youtube.com/x"))
class AllowPrivateBypassTests(unittest.TestCase):
"""ALLOW_PRIVATE_ADDRESSES: trusted proxy/VPN environments opt out of the
SSRF address checks (e.g. Fake-IP clients that resolve to 198.18.0.0/15)."""
def test_internal_address_allowed_when_bypassed(self):
# Fake-IP benchmarking range that is_global rejects by default.
self.assertIsNone(validate_url("http://www.youtube.com/x", allow_private=True))
def test_private_host_allowed_when_bypassed(self):
# No DNS lookup needed: the bypass returns before resolution.
with mock.patch("url_guard.socket.getaddrinfo") as gai:
self.assertIsNone(validate_url("http://192.168.1.1/x", allow_private=True))
gai.assert_not_called()
def test_scheme_still_enforced_when_bypassed(self):
self.assertIsNotNone(validate_url("file:///etc/passwd", allow_private=True))
def test_socket_guard_not_installed_when_bypassed(self):
original = socket.getaddrinfo
try:
install_socket_guard(allow_private=True)
self.assertIs(socket.getaddrinfo, original)
finally:
socket.getaddrinfo = original
class InstallSocketGuardTests(unittest.TestCase):
def setUp(self):
original, saved = socket.getaddrinfo, set(url_guard._allowed_endpoints)
self.addCleanup(lambda: setattr(socket, "getaddrinfo", original))
self.addCleanup(lambda: setattr(url_guard, "_allowed_endpoints", saved))
# Keep the host's own environment out of the assertions below.
patcher = mock.patch("url_guard.urllib.request.getproxies", return_value={})
self.getproxies = patcher.start()
self.addCleanup(patcher.stop)
def test_install_replaces_and_is_idempotent(self):
install_socket_guard()
self.assertIs(socket.getaddrinfo, url_guard._guarded_getaddrinfo)
# Re-installing must not wrap the wrapper (real fn captured at import).
install_socket_guard()
self.assertIs(socket.getaddrinfo, url_guard._guarded_getaddrinfo)
def test_no_proxy_means_no_loopback_allowance(self):
install_socket_guard()
self.assertEqual(url_guard._allowed_endpoints, set())
def test_explicit_proxy_is_registered(self):
install_socket_guard(proxy_urls=("socks5://127.0.0.1:9050",))
self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 9050)})
def test_unset_proxy_option_is_ignored(self):
# ytdl_opts.get('proxy') is None when the operator configured no proxy.
install_socket_guard(proxy_urls=(None,))
self.assertEqual(url_guard._allowed_endpoints, set())
def test_environment_proxies_are_registered(self):
self.getproxies.return_value = {"http": "http://127.0.0.1:8080"}
install_socket_guard()
self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 8080)})
def test_service_url_is_registered(self):
install_socket_guard(service_urls=("http://127.0.0.1:4416",))
self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 4416)})
def test_service_and_proxy_endpoints_coexist(self):
install_socket_guard(
proxy_urls=("socks5://10.1.20.30:1080",),
service_urls=("http://127.0.0.1:4416",),
)
self.assertEqual(
url_guard._allowed_endpoints,
{("10.1.20.30", 1080), ("127.0.0.1", 4416)},
)
def test_service_urls_reset_between_installs(self):
install_socket_guard(service_urls=("http://127.0.0.1:4416",))
install_socket_guard()
self.assertEqual(url_guard._allowed_endpoints, set())
def test_endpoints_reset_between_installs(self):
install_socket_guard(proxy_urls=("http://127.0.0.1:8080",))
install_socket_guard(proxy_urls=(None,))
self.assertEqual(url_guard._allowed_endpoints, set())
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
-414
View File
@@ -1,414 +0,0 @@
"""Lightweight SSRF guard for user-submitted URLs.
MeTube hands user-submitted URLs to yt-dlp, whose generic extractor will fetch
any ``http(s)`` URL. Without a guard, an attacker can make the server fetch
internal endpoints (cloud metadata services, loopback, RFC1918 hosts, etc.) and
have the response saved to the download directory and served back.
This module provides two layers:
* ``validate_url`` a cheap validator applied at every URL ingress.
* ``install_socket_guard`` a connect-time ``getaddrinfo`` guard installed in
the download subprocess, which re-validates every resolved address and so
covers redirects, DNS rebinding, and media URLs yt-dlp derives from remote
metadata for any backend that resolves through Python's socket module.
Known limitations network isolation (e.g. Docker) remains the backstop for
all of these:
* The socket guard is installed only in the download subprocess. Metadata
extraction (``ytdl.DownloadQueue.__extract_info``) runs in the main process,
where installing a process-wide guard would reject the server's own bind on
``HOST=0.0.0.0``. So extraction which also follows redirects is covered
only by ``validate_url`` at ingress, not at connect time; a redirect from an
allowed host to an internal one during extraction is not blocked (a lower-
impact, blind SSRF, since the extraction response is not written to disk).
* Native resolvers (curl_cffi/libcurl via ``--impersonate``) resolve outside
Python's socket module and bypass the connect-time guard entirely.
* When a proxy carries the fetch and resolves hostnames itself (an HTTP proxy,
``socks5h``, ``socks4a``, or the plain ``socks5`` yt-dlp rewrites to
``socks5h``), ``validate_url`` cannot check where a *hostname* leads: the
proxy resolves it on its own network, and looking it up here would both
describe the wrong network and leak the hostname to the local resolver. The
address check is skipped for those, and what the proxy's network exposes is
the proxy's to police. Hosts written as IP literals are still checked, and
the connect-time guard still covers everything dialled directly.
"""
import ipaddress
import logging
import socket
import urllib.request
from urllib.parse import urlsplit
log = logging.getLogger('url_guard')
_ALLOWED_SCHEMES = ('http', 'https')
# Ports to assume when a configured endpoint URL omits one, per scheme.
_SCHEME_DEFAULT_PORTS = {
'http': 80,
'https': 443,
'socks4': 1080,
'socks4a': 1080,
'socks5': 1080,
'socks5h': 1080,
}
# Proxy schemes that hand the destination hostname to the proxy instead of
# resolving it here. yt-dlp rewrites a scheme-less proxy to ``http`` and plain
# ``socks5`` to ``socks5h`` on every request (``clean_proxies``), so only SOCKS4
# — and the non-standard ``socks`` alias yt-dlp maps onto it — still resolves
# locally and is therefore absent from this list.
_REMOTE_DNS_PROXY_SCHEMES = ('http', 'https', 'socks5', 'socks5h', 'socks4a')
# Hostnames that must be blocked without needing a lookup. ``localhost`` and any
# subdomain of it are conventionally loopback, and the GCP metadata name is a
# well-known SSRF target that may resolve via a resolver we don't control.
_BLOCKED_HOSTNAMES = ('localhost', 'metadata.google.internal')
def _hostname_is_blocked(hostname: str) -> bool:
host = hostname.rstrip('.').lower()
for blocked in _BLOCKED_HOSTNAMES:
if host == blocked or host.endswith('.' + blocked):
return True
return False
# IPv6 ranges that tunnel an IPv4 address at a fixed offset. ``is_global``
# judges only the outer address, so an internal IPv4 wrapped in one of these can
# pass a check the bare address would fail — 64:ff9b::a9fe:a9fe carries the cloud
# metadata address but sits in the 2000::/3 global unicast range.
_NAT64_WELL_KNOWN_PREFIX = ipaddress.ip_network('64:ff9b::/96')
_IPV4_COMPATIBLE = ipaddress.ip_network('::/96')
# ``::`` and ``::1`` sit inside ::/96 without being IPv4-compatible addresses
# (RFC 4291 reserves both), and 0.0.0.0/8 is not a routable destination anyway.
# Reading a tunnelled address out of them would just misdescribe them.
_UNUSABLE_IPV4 = ipaddress.ip_network('0.0.0.0/8')
def _normalise_ip(addr: str):
"""Parse *addr*, unwrapping IPv4-mapped IPv6 (e.g. ``::ffff:169.254.169.254``)
so the embedded IPv4 address is judged on its own merits. Returns ``None``
when *addr* is not a valid IP literal."""
try:
ip = ipaddress.ip_address(addr)
except ValueError:
return None
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
return ip
def _tunnelled_ipv4(ip):
"""The IPv4 address an IPv6 transition form tunnels, or ``None``.
Covers 6to4 (``2002::/16``), Teredo (``2001::/32``), the NAT64 well-known
prefix (``64:ff9b::/96``) and the deprecated IPv4-compatible form
(``::/96``). IPv4-mapped is handled by ``_normalise_ip`` instead: that form
*is* its embedded address rather than a tunnel to it.
"""
if not isinstance(ip, ipaddress.IPv6Address):
return None
if ip.sixtofour is not None:
return ip.sixtofour
if ip.teredo is not None:
return ip.teredo[1]
if ip in _NAT64_WELL_KNOWN_PREFIX or ip in _IPV4_COMPATIBLE:
tunnelled = ipaddress.ip_address(int(ip) & 0xFFFFFFFF)
return None if tunnelled in _UNUSABLE_IPV4 else tunnelled
return None
def _ips_to_judge(addr: str) -> tuple:
"""Every address a verdict on *addr* has to account for: the address itself
plus any IPv4 it tunnels. Empty when *addr* is not a valid IP literal.
A tunnelled address is judged on *both* halves, so unwrapping can only ever
tighten the verdict. Returning the embedded address alone would be a way in:
Python already rejects all of 2002::/16 and 2001::/32, and replacing
``2002:0808:0808::`` with the global 8.8.8.8 would turn an address the guard
blocks today into an allowed one.
"""
ip = _normalise_ip(addr)
if ip is None:
return ()
tunnelled = _tunnelled_ipv4(ip)
return (ip,) if tunnelled is None else (ip, tunnelled)
def _address_is_global(addr: str) -> bool:
ips = _ips_to_judge(addr)
return bool(ips) and all(ip.is_global for ip in ips)
def _address_allowed_at_connect(addr: str, is_allowed_endpoint: bool = False) -> bool:
"""True if *addr* may be connected to at download time.
Permits global addresses, and anything at all when the destination is an
endpoint the operator or the image configured a proxy, or the PO token
provider (see ``_is_allowed_endpoint``). Internal addresses are otherwise
refused with no blanket exception: media URLs that yt-dlp derives from a
remote manifest are attacker-controlled and reach this policy without passing
``validate_url``, so any range opened here is a range a hostile playlist can
read from the server's own network. Blocks link-local
(cloud metadata at 169.254.169.254), private (RFC1918), loopback,
unique-local and every other non-global range.
"""
ips = _ips_to_judge(addr)
if not ips:
return False
return is_allowed_endpoint or all(ip.is_global for ip in ips)
def _url_endpoint(url: str):
"""Parse a configured URL into a ``(hostname, port)`` pair, or ``None`` if it
has no usable host. Used to scope the internal-address allowance to that
endpoint alone."""
if not isinstance(url, str) or not url.strip():
return None
candidate = url.strip()
if '://' not in candidate:
# Bare host:port, as accepted by the *_proxy environment variables.
candidate = '//' + candidate
try:
parts = urlsplit(candidate)
hostname, port = parts.hostname, parts.port
except ValueError:
return None
if not hostname:
return None
if port is None:
port = _SCHEME_DEFAULT_PORTS.get(parts.scheme.lower())
return (hostname.rstrip('.').lower(), port)
def _endpoints(urls) -> set:
"""The parseable endpoints among *urls*, dropping any that name no host."""
return {ep for ep in map(_url_endpoint, urls) if ep is not None}
def _collect_proxy_endpoints(proxy_urls) -> set:
"""Endpoints of every proxy this download may legitimately dial: the explicit
yt-dlp ``proxy`` option plus the ``*_proxy`` environment variables yt-dlp falls
back to. All are operator-configured, unlike the URLs inside fetched media."""
candidates = list(proxy_urls) + list(urllib.request.getproxies().values())
return _endpoints(candidates)
def _proxy_scheme(proxy: str) -> str:
"""The scheme of a configured proxy URL.
Defaults to ``http`` for the bare ``host:port`` form the ``*_proxy``
variables accept, which is the same default yt-dlp applies to them.
"""
if not isinstance(proxy, str):
return ''
candidate = proxy.strip()
if '://' not in candidate:
return 'http' if candidate else ''
return urlsplit(candidate).scheme.lower()
def download_proxies(ytdl_opts=None) -> dict:
"""The proxy map a fetch will use, assembled as ``YoutubeDL.proxies`` does.
An explicit yt-dlp ``proxy`` option replaces the environment wholesale
including any ``no_proxy`` exceptions while without one the ``*_proxy``
variables apply as they stand. Mirrored rather than imported because
``YoutubeDL.proxies`` is only reachable from a constructed instance, and
because this decides whether a security check runs: a quiet upstream change
should leave the check in place rather than silently skip it.
"""
opts_proxy = (ytdl_opts or {}).get('proxy')
if opts_proxy is not None:
# '' means "no proxy, ignore the environment", which yt-dlp spells
# '__noproxy__' internally.
return {'all': opts_proxy or '__noproxy__'}
proxies = urllib.request.getproxies()
# compat, as in yt-dlp: http_proxy alone also covers https.
if 'http' in proxies and 'https' not in proxies:
proxies['https'] = proxies['http']
return proxies
def _proxy_resolves_remotely(parts, proxies) -> bool:
"""True when the proxy carrying this URL looks its hostname up itself.
Follows yt-dlp's ``select_proxy``: ``no_proxy`` exclusions first, then the
per-scheme entry, then the catch-all. Anything unrecognised answers False,
so an unparseable or unusual configuration keeps the address check.
"""
if not proxies:
return False
hostname = parts.hostname
if not hostname:
return False
no_proxy = proxies.get('no')
if no_proxy:
hostport = hostname if parts.port is None else f'{hostname}:{parts.port}'
try:
if urllib.request.proxy_bypass_environment(hostport, {'no': no_proxy}):
return False
except (ValueError, UnicodeError):
return False
proxy = proxies.get(parts.scheme.lower()) or proxies.get('all')
if not proxy or proxy == '__noproxy__':
return False
return _proxy_scheme(proxy) in _REMOTE_DNS_PROXY_SCHEMES
# Captured at import so re-installing the guard never wraps the wrapper.
_real_getaddrinfo = socket.getaddrinfo
# Populated by install_socket_guard; empty means no internal destination is allowed.
_allowed_endpoints: set = set()
def _normalise_port(port):
if isinstance(port, str):
try:
return int(port)
except ValueError:
try:
return socket.getservbyname(port)
except OSError:
return None
return port
def _is_allowed_endpoint(host, port) -> bool:
"""True when host:port is exactly one of the endpoints this download is
configured to dial a proxy or the PO token provider. Matching is on the
configured host *string*, not on the resolved address, so a hostile media URL
cannot borrow the allowance by resolving to the same address under a
different name."""
if not _allowed_endpoints or host is None:
return False
return (str(host).rstrip('.').lower(), _normalise_port(port)) in _allowed_endpoints
def _guarded_getaddrinfo(host, *args, **kwargs):
results = _real_getaddrinfo(host, *args, **kwargs)
# Mirrors getaddrinfo(host, port, ...): port is the first optional argument.
port = args[0] if args else kwargs.get('port')
is_configured = _is_allowed_endpoint(host, port)
allowed = [r for r in results if _address_allowed_at_connect(r[4][0], is_configured)]
if not allowed:
raise socket.gaierror(f'Refusing to connect to non-global address for host {host!r}')
return allowed
def install_socket_guard(allow_private: bool = False, proxy_urls=(), service_urls=()) -> None:
"""Enforce the no-internal-hosts policy at actual connection time.
``validate_url`` only checks the *submitted* URL string; yt-dlp then follows
HTTP redirects and resolves media URLs from remote metadata without
re-validating them. Installing this in the download subprocess re-checks
every resolved address at connect time, covering redirects, DNS rebinding and
manifest-derived media URLs for any networking backend that resolves through
Python's socket module (urllib, requests). Native resolvers — notably
curl_cffi/libcurl used by ``--impersonate`` bypass this and rely on network
isolation as the backstop.
*proxy_urls* are the operator's configured proxies (yt-dlp's ``proxy`` option;
the ``*_proxy`` environment variables are picked up automatically), and
*service_urls* the helper services the download itself has to reach the PO
token provider this image ships and starts on loopback. Each is reachable at
its own host:port wherever it lives loopback, the LAN, a VPN range and
nothing else internal is. That costs those setups nothing and gives away
little: yt-dlp dials each at exactly that host:port, and a media URL is either
handed to the proxy unresolved or resolved on its own merits never
inheriting the allowance. A hostile media URL naming an allowed endpoint
reaches only what is listening there: a proxy that would have fetched it
anyway, or a token server with two endpoints and nothing to read.
When *allow_private* is set (``ALLOW_PRIVATE_ADDRESSES``), the guard is not
installed at all, so proxy/VPN setups that route through private or Fake-IP
ranges keep working.
"""
if allow_private:
return
proxy_endpoints = _collect_proxy_endpoints(proxy_urls)
service_endpoints = _endpoints(service_urls) - proxy_endpoints
_allowed_endpoints.clear()
_allowed_endpoints.update(proxy_endpoints | service_endpoints)
for label, endpoints in (('proxy', proxy_endpoints), ('service', service_endpoints)):
for host, port in sorted(endpoints, key=lambda ep: (ep[0], ep[1] or 0)):
log.info(f'Allowing connections to configured {label} {host}:{port}')
socket.getaddrinfo = _guarded_getaddrinfo
def validate_url(url: str, allow_private: bool = False, proxies: dict | None = None) -> str | None:
"""Return an error message if the URL is disallowed, else ``None``.
Inputs without a ``://`` scheme separator (bare video IDs, ``ytsearch:``
and other yt-dlp search/extractor prefixes) are allowed unchanged so that
non-URL entries keep working.
When *allow_private* is set (``ALLOW_PRIVATE_ADDRESSES``), the internal-host
and internal-address checks are skipped so that trusted proxy/VPN setups
e.g. Fake-IP clients that resolve YouTube to ``198.18.0.0/15`` can be used.
Scheme validation (http/https only) still applies.
*proxies* is the proxy map the fetch will use (see ``download_proxies``).
When it routes this URL through a proxy that resolves hostnames itself, the
address check is skipped see the comment at that branch for why.
"""
if not isinstance(url, str):
return 'Invalid URL'
candidate = url.strip()
if '://' not in candidate:
# Not an absolute URL: bare video IDs, ytsearch: prefixes, etc.
return None
parts = urlsplit(candidate)
scheme = parts.scheme.lower()
if scheme not in _ALLOWED_SCHEMES:
return f'URL scheme "{parts.scheme}" is not allowed (only http and https)'
hostname = parts.hostname
if not hostname:
return 'URL is missing a host'
if allow_private:
# Environment is explicitly trusted: skip the SSRF address checks.
return None
if _hostname_is_blocked(hostname):
return f'Refusing to fetch internal host "{hostname}"'
# A host written as an IP literal needs no lookup, so it is judged directly
# whatever the proxy setup: nothing leaves this process, and the address is
# exactly the one the request will name.
if _normalise_ip(hostname) is None and _proxy_resolves_remotely(parts, proxies):
# The proxy resolves this hostname on its own network, so a lookup here
# answers a different question than the one that matters, and asking it
# is itself the harm: it leaks the hostname of every queued URL to the
# local resolver, which is the single thing a SOCKS/Tor setup exists to
# prevent. It also fails closed against resolvers this process cannot
# reach — a container pointed at the proxy's own DNS port resolves
# nothing here and every add is refused. What the proxy's network
# exposes is the proxy's to police; the connect-time guard still holds
# everything that is dialled directly.
return None
try:
addrinfo = socket.getaddrinfo(hostname, parts.port, proto=socket.IPPROTO_TCP)
except socket.gaierror:
# Fail closed: a host we cannot resolve is a host we cannot verify as
# non-internal, so refuse it rather than letting the download proceed
# to a target that may resolve differently at fetch time.
return f'Could not resolve host "{hostname}"'
except (UnicodeError, ValueError):
return f'Invalid host "{hostname}"'
for family, _type, _proto, _canonname, sockaddr in addrinfo:
addr = sockaddr[0]
if not _address_is_global(addr):
return f'Refusing to fetch internal address "{addr}" for host "{hostname}"'
return None
+300 -1657
View File
File diff suppressed because it is too large Load Diff
+5 -55
View File
@@ -2,54 +2,11 @@
PUID="${UID:-$PUID}"
PGID="${GID:-$PGID}"
AUDIO_DOWNLOAD_DIR="${AUDIO_DOWNLOAD_DIR:-$DOWNLOAD_DIR}"
echo "Setting umask to ${UMASK}"
umask ${UMASK}
echo "Creating download directory (${DOWNLOAD_DIR}), audio download directory (${AUDIO_DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})"
mkdir -p "${DOWNLOAD_DIR}" "${AUDIO_DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
do_upgrade() {
echo "Upgrading yt-dlp to nightly channel..."
if ! python3 -m pip --version >/dev/null 2>&1; then
echo "pip not found; attempting ensurepip"
python3 -m ensurepip --upgrade >/dev/null 2>&1 || true
fi
if ! python3 -m pip install -U --pre "yt-dlp[default,curl-cffi,deno]"; then
echo "Warning: yt-dlp nightly upgrade failed; continuing with existing installation"
return 1
fi
echo "yt-dlp nightly upgrade complete"
return 0
}
run_supervised() {
while true; do
"$@" &
child_pid=$!
trap 'kill -TERM "$child_pid" 2>/dev/null; wait "$child_pid" 2>/dev/null' TERM INT
wait "$child_pid"
exit_code=$?
trap - TERM INT
if [ "$exit_code" -eq 42 ]; then
echo "MeTube requested yt-dlp update restart (exit 42)"
do_upgrade || true
continue
fi
return "$exit_code"
done
}
nightly_enabled() {
[ -n "${YTDL_NIGHTLY_UPDATE_TIME}" ]
}
disable_nightly_for_non_root() {
if nightly_enabled; then
echo "YTDL_NIGHTLY_UPDATE_TIME is set but this container runs as a non-root user; nightly yt-dlp updates are not supported. Ignoring YTDL_NIGHTLY_UPDATE_TIME."
unset YTDL_NIGHTLY_UPDATE_TIME
fi
}
echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})"
mkdir -p "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
if [ "${PUID}" -eq 0 ]; then
@@ -57,22 +14,15 @@ if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
fi
if [ "${CHOWN_DIRS:-true}" != "false" ]; then
echo "Changing ownership of download and state directories to ${PUID}:${PGID}"
chown -R "${PUID}":"${PGID}" /app "${DOWNLOAD_DIR}" "${AUDIO_DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
fi
if nightly_enabled; then
echo "YTDL_NIGHTLY_UPDATE_TIME is set to ${YTDL_NIGHTLY_UPDATE_TIME}; upgrading yt-dlp on startup"
do_upgrade || true
chown -R "${PUID}":"${PGID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
fi
echo "Starting BgUtils POT Provider"
gosu "${PUID}":"${PGID}" bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
echo "Running MeTube as user ${PUID}:${PGID}"
run_supervised gosu "${PUID}":"${PGID}" python3 app/main.py
exit $?
exec gosu "${PUID}":"${PGID}" python3 app/main.py
else
echo "User set by docker; running MeTube as `id -u`:`id -g`"
disable_nightly_for_non_root
echo "Starting BgUtils POT Provider"
bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
run_supervised python3 app/main.py
exit $?
exec python3 app/main.py
fi
-9
View File
@@ -15,13 +15,4 @@ dependencies = [
[dependency-groups]
dev = [
"pylint",
"pytest>=8.0",
"pytest-aiohttp>=1.0",
"pytest-asyncio>=0.24",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["app/tests"]
pythonpath = [".", "app"]
addopts = "-v"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 885 KiB

+4 -3
View File
@@ -33,7 +33,9 @@
"node_modules/@ng-select/ng-select/themes/default.theme.css",
"src/styles.sass"
],
"scripts": [],
"scripts": [
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
],
"serviceWorker": "ngsw-config.json",
"browser": "src/main.ts",
"polyfills": [
@@ -75,8 +77,7 @@
"buildTarget": "metube:build:production"
},
"development": {
"buildTarget": "metube:build:development",
"proxyConfig": "proxy.conf.json"
"buildTarget": "metube:build:development"
}
},
"defaultConfiguration": "development"
+31 -32
View File
@@ -5,7 +5,7 @@
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build:watch": "ng build --watch --configuration development",
"build:watch": "ng build --watch",
"test": "ng test",
"lint": "ng lint"
},
@@ -21,44 +21,43 @@
}
]
},
"packageManager": "pnpm@11.5.2",
"private": true,
"dependencies": {
"@angular/animations": "^22.1.6",
"@angular/common": "^22.1.6",
"@angular/compiler": "^22.1.6",
"@angular/core": "^22.1.6",
"@angular/forms": "^22.1.6",
"@angular/platform-browser": "^22.1.6",
"@angular/platform-browser-dynamic": "^22.1.6",
"@angular/service-worker": "^22.1.6",
"@fortawesome/angular-fontawesome": "~5.1.0",
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-brands-svg-icons": "^7.3.1",
"@fortawesome/free-regular-svg-icons": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "^24.1.2",
"@angular/animations": "^21.2.1",
"@angular/common": "^21.2.1",
"@angular/compiler": "^21.2.1",
"@angular/core": "^21.2.1",
"@angular/forms": "^21.2.1",
"@angular/platform-browser": "^21.2.1",
"@angular/platform-browser-dynamic": "^21.2.1",
"@angular/service-worker": "^21.2.1",
"@fortawesome/angular-fontawesome": "~4.0.0",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-regular-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^21.5.2",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"ngx-cookie-service": "^22.0.0",
"ngx-socket-io": "~4.11.1",
"ngx-cookie-service": "^21.1.0",
"ngx-socket-io": "~4.10.0",
"rxjs": "~7.8.2",
"tslib": "^2.8.1",
"zone.js": "0.16.3"
"zone.js": "0.15.0"
},
"devDependencies": {
"@angular-eslint/builder": "22.5.0",
"@angular/build": "^22.1.8",
"@angular/cli": "^22.1.8",
"@angular/compiler-cli": "^22.1.6",
"@angular/localize": "^22.1.6",
"@eslint/js": "^10.0.1",
"angular-eslint": "22.5.0",
"eslint": "^10.10.0",
"jsdom": "^30.0.1",
"typescript": "~6.0.3",
"typescript-eslint": "8.70.0",
"vitest": "^4.1.11"
"@angular-eslint/builder": "21.1.0",
"@angular/build": "^21.2.1",
"@angular/cli": "^21.2.1",
"@angular/compiler-cli": "^21.2.1",
"@angular/localize": "^21.2.1",
"@eslint/js": "^9.39.3",
"angular-eslint": "21.1.0",
"eslint": "^9.39.3",
"jsdom": "^27.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "8.47.0",
"vitest": "^4.0.18"
}
}
+3301 -2928
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
allowBuilds:
'@parcel/watcher': true
core-js: true
esbuild: true
lmdb: true
msgpackr-extract: true
+3 -3
View File
@@ -1,6 +1,6 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZoneChangeDetection } from '@angular/core';
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZonelessChangeDetection, provideZoneChangeDetection } from '@angular/core';
import { provideServiceWorker } from '@angular/service-worker';
import { provideHttpClient, withInterceptorsFromDi, withXhr } from '@angular/common/http';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
@@ -12,6 +12,6 @@ export const appConfig: ApplicationConfig = {
// or after 30 seconds (whichever comes first).
registrationStrategy: 'registerWhenStable:30000'
}),
provideHttpClient(withXhr(), withInterceptorsFromDi()),
provideHttpClient(withInterceptorsFromDi()),
]
};
+390 -902
View File
File diff suppressed because it is too large Load Diff
+65 -47
View File
@@ -1,7 +1,29 @@
.button-toggle-theme:focus, .button-toggle-theme:active
box-shadow: none
outline: 0px
.add-url-box
max-width: 960px
margin: 4rem auto
.add-url-component
margin: 0.5rem auto
.add-url-group
width: 100%
button.add-url
width: 100%
.folder-dropdown-menu
width: 500px
max-width: calc(100vw - 3rem)
.folder-dropdown-menu .input-group
display: flex
padding-left: 5px
padding-right: 5px
.metube-section-header
font-size: 1.8rem
font-weight: 300
@@ -10,28 +32,6 @@
padding: 0.5rem 0
margin-top: 3.5rem
.metube-section-toggle
// Positioned so it paints above the header's full-bleed :before overlay.
position: relative
display: flex
align-items: center
// Title left, chevron against the right edge, so all three section titles
// stay on the same left margin whether or not a chevron is present.
justify-content: space-between
gap: 0.75rem
width: 100%
padding: 0
border: 0
background: none
color: inherit
font: inherit
text-align: left
.metube-section-chevron
font-size: 1.1rem
width: 1.1rem
color: var(--bs-secondary-color)
.metube-section-header:before
content: ""
position: absolute
@@ -66,11 +66,39 @@ td
width: 12rem
margin-left: auto
.batch-panel
margin-top: 15px
border: 1px solid #ccc
border-radius: 8px
padding: 15px
background-color: #fff
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1)
.batch-panel-header
border-bottom: 1px solid #eee
padding-bottom: 8px
margin-bottom: 15px
h4
font-size: 1.5rem
margin: 0
.batch-panel-body
textarea.form-control
resize: vertical
.batch-status
font-size: 0.9rem
color: #555
.d-flex.my-3
margin-top: 1rem
margin-bottom: 1rem
.modal.fade.show
background-color: rgba(0, 0, 0, 0.5)
.modal-header
border-bottom: 1px solid var(--bs-border-color)
border-bottom: 1px solid #eee
.modal-body
textarea.form-control
@@ -84,12 +112,20 @@ td
.spinner-border
margin-right: 0.5rem
.add-progress-btn
min-width: 9.5rem
cursor: default
.add-cancel-btn
min-width: 3.25rem
::ng-deep .ng-select
flex: 1
.ng-select-container
min-height: 38px
.ng-value
white-space: nowrap
overflow: visible
.ng-dropdown-panel
.ng-dropdown-panel-items
max-height: 300px
.ng-option
white-space: nowrap
overflow: visible
text-overflow: ellipsis
:host
display: flex
@@ -204,18 +240,6 @@ main
opacity: 0.65
pointer-events: none
.settings-section-label
font-size: 0.8rem
text-transform: uppercase
letter-spacing: 0.1em
font-weight: 600
color: var(--bs-body-color)
margin-top: 1.75rem
margin-bottom: 0.75rem
&:first-child
margin-top: 0
.action-group-label
font-size: 0.7rem
text-transform: uppercase
@@ -223,12 +247,6 @@ main
color: var(--bs-secondary-color)
margin-bottom: 0.4rem
.help-title
cursor: help
&:focus
outline: none
.cookie-status
font-size: 0.8rem
margin-top: 0.35rem
+14 -528
View File
@@ -1,145 +1,26 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http';
import { Subject, of } from 'rxjs';
import { App } from './app';
import { DownloadsService } from './services/downloads.service';
import { SubscriptionsService } from './services/subscriptions.service';
import { ToastService } from './services/toast.service';
import { CookieService } from 'ngx-cookie-service';
import { Download } from './interfaces';
class DownloadsServiceStub {
loading = false;
queue = new Map();
done = new Map();
configuration: Record<string, unknown> = { CUSTOM_DIRS: true, CREATE_CUSTOM_DIRS: true, ALLOW_YTDL_OPTIONS_OVERRIDES: false };
customDirs = { download_dir: [], audio_download_dir: [] };
queueChanged = new Subject<void>();
doneChanged = new Subject<void>();
configurationChanged = new Subject<Record<string, unknown>>();
customDirsChanged = new Subject<Record<string, string[]>>();
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
updated = new Subject<void>();
retryCalls: string[] = [];
vi.hoisted(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
enumerable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
getCookieStatus() {
return of({ status: 'ok', has_cookies: false });
}
getPresets() {
return of({ presets: ['Preset A'] });
}
add() {
return of({ status: 'ok' as const });
}
retry(id: string) {
this.retryCalls.push(id);
return of({ status: 'ok' as const });
}
cancelAdd() {
return of({ status: 'ok' as const });
}
startById() {
return of({});
}
delById() {
return of({});
}
delByFilter() {
return of({});
}
startByFilter() {
return of({});
}
uploadCookies() {
return of({ status: 'ok' });
}
deleteCookies() {
return of({ status: 'ok' });
}
}
class SubscriptionsServiceStub {
subscriptions = new Map();
subscriptionsChanged = new Subject<void>();
subscribeCalls: unknown[] = [];
subscribe(payload: unknown) {
this.subscribeCalls.push(payload);
return of({ status: 'ok' as const });
}
delete() {
return of({});
}
updateCalls: [string, unknown][] = [];
update(id: string, changes: unknown) {
this.updateCalls.push([id, changes]);
return of({ status: 'ok' as const });
}
refreshList() {
return of([]);
}
}
class CookieServiceStub {
private cookies = new Map<string, string>();
get(name: string) {
return this.cookies.get(name) ?? '';
}
set(name: string, value: string) {
this.cookies.set(name, value);
}
check(name: string) {
return this.cookies.has(name);
}
}
describe('App', () => {
let downloads: DownloadsServiceStub;
beforeEach(async () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
enumerable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
downloads = new DownloadsServiceStub();
await TestBed.configureTestingModule({
imports: [App],
providers: [
{ provide: DownloadsService, useValue: downloads },
{ provide: SubscriptionsService, useClass: SubscriptionsServiceStub },
{ provide: CookieService, useClass: CookieServiceStub },
{
provide: HttpClient,
useValue: {
get: vi.fn().mockReturnValue(of({ 'yt-dlp': 'test', version: 'test' })),
},
},
],
}).compileComponents();
});
@@ -149,399 +30,4 @@ describe('App', () => {
expect(app).toBeTruthy();
});
it('pre-fills the download folder from DEFAULT_FOLDER', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
downloads.configurationChanged.next({ DEFAULT_FOLDER: 'youtube' });
expect(fixture.componentInstance.folder).toBe('youtube');
});
it('does not overwrite a folder the user already typed', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
fixture.componentInstance.folder = 'music';
downloads.configurationChanged.next({ DEFAULT_FOLDER: 'youtube' });
expect(fixture.componentInstance.folder).toBe('music');
});
it('collapses each section independently and remembers it (#1070)', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const app = fixture.componentInstance;
const cookies = TestBed.inject(CookieService);
expect(app.downloadingCollapsed).toBe(false);
expect(app.completedCollapsed).toBe(false);
expect(app.subscriptionsCollapsed).toBe(false);
app.toggleCompletedCollapsed();
expect(app.completedCollapsed).toBe(true);
expect(app.downloadingCollapsed).toBe(false);
expect(app.subscriptionsCollapsed).toBe(false);
expect(cookies.get('metube_completed_collapsed')).toBe('true');
// A fresh component picks the state back up from the cookie.
const restored = TestBed.createComponent(App);
restored.detectChanges();
expect(restored.componentInstance.completedCollapsed).toBe(true);
expect(restored.componentInstance.downloadingCollapsed).toBe(false);
expect(restored.componentInstance.subscriptionsCollapsed).toBe(false);
});
it('asIsOrder returns a stable comparator value (insertion order preserved)', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app.asIsOrder()).toBe(0);
});
it('hides manual override input when disabled', () => {
const fixture = TestBed.createComponent(App);
fixture.componentInstance.isAdvancedOpen = true;
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('input[name="ytdlOptionsOverrides"]')).toBeNull();
const presetWrapper = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.col-12');
expect(presetWrapper?.classList.contains('col-md-6')).toBe(false);
const presetRow = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.row');
expect(presetRow?.querySelector('input[name="checkIntervalMinutes"]')).toBeNull();
});
it('shows manual override input when enabled', () => {
downloads.configuration['ALLOW_YTDL_OPTIONS_OVERRIDES'] = true;
const fixture = TestBed.createComponent(App);
fixture.componentInstance.isAdvancedOpen = true;
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('input[name="ytdlOptionsOverrides"]')).not.toBeNull();
const presetWrapper = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.col-12');
expect(presetWrapper?.classList.contains('col-md-6')).toBe(true);
const presetRow = root.querySelector('ng-select[name="ytdlOptionsPresets"]')?.closest('.row');
expect(presetRow?.querySelector('input[name="checkIntervalMinutes"]')).toBeNull();
expect(presetRow?.querySelector('input[name="ytdlOptionsOverrides"]')).not.toBeNull();
});
it('does not submit manual overrides when disabled', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
app.ytdlOptionsOverrides = '{"exec":"echo hi"}';
const payload = app['buildAddPayload']();
expect(payload.ytdlOptionsOverrides).toBe('');
});
it('shows waiting badge for scheduled live stream', () => {
downloads.queue.set('https://example.com/live', {
id: 'live1',
title: 'Upcoming Stream',
url: 'https://example.com/live',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'scheduled',
live_status: 'is_upcoming',
live_release_timestamp: Date.now() / 1000 + 3600,
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
});
downloads.queueChanged.next();
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.textContent).toContain('Waiting for stream');
expect(root.textContent).toContain('starts in');
});
it('shows the queued format in the Downloading table', () => {
downloads.queue.set('https://example.com/v', {
id: 'v1',
title: 'Some Video',
url: 'https://example.com/v',
download_type: 'audio',
quality: 'best',
format: 'flac',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'downloading',
msg: '',
percent: 10,
speed: 0,
eta: 0,
filename: '',
checked: false,
});
downloads.queueChanged.next();
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const row = (fixture.nativeElement as HTMLElement).querySelector('tbody tr');
expect(row?.textContent).toContain('FLAC');
});
it('labels formats the way the form does, and copes with an unknown one', () => {
const app = TestBed.createComponent(App).componentInstance;
const base = { format: '' } as Download;
expect(app.formatLabel({ ...base, format: 'any' })).toBe('Auto');
expect(app.formatLabel({ ...base, format: 'mp4' })).toBe('MP4');
expect(app.formatLabel({ ...base, format: 'srt' })).toBe('SRT');
// A format from a record older than the option list still reads sensibly.
expect(app.formatLabel({ ...base, format: 'mkv' })).toBe('MKV');
expect(app.formatLabel(base)).toBe('-');
});
it('includes titleRegex in subscribe payload', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
app.addUrl = 'https://example.com/channel';
app.titleRegex = 'EPISODE';
app.addSubscription();
expect(subs.subscribeCalls.length).toBe(1);
const payload = subs.subscribeCalls[0] as { titleRegex: string; skipSubscriberOnly: boolean };
expect(payload.titleRegex).toBe('EPISODE');
expect(payload.skipSubscriberOnly).toBe(false);
});
it('includes skipSubscriberOnly true when checked', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
app.addUrl = 'https://example.com/channel';
app.skipSubscriberOnly = true;
app.addSubscription();
expect(subs.subscribeCalls.length).toBe(1);
const payload = subs.subscribeCalls[0] as { skipSubscriberOnly: boolean };
expect(payload.skipSubscriberOnly).toBe(true);
});
it('passes clip fields through to the subscribe payload', () => {
// #1049: a subscription's options apply to all its future downloads, and
// clip bounds used to be stripped out on the way.
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
app.addUrl = 'https://example.com/channel';
app.clipStart = '1:00';
app.clipEnd = '2:00';
app.addSubscription();
expect(subs.subscribeCalls.length).toBe(1);
const payload = subs.subscribeCalls[0] as Record<string, unknown>;
expect(payload['clipStart']).toBe('1:00');
expect(payload['clipEnd']).toBe('2:00');
});
it('buildAddPayload includes clip times', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
app.clipStart = '0:10';
app.clipEnd = '1:20';
const payload = app['buildAddPayload']();
expect(payload.clipStart).toBe('0:10');
expect(payload.clipEnd).toBe('1:20');
});
it('retries a failed download by its server-side queue id', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const download = {
id: 'vid1',
title: 'Test Video',
url: 'https://example.com/v',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'error',
msg: 'temporary failure',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
};
app.retryDownload(download.url, download);
expect(downloads.retryCalls).toEqual([download.url]);
});
it('blocks subscribe with invalid title regex', () => {
const toasts = TestBed.inject(ToastService);
const errorSpy = vi.spyOn(toasts, 'error').mockImplementation(() => undefined);
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
app.addUrl = 'https://example.com/channel';
app.titleRegex = '[';
app.addSubscription();
expect(subs.subscribeCalls.length).toBe(0);
expect(errorSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
errorSpy.mockRestore();
});
it('renames a subscription and closes the inline editor', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
app.beginEditName('sub1', 'Videos');
expect(app.editingNameId).toBe('sub1');
expect(app.nameEditDraft).toBe('Videos');
app.nameEditDraft = ' Jane uploads ';
app.saveName('sub1');
expect(subs.updateCalls).toEqual([['sub1', { name: 'Jane uploads' }]]);
expect(app.editingNameId).toBeNull();
});
it('blocks renaming a subscription to an empty name', () => {
const toasts = TestBed.inject(ToastService);
const errorSpy = vi.spyOn(toasts, 'error').mockImplementation(() => undefined);
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
app.beginEditName('sub1', 'Videos');
app.nameEditDraft = ' ';
app.saveName('sub1');
expect(subs.updateCalls.length).toBe(0);
expect(app.editingNameId).toBe('sub1');
expect(errorSpy).toHaveBeenCalledWith('Subscription name must not be empty');
errorSpy.mockRestore();
});
// Issue #533: the server picks AUDIO_DOWNLOAD_DIR on download_type alone
// (ytdl.py), so the UI's choice of URL base has to use the same rule. It used
// to also treat any .mp3 as audio, which pointed the link at audio_download/
// for files the server had written to DOWNLOAD_DIR.
describe('download links follow the server directory rule (#533)', () => {
const makeDownload = (over: Partial<Download>): Download => ({
id: 'vid1',
title: 'Test',
url: 'https://example.com/v',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'finished',
msg: '',
percent: 100,
speed: 0,
eta: 0,
filename: 'song.mp4',
checked: false,
...over,
} as Download);
const appWithDirs = () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const downloads = TestBed.inject(DownloadsService) as unknown as DownloadsServiceStub;
downloads.configuration['PUBLIC_HOST_URL'] = 'download/';
downloads.configuration['PUBLIC_HOST_AUDIO_URL'] = 'audio_download/';
return app;
};
it('uses the audio base for an audio download', () => {
const app = appWithDirs();
const link = app.buildDownloadLink(makeDownload({ download_type: 'audio', filename: 'song.mp3' }));
expect(link).toBe('audio_download/song.mp3');
});
it('uses the video base for an mp3 produced by a video download', () => {
const app = appWithDirs();
const link = app.buildDownloadLink(makeDownload({ download_type: 'video', filename: 'song.mp3' }));
expect(link).toBe('download/song.mp3');
});
it('uses the video base for a video download', () => {
const app = appWithDirs();
const link = app.buildDownloadLink(makeDownload({ filename: 'clip.mp4' }));
expect(link).toBe('download/clip.mp4');
});
it('applies the same rule to chapter links', () => {
const app = appWithDirs();
const dl = makeDownload({ download_type: 'video' });
expect(app.buildChapterDownloadLink(dl, 'ch1.mp3')).toBe('download/ch1.mp3');
const audio = makeDownload({ download_type: 'audio' });
expect(app.buildChapterDownloadLink(audio, 'ch1.mp3')).toBe('audio_download/ch1.mp3');
});
});
// Issue #424: ffmpeg work after the bytes land (merge, re-encode, split) used
// to leave the row on a full, frozen bar with the item counted as neither
// active nor queued.
describe('post-processing is visible (#424)', () => {
const queueEntry = (status: string): Download => ({
id: 'vid1',
title: 'Test',
url: 'https://example.com/v',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status,
msg: '',
percent: 100,
speed: 0,
eta: 0,
filename: '',
checked: false,
} as Download);
it('runs the bar indeterminate while preparing or post-processing', () => {
const app = TestBed.createComponent(App).componentInstance;
expect(app.isIndeterminate(queueEntry('preparing'))).toBe(true);
expect(app.isIndeterminate(queueEntry('postprocessing'))).toBe(true);
expect(app.isIndeterminate(queueEntry('downloading'))).toBe(false);
expect(app.isIndeterminate(queueEntry('pending'))).toBe(false);
});
it('labels the bar and counts the item as active', () => {
// The component subscribes to queueChanged on construction, so the entry
// has to be announced after it exists or updateMetrics never runs.
const fixture = TestBed.createComponent(App);
downloads.queue.set('https://example.com/v', queueEntry('postprocessing'));
downloads.queueChanged.next();
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Post-processing');
expect(fixture.componentInstance.activeDownloads).toBe(1);
expect(fixture.componentInstance.queuedDownloads).toBe(0);
});
});
});
+300 -1179
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,3 +1,2 @@
export { SelectAllCheckboxComponent } from './master-checkbox.component';
export { ItemCheckboxComponent } from './slave-checkbox.component';
export { ToastContainerComponent } from './toast-container.component';
export { MasterCheckboxComponent } from './master-checkbox.component';
export { SlaveCheckboxComponent } from './slave-checkbox.component';
@@ -1,138 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { SelectAllCheckboxComponent } from './master-checkbox.component';
import { Checkable } from '../interfaces';
function makeList(ids: string[]): Map<string, Checkable> {
const list = new Map<string, Checkable>();
for (const id of ids) {
list.set(id, { checked: false });
}
return list;
}
function makeMaster(list: Map<string, Checkable>, orderedIds: string[] | null = null) {
const fixture = TestBed.createComponent(SelectAllCheckboxComponent);
fixture.componentRef.setInput('id', 'queue');
fixture.componentRef.setInput('list', list);
if (orderedIds) {
fixture.componentRef.setInput('orderedIds', orderedIds);
}
fixture.detectChanges();
return fixture;
}
// Simulates what the item checkbox does: ngModel writes the new state, then
// the change handler reports the click to the master.
function clickItem(
master: SelectAllCheckboxComponent,
list: Map<string, Checkable>,
id: string,
shift = false,
) {
const item = list.get(id)!;
item.checked = !item.checked;
master.selectionChanged(id, shift);
}
describe('SelectAllCheckboxComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SelectAllCheckboxComponent],
}).compileComponents();
});
it('clicked sets checked on all list items', () => {
const fixture = TestBed.createComponent(SelectAllCheckboxComponent);
const list = new Map<string, Checkable>();
list.set('u1', { checked: false });
fixture.componentRef.setInput('id', 'queue');
fixture.componentRef.setInput('list', list);
fixture.componentInstance.selected = true;
fixture.detectChanges();
fixture.componentInstance.clicked();
expect(list.get('u1')?.checked).toBe(true);
});
it('shift-click checks every item between the two clicks', () => {
const list = makeList(['u1', 'u2', 'u3', 'u4', 'u5']);
const master = makeMaster(list).componentInstance;
clickItem(master, list, 'u2');
clickItem(master, list, 'u4', true);
expect([...list.values()].map((i) => i.checked)).toEqual([false, true, true, true, false]);
});
it('extends upwards as well as downwards', () => {
const list = makeList(['u1', 'u2', 'u3', 'u4']);
const master = makeMaster(list).componentInstance;
clickItem(master, list, 'u4');
clickItem(master, list, 'u2', true);
expect([...list.values()].map((i) => i.checked)).toEqual([false, true, true, true]);
});
it('shift-clicking a checked box clears the range', () => {
const list = makeList(['u1', 'u2', 'u3']);
list.forEach((item) => (item.checked = true));
const master = makeMaster(list).componentInstance;
clickItem(master, list, 'u1');
clickItem(master, list, 'u3', true);
expect([...list.values()].map((i) => i.checked)).toEqual([false, false, false]);
});
it('follows the rendered order, not the map order', () => {
// The done list renders newest-first, so its rendered order is not the
// order the entries sit in the map. u2 lies inside the range on screen
// and outside it in the map, which is what separates the two.
const list = makeList(['u1', 'u2', 'u3', 'u4']);
const master = makeMaster(list, ['u4', 'u2', 'u3', 'u1']).componentInstance;
clickItem(master, list, 'u4');
clickItem(master, list, 'u3', true);
// u1 (rendered last) stays clear; u2 is swept up with the range.
expect([...list.values()].map((i) => i.checked)).toEqual([false, true, true, true]);
});
it('a plain click after a range starts a new anchor', () => {
const list = makeList(['u1', 'u2', 'u3', 'u4']);
const master = makeMaster(list).componentInstance;
clickItem(master, list, 'u1');
clickItem(master, list, 'u2', true);
clickItem(master, list, 'u4');
expect([...list.values()].map((i) => i.checked)).toEqual([true, true, false, true]);
});
it('select-all clears the anchor so the next shift-click is a plain toggle', () => {
const list = makeList(['u1', 'u2', 'u3']);
const fixture = makeMaster(list);
const master = fixture.componentInstance;
clickItem(master, list, 'u1');
master.selected = true;
master.clicked();
master.selected = false;
master.clicked();
clickItem(master, list, 'u3', true);
expect([...list.values()].map((i) => i.checked)).toEqual([false, false, true]);
});
it('ignores a range whose anchor row is gone', () => {
const list = makeList(['u1', 'u2', 'u3']);
const master = makeMaster(list).componentInstance;
clickItem(master, list, 'u1');
// The anchor finishes downloading and leaves the queue.
list.delete('u1');
clickItem(master, list, 'u3', true);
expect([...list.values()].map((i) => i.checked)).toEqual([false, true]);
});
});
@@ -1,82 +1,40 @@
import { Component, ElementRef, viewChild, output, input, ChangeDetectionStrategy } from "@angular/core";
import { Component, ElementRef, viewChild, output, input } from "@angular/core";
import { Checkable } from "../interfaces";
import { FormsModule } from "@angular/forms";
@Component({
selector: 'app-select-all-checkbox',
selector: 'app-master-checkbox',
template: `
<div class="form-check">
<input type="checkbox" class="form-check-input" id="{{id()}}-select-all" #masterCheckbox [(ngModel)]="selected" (change)="clicked()" [attr.aria-label]="'Select all ' + id() + ' items'">
<label class="form-check-label visually-hidden" for="{{id()}}-select-all">Select all</label>
<input type="checkbox" class="form-check-input" id="{{id()}}-select-all" #masterCheckbox [(ngModel)]="selected" (change)="clicked()">
<label class="form-check-label" for="{{id()}}-select-all"></label>
</div>
`,
// Shared Checkable objects are mutated in place; Eager preserves pre-v22 behavior.
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
changeDetection: ChangeDetectionStrategy.Eager,
imports: [
imports: [
FormsModule
]
})
export class SelectAllCheckboxComponent {
export class MasterCheckboxComponent {
readonly id = input.required<string>();
readonly list = input.required<Map<string, Checkable>>();
// The ids in the order the rows are rendered. The done list is sorted for
// display, so its order is not the map's insertion order, and a range
// selection has to follow what the user sees. Left unset, the map order is
// the rendered order.
readonly orderedIds = input<string[] | null>(null);
readonly changed = output<number>();
readonly masterCheckbox = viewChild.required<ElementRef>('masterCheckbox');
selected!: boolean;
// The item a range extends from: the last one toggled on its own.
private anchorId: string | null = null;
clicked() {
this.list().forEach(item => item.checked = this.selected);
// Select-all is not a position, so there is nothing to extend from next.
this.anchorId = null;
this.selectionChanged();
}
selectionChanged(id?: string, extend = false) {
if (id !== undefined) {
if (extend && this.anchorId !== null && this.anchorId !== id) {
this.applyRange(this.anchorId, id);
}
this.anchorId = id;
}
selectionChanged() {
const masterCheckbox = this.masterCheckbox();
if (!masterCheckbox)
return;
let checked = 0;
this.list().forEach(item => { if(item.checked) checked++ });
this.selected = checked > 0 && checked === this.list().size;
this.selected = checked > 0 && checked == this.list().size;
masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list().size;
this.changed.emit(checked);
}
// Everything between the anchor and the just-clicked row takes the state the
// click produced, so shift-clicking a checked box clears the range and
// shift-clicking an unchecked one fills it.
private applyRange(fromId: string, toId: string) {
const ids = this.orderedIds() ?? Array.from(this.list().keys());
const from = ids.indexOf(fromId);
const to = ids.indexOf(toId);
// A row can disappear between two clicks (a download finishing moves it
// from the queue to the done list); without both ends there is no range.
if (from < 0 || to < 0) {
return;
}
const target = this.list().get(toId)?.checked ?? false;
const start = Math.min(from, to);
const end = Math.max(from, to);
for (let i = start; i <= end; i++) {
const item = this.list().get(ids[i]);
if (item) {
item.checked = target;
}
}
}
}
@@ -1,54 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { SelectAllCheckboxComponent } from './master-checkbox.component';
import { ItemCheckboxComponent } from './slave-checkbox.component';
describe('ItemCheckboxComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ItemCheckboxComponent, SelectAllCheckboxComponent],
}).compileComponents();
});
it('creates with master and checkable inputs', () => {
const masterFixture = TestBed.createComponent(SelectAllCheckboxComponent);
masterFixture.componentRef.setInput('id', 'q');
masterFixture.componentRef.setInput('list', new Map());
masterFixture.detectChanges();
const itemFixture = TestBed.createComponent(ItemCheckboxComponent);
itemFixture.componentRef.setInput('id', 'row1');
itemFixture.componentRef.setInput('master', masterFixture.componentInstance);
itemFixture.componentRef.setInput('checkable', { checked: false });
itemFixture.detectChanges();
expect(itemFixture.componentInstance).toBeTruthy();
});
it('reports the shift modifier from the click to the master', () => {
const masterFixture = TestBed.createComponent(SelectAllCheckboxComponent);
masterFixture.componentRef.setInput('id', 'q');
masterFixture.componentRef.setInput('list', new Map());
masterFixture.detectChanges();
const master = masterFixture.componentInstance;
const reported: [string | undefined, boolean | undefined][] = [];
master.selectionChanged = (id?: string, extend?: boolean) => {
reported.push([id, extend]);
};
const itemFixture = TestBed.createComponent(ItemCheckboxComponent);
itemFixture.componentRef.setInput('id', 'row1');
itemFixture.componentRef.setInput('master', master);
itemFixture.componentRef.setInput('checkable', { checked: false });
itemFixture.detectChanges();
const item = itemFixture.componentInstance;
item.clicked(new MouseEvent('click', { shiftKey: true }));
item.changed();
// The modifier must not stick to the next toggle.
item.changed();
expect(reported).toEqual([
['row1', true],
['row1', false],
]);
});
});
@@ -1,40 +1,22 @@
import { Component, input, ChangeDetectionStrategy } from '@angular/core';
import { SelectAllCheckboxComponent } from './master-checkbox.component';
import { Component, input } from '@angular/core';
import { MasterCheckboxComponent } from './master-checkbox.component';
import { Checkable } from '../interfaces';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-item-checkbox',
selector: 'app-slave-checkbox',
template: `
<div class="form-check">
<input type="checkbox" class="form-check-input" id="{{master().id()}}-{{id()}}-select" [(ngModel)]="checkable().checked" (click)="clicked($event)" (change)="changed()" [attr.aria-label]="'Select item ' + id()">
<label class="form-check-label visually-hidden" for="{{master().id()}}-{{id()}}-select">Select item</label>
<input type="checkbox" class="form-check-input" id="{{master().id()}}-{{id()}}-select" [(ngModel)]="checkable().checked" (change)="master().selectionChanged()">
<label class="form-check-label" for="{{master().id()}}-{{id()}}-select"></label>
</div>
`,
// Shared Checkable objects are mutated in place; Eager preserves pre-v22 behavior.
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
changeDetection: ChangeDetectionStrategy.Eager,
imports: [
imports: [
FormsModule
]
})
export class ItemCheckboxComponent {
export class SlaveCheckboxComponent {
readonly id = input.required<string>();
readonly master = input.required<SelectAllCheckboxComponent>();
readonly master = input.required<MasterCheckboxComponent>();
readonly checkable = input.required<Checkable>();
// click fires before change, so the modifier is recorded here and read once
// ngModel has written the new state into the checkable. Keyboard activation
// fires change without a click, which is a plain toggle.
private extend = false;
clicked(event: MouseEvent) {
this.extend = event.shiftKey;
}
changed() {
const extend = this.extend;
this.extend = false;
this.master().selectionChanged(this.id(), extend);
}
}
@@ -1,58 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faCheckCircle, faTimesCircle, faInfoCircle, faXmark } from '@fortawesome/free-solid-svg-icons';
import { ToastService } from '../services/toast.service';
@Component({
selector: 'app-toast-container',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FontAwesomeModule],
template: `
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1100;" aria-live="polite" aria-atomic="true">
@for (toast of toasts.toasts(); track toast.id) {
<div class="toast show align-items-center border-0 mb-2"
[class.text-bg-danger]="toast.level === 'error'"
[class.text-bg-success]="toast.level === 'success'"
[class.text-bg-primary]="toast.level === 'info'"
role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body d-flex align-items-start gap-2">
@if (toast.level === 'error') {
<fa-icon [icon]="faTimesCircle" class="mt-1" />
} @else if (toast.level === 'success') {
<fa-icon [icon]="faCheckCircle" class="mt-1" />
} @else {
<fa-icon [icon]="faInfoCircle" class="mt-1" />
}
<span style="white-space: pre-line;">{{ toast.message }}</span>
</div>
@if (!toast.actions) {
<button type="button" class="btn-close btn-close-white me-2 m-auto"
aria-label="Close" (click)="toasts.dismiss(toast.id)"></button>
}
</div>
@if (toast.actions) {
<div class="d-flex justify-content-end gap-2 px-3 pb-2">
@for (action of toast.actions; track action.label) {
<button type="button"
class="btn btn-sm"
[class.btn-light]="!action.primary"
[class.btn-outline-light]="action.primary"
(click)="toasts.respond(toast.id, action.value)">
{{ action.label }}
</button>
}
</div>
}
</div>
}
</div>
`,
})
export class ToastContainerComponent {
protected readonly toasts = inject(ToastService);
protected readonly faCheckCircle = faCheckCircle;
protected readonly faTimesCircle = faTimesCircle;
protected readonly faInfoCircle = faInfoCircle;
protected readonly faXmark = faXmark;
}
+2 -11
View File
@@ -3,24 +3,16 @@ export interface Download {
id: string;
title: string;
url: string;
download_type: string;
codec?: string;
quality: string;
format: string;
folder: string;
custom_name_prefix: string;
playlist_item_limit: number;
split_by_chapters?: boolean;
sponsorblock?: boolean;
chapter_template?: string;
subtitle_format?: string;
subtitle_language?: string;
subtitle_mode?: string;
ytdl_options_presets?: string[];
ytdl_options_overrides?: Record<string, unknown>;
clip_start?: number;
clip_end?: number;
live_status?: string;
live_release_timestamp?: number;
status: string;
msg: string;
percent: number;
@@ -28,9 +20,8 @@ export interface Download {
eta: number;
filename: string;
checked: boolean;
timestamp?: number;
size?: number;
error?: string;
deleting?: boolean;
chapter_files?: { filename: string, size: number }[];
chapter_files?: Array<{ filename: string, size: number }>;
}
+70 -66
View File
@@ -1,77 +1,81 @@
import { Quality } from "./quality";
import { Format } from "./format";
export interface Option {
id: string;
text: string;
}
export interface AudioFormatOption extends Option {
qualities: Quality[];
}
export const DOWNLOAD_TYPES: Option[] = [
{ id: "video", text: "Video" },
{ id: "audio", text: "Audio" },
{ id: "captions", text: "Captions" },
{ id: "thumbnail", text: "Thumbnail" },
];
export const VIDEO_CODECS: Option[] = [
{ id: "auto", text: "Auto" },
{ id: "h264", text: "H.264" },
{ id: "h265", text: "H.265 (HEVC)" },
{ id: "av1", text: "AV1" },
{ id: "vp9", text: "VP9" },
];
export const VIDEO_FORMATS: Option[] = [
{ id: "any", text: "Auto" },
{ id: "mp4", text: "MP4" },
{ id: "ios", text: "iOS Compatible" },
];
export const VIDEO_QUALITIES: Quality[] = [
{ id: "best", text: "Best" },
{ id: "2160", text: "2160p" },
{ id: "1440", text: "1440p" },
{ id: "1080", text: "1080p" },
{ id: "720", text: "720p" },
{ id: "480", text: "480p" },
{ id: "360", text: "360p" },
{ id: "240", text: "240p" },
{ id: "worst", text: "Worst" },
];
export const AUDIO_FORMATS: AudioFormatOption[] = [
export const Formats: Format[] = [
{
id: "m4a",
text: "M4A",
id: 'any',
text: 'Any',
qualities: [
{ id: "best", text: "Best" },
{ id: "192", text: "192 kbps" },
{ id: "128", text: "128 kbps" },
{ id: 'best', text: 'Best' },
{ id: '2160', text: '2160p' },
{ id: '1440', text: '1440p' },
{ id: '1080', text: '1080p' },
{ id: '720', text: '720p' },
{ id: '480', text: '480p' },
{ id: '360', text: '360p' },
{ id: '240', text: '240p' },
{ id: 'worst', text: 'Worst' },
{ id: 'audio', text: 'Audio Only' },
],
},
{
id: "mp3",
text: "MP3",
id: 'mp4',
text: 'MP4',
qualities: [
{ id: "best", text: "Best" },
{ id: "320", text: "320 kbps" },
{ id: "192", text: "192 kbps" },
{ id: "128", text: "128 kbps" },
{ id: 'best', text: 'Best' },
{ id: 'best_ios', text: 'Best (iOS)' },
{ id: '2160', text: '2160p' },
{ id: '1440', text: '1440p' },
{ id: '1080', text: '1080p' },
{ id: '720', text: '720p' },
{ id: '480', text: '480p' },
{ id: '360', text: '360p' },
{ id: '240', text: '240p' },
{ id: 'worst', text: 'Worst' },
],
},
{ id: "opus", text: "OPUS", qualities: [{ id: "best", text: "Best" }] },
{ id: "wav", text: "WAV", qualities: [{ id: "best", text: "Best" }] },
{ id: "flac", text: "FLAC", qualities: [{ id: "best", text: "Best" }] },
{
id: 'm4a',
text: 'M4A',
qualities: [
{ id: 'best', text: 'Best' },
{ id: '192', text: '192 kbps' },
{ id: '128', text: '128 kbps' },
],
},
{
id: 'mp3',
text: 'MP3',
qualities: [
{ id: 'best', text: 'Best' },
{ id: '320', text: '320 kbps' },
{ id: '192', text: '192 kbps' },
{ id: '128', text: '128 kbps' },
],
},
{
id: 'opus',
text: 'OPUS',
qualities: [{ id: 'best', text: 'Best' }],
},
{
id: 'wav',
text: 'WAV',
qualities: [{ id: 'best', text: 'Best' }],
},
{
id: 'flac',
text: 'FLAC',
qualities: [{ id: 'best', text: 'Best' }],
},
{
id: 'thumbnail',
text: 'Thumbnail',
qualities: [{ id: 'best', text: 'Best' }],
},
{
id: 'captions',
text: 'Captions',
qualities: [{ id: 'best', text: 'Best' }],
},
];
export const CAPTION_FORMATS: Option[] = [
{ id: "srt", text: "SRT" },
{ id: "txt", text: "TXT (Text only)" },
{ id: "vtt", text: "VTT" },
{ id: "ttml", text: "TTML" },
];
export const THUMBNAIL_FORMATS: Option[] = [{ id: "jpg", text: "JPG" }];
+1 -1
View File
@@ -6,4 +6,4 @@ export * from './download';
export * from './checkable';
export * from './format';
export * from './formats';
export * from './subscription';
-19
View File
@@ -1,19 +0,0 @@
export interface SubscriptionRow {
id: string;
name: string;
url: string;
enabled: boolean;
check_interval_minutes: number;
download_type: string;
codec: string;
format: string;
quality: string;
folder: string;
title_regex?: string;
skip_subscriber_only?: boolean;
clip_start?: number | null;
clip_end?: number | null;
last_checked: number | null;
seen_count: number;
error: string | null;
}
-26
View File
@@ -1,26 +0,0 @@
import { EtaPipe } from './eta.pipe';
describe('EtaPipe', () => {
it('returns null for null input', () => {
const pipe = new EtaPipe();
expect(pipe.transform(null as unknown as number)).toBeNull();
});
it('formats seconds under one minute', () => {
const pipe = new EtaPipe();
expect(pipe.transform(0)).toBe('0s');
expect(pipe.transform(59)).toBe('59s');
});
it('formats minutes and seconds', () => {
const pipe = new EtaPipe();
expect(pipe.transform(60)).toBe('1m 0s');
expect(pipe.transform(90)).toBe('1m 30s');
});
it('formats hours', () => {
const pipe = new EtaPipe();
expect(pipe.transform(3600)).toBe('1h 0m 0s');
expect(pipe.transform(3661)).toBe('1h 1m 1s');
});
});
-25
View File
@@ -1,25 +0,0 @@
import { FileSizePipe } from './file-size.pipe';
describe('FileSizePipe', () => {
it('returns 0 Bytes for zero or NaN', () => {
const pipe = new FileSizePipe();
expect(pipe.transform(0)).toBe('0 Bytes');
expect(pipe.transform(Number.NaN)).toBe('0 Bytes');
});
it('formats bytes and larger units', () => {
const pipe = new FileSizePipe();
expect(pipe.transform(500)).toContain('Bytes');
expect(pipe.transform(1000)).toContain('Bytes');
expect(pipe.transform(1024)).toContain('KB');
expect(pipe.transform(1024 ** 2)).toContain('MB');
expect(pipe.transform(1024 ** 3)).toContain('GB');
});
it('handles boundaries between units', () => {
const pipe = new FileSizePipe();
expect(pipe.transform(1023)).toContain('Bytes');
expect(pipe.transform(1024)).toContain('KB');
expect(pipe.transform(1025)).toContain('KB');
});
});
+2 -3
View File
@@ -8,10 +8,9 @@ export class FileSizePipe implements PipeTransform {
if (isNaN(value) || value === 0) return '0 Bytes';
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const k = 1024; // Matches SpeedPipe's base so file sizes and transfer speeds agree.
const unitIndex = Math.floor(Math.log(value) / Math.log(k));
const unitIndex = Math.floor(Math.log(value) / Math.log(1000)); // Use 1000 for common units
const unitValue = value / Math.pow(k, unitIndex);
const unitValue = value / Math.pow(1000, unitIndex);
return `${unitValue.toFixed(2)} ${units[unitIndex]}`;
}
}
-21
View File
@@ -1,21 +0,0 @@
import { SpeedPipe } from './speed.pipe';
describe('SpeedPipe', () => {
it('returns empty string for non-positive speed values', () => {
const pipe = new SpeedPipe();
expect(pipe.transform(0)).toBe('');
expect(pipe.transform(-1)).toBe('');
});
it('formats bytes per second values', () => {
const pipe = new SpeedPipe();
expect(pipe.transform(1024)).toBe('1 KB/s');
expect(pipe.transform(1536)).toBe('1.5 KB/s');
});
it('formats MB/s and GB/s', () => {
const pipe = new SpeedPipe();
expect(pipe.transform(1024 * 1024)).toBe('1 MB/s');
expect(pipe.transform(1024 * 1024 * 1024)).toBe('1 GB/s');
});
});
+31 -7
View File
@@ -1,19 +1,43 @@
import { Pipe, PipeTransform } from "@angular/core";
import { BehaviorSubject, throttleTime } from "rxjs";
@Pipe({
name: 'speed',
pure: true
pure: false // Make the pipe impure so it can handle async updates
})
export class SpeedPipe implements PipeTransform {
private speedSubject = new BehaviorSubject<number>(0);
private formattedSpeed = '';
constructor() {
// Throttle updates to once per second
this.speedSubject.pipe(
throttleTime(1000)
).subscribe(speed => {
// If speed is invalid or 0, return empty string
if (speed === null || speed === undefined || isNaN(speed) || speed <= 0) {
this.formattedSpeed = '';
return;
}
const k = 1024;
const dm = 2;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
const i = Math.floor(Math.log(speed) / Math.log(k));
this.formattedSpeed = parseFloat((speed / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
});
}
transform(value: number): string {
// If speed is invalid or 0, return empty string
if (value === null || value === undefined || isNaN(value) || value <= 0) {
return '';
}
const k = 1024;
const decimals = 2;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
const i = Math.floor(Math.log(value) / Math.log(k));
return `${parseFloat((value / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
// Update the speed subject
this.speedSubject.next(value);
// Return the last formatted speed
return this.formattedSpeed;
}
}
-62
View File
@@ -1,62 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { DownloadsService } from './downloads.service';
import { ToastService } from './toast.service';
export type BatchUrlFilter = 'pending' | 'completed' | 'failed' | 'all';
/**
* Encapsulates collecting download URLs by status and exporting/copying them.
* Extracted from the main app component to keep it focused on view concerns.
*/
@Injectable({ providedIn: 'root' })
export class BatchUrlsService {
private downloads = inject(DownloadsService);
private toasts = inject(ToastService);
collect(filter: BatchUrlFilter): string[] {
const queueUrls = () => Array.from(this.downloads.queue.values()).map((dl) => dl.url);
const doneUrls = (status?: string) =>
Array.from(this.downloads.done.values())
.filter((dl) => status === undefined || dl.status === status)
.map((dl) => dl.url);
switch (filter) {
case 'pending':
return queueUrls();
case 'completed':
return doneUrls('finished');
case 'failed':
return doneUrls('error');
default:
return [...queueUrls(), ...doneUrls()];
}
}
export(filter: BatchUrlFilter): void {
const urls = this.collect(filter);
if (!urls.length) {
this.toasts.info('No URLs found for the selected filter.');
return;
}
const blob = new Blob([urls.join('\n')], { type: 'text/plain' });
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = 'metube_urls.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
}
copy(filter: BatchUrlFilter): void {
const urls = this.collect(filter);
if (!urls.length) {
this.toasts.info('No URLs found for the selected filter.');
return;
}
navigator.clipboard
.writeText(urls.join('\n'))
.then(() => this.toasts.success('URLs copied to clipboard.'))
.catch(() => this.toasts.error('Failed to copy URLs.'));
}
}
@@ -1,385 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient, HttpErrorResponse } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { Subject } from 'rxjs';
import { DownloadsService, AddDownloadPayload } from './downloads.service';
import { MeTubeSocket } from './metube-socket.service';
import { Download } from '../interfaces';
class MeTubeSocketStub {
private subjects: Record<string, Subject<string>> = {};
fromEvent(event: string) {
if (!this.subjects[event]) {
this.subjects[event] = new Subject<string>();
}
return this.subjects[event].asObservable();
}
emit(event: string, data: string) {
if (!this.subjects[event]) {
this.subjects[event] = new Subject<string>();
}
this.subjects[event].next(data);
}
}
function basePayload(): AddDownloadPayload {
return {
url: 'https://example.com/v',
downloadType: 'video',
codec: 'auto',
quality: 'best',
format: 'any',
folder: '',
customNamePrefix: '',
playlistItemLimit: 0,
autoStart: true,
splitByChapters: false,
sponsorblock: false,
chapterTemplate: '',
subtitleLanguage: 'en',
subtitleMode: 'prefer_manual',
ytdlOptionsPresets: [],
ytdlOptionsOverrides: '',
clipStart: '',
clipEnd: '',
};
}
describe('DownloadsService', () => {
let socket: MeTubeSocketStub;
let httpMock: HttpTestingController;
let service: DownloadsService;
beforeEach(async () => {
socket = new MeTubeSocketStub();
await TestBed.configureTestingModule({
providers: [
DownloadsService,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: MeTubeSocket, useValue: socket },
],
}).compileComponents();
service = TestBed.inject(DownloadsService);
httpMock = TestBed.inject(HttpTestingController);
});
it('add() posts snake_case fields matching backend', () => {
service.add(basePayload()).subscribe();
const req = httpMock.expectOne('add');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(
expect.objectContaining({
url: 'https://example.com/v',
download_type: 'video',
codec: 'auto',
quality: 'best',
format: 'any',
playlist_item_limit: 0,
auto_start: true,
split_by_chapters: false,
chapter_template: '',
subtitle_language: 'en',
subtitle_mode: 'prefer_manual',
ytdl_options_presets: [],
ytdl_options_overrides: '',
}),
);
req.flush({ status: 'ok' });
});
it('add() sends clip_start and clip_end when set', () => {
service
.add({
...basePayload(),
clipStart: '1:00',
clipEnd: '2:00',
})
.subscribe();
const req = httpMock.expectOne('add');
expect(req.request.body).toEqual(
expect.objectContaining({
clip_start: '1:00',
clip_end: '2:00',
}),
);
req.flush({ status: 'ok' });
});
it('getPresets() fetches configured preset names', () => {
service.getPresets().subscribe((result) => {
expect(result).toEqual({ presets: ['Preset A'] });
});
const req = httpMock.expectOne('presets');
expect(req.request.method).toBe('GET');
req.flush({ presets: ['Preset A'] });
});
it('retry() posts the failed download id', () => {
service.retry('https://example.com/v').subscribe();
const req = httpMock.expectOne('retry');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ id: 'https://example.com/v' });
req.flush({ status: 'ok' });
});
it('cancelAdd posts to cancel-add', () => {
service.cancelAdd().subscribe();
const req = httpMock.expectOne('cancel-add');
expect(req.request.method).toBe('POST');
req.flush({ status: 'ok' });
});
it('startById posts ids', () => {
service.startById(['a', 'b']).subscribe();
const req = httpMock.expectOne('start');
expect(req.request.body).toEqual({ ids: ['a', 'b'] });
req.flush({});
});
it('delById marks items deleting and posts delete', () => {
const dl: Download = {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'finished',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
deleting: false,
};
service.queue.set('u1', dl);
service.delById('queue', ['u1']).subscribe();
expect(dl.deleting).toBe(true);
const req = httpMock.expectOne('delete');
expect(req.request.body).toEqual({ where: 'queue', ids: ['u1'] });
req.flush({});
});
it('delById resets deleting flag and emits error status on HTTP failure', () => {
const dl: Download = {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'finished',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
deleting: false,
};
service.queue.set('u1', dl);
let queueChangedCount = 0;
service.queueChanged.subscribe(() => queueChangedCount++);
let result: unknown;
let threw = false;
service.delById('queue', ['u1']).subscribe({
next: (res) => { result = res; },
error: () => { threw = true; },
});
expect(dl.deleting).toBe(true);
const req = httpMock.expectOne('delete');
req.flush({ msg: 'boom' }, { status: 500, statusText: 'Server Error' });
expect(threw).toBe(false);
expect(dl.deleting).toBe(false);
expect(queueChangedCount).toBeGreaterThan(0);
expect((result as { status: string }).status).toBe('error');
});
it('startById surfaces HTTP errors as a status object instead of throwing', () => {
let result: unknown;
let threw = false;
service.startById(['a']).subscribe({
next: (res) => { result = res; },
error: () => { threw = true; },
});
const req = httpMock.expectOne('start');
req.flush({ msg: 'nope' }, { status: 500, statusText: 'Server Error' });
expect(threw).toBe(false);
expect((result as { status: string }).status).toBe('error');
});
it('handleHTTPError extracts msg from object body', async () => {
const err = new HttpErrorResponse({
error: { msg: 'bad' },
status: 400,
});
const res = await new Promise((resolve) => {
service.handleHTTPError(err).subscribe(resolve);
});
expect((res as { status: string }).status).toBe('error');
expect((res as { msg?: string }).msg).toBe('bad');
});
it('socket all updates queue and done', () => {
const row: Download = {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'pending',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
};
const q: [string, Download][] = [['u1', row]];
const d: [string, Download][] = [];
socket.emit('all', JSON.stringify([q, d]));
expect(service.loading).toBe(false);
expect(service.queue.has('u1')).toBe(true);
});
it('socket updated preserves checked and deleting', () => {
service.queue.set('u1', {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'pending',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: true,
deleting: true,
});
socket.emit(
'updated',
JSON.stringify({ url: 'u1', title: 't', status: 'downloading' }),
);
const updated = service.queue.get('u1');
expect(updated?.checked).toBe(true);
expect(updated?.deleting).toBe(true);
});
it('socket updated ignores events for urls not already in the queue', () => {
expect(service.queue.has('unknown-url')).toBe(false);
socket.emit(
'updated',
JSON.stringify({ url: 'unknown-url', title: 't', status: 'downloading' }),
);
expect(service.queue.has('unknown-url')).toBe(false);
});
it('socket completed moves entry to done', () => {
service.queue.set('u1', {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'pending',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
});
socket.emit('completed', JSON.stringify({ url: 'u1', title: 't', status: 'finished' }));
expect(service.queue.has('u1')).toBe(false);
expect(service.done.has('u1')).toBe(true);
});
it('socket canceled removes from queue', () => {
service.queue.set('u1', {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'pending',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
});
socket.emit('canceled', JSON.stringify('u1'));
expect(service.queue.has('u1')).toBe(false);
});
it('socket cleared removes from done', () => {
service.done.set('u1', {
id: '1',
title: 't',
url: 'u1',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'finished',
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
});
socket.emit('cleared', JSON.stringify('u1'));
expect(service.done.has('u1')).toBe(false);
});
it('socket configuration updates configuration', () => {
socket.emit('configuration', JSON.stringify({ CUSTOM_DIRS: true }));
expect(service.configuration['CUSTOM_DIRS']).toBe(true);
});
it('socket custom_dirs updates customDirs', () => {
socket.emit('custom_dirs', JSON.stringify({ download_dir: [''] }));
expect(service.customDirs['download_dir']).toEqual(['']);
});
afterEach(() => {
httpMock.verify();
});
});
+95 -109
View File
@@ -5,27 +5,6 @@ import { catchError } from 'rxjs/operators';
import { MeTubeSocket } from './metube-socket.service';
import { Download, Status, State } from '../interfaces';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export interface AddDownloadPayload {
url: string;
downloadType: string;
codec: string;
quality: string;
format: string;
folder: string;
customNamePrefix: string;
playlistItemLimit: number;
autoStart: boolean;
splitByChapters: boolean;
sponsorblock: boolean;
chapterTemplate: string;
subtitleLanguage: string;
subtitleMode: string;
ytdlOptionsPresets: string[];
ytdlOptionsOverrides: string;
clipStart?: string;
clipEnd?: string;
}
@Injectable({
providedIn: 'root'
})
@@ -35,15 +14,16 @@ export class DownloadsService {
loading = true;
queue = new Map<string, Download>();
done = new Map<string, Download>();
queueChanged = new Subject<void>();
doneChanged = new Subject<void>();
customDirsChanged = new Subject<Record<string, string[]>>();
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
configurationChanged = new Subject<Record<string, unknown>>();
updated = new Subject<void>();
queueChanged = new Subject();
doneChanged = new Subject();
customDirsChanged = new Subject();
ytdlOptionsChanged = new Subject();
configurationChanged = new Subject();
updated = new Subject();
configuration: Record<string, unknown> = {};
customDirs: Record<string, string[]> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
configuration: any = {};
customDirs = {};
constructor() {
this.socket.fromEvent('all')
@@ -55,31 +35,25 @@ export class DownloadsService {
data[0].forEach(entry => this.queue.set(...entry));
this.done.clear();
data[1].forEach(entry => this.done.set(...entry));
this.queueChanged.next();
this.doneChanged.next();
this.queueChanged.next(null);
this.doneChanged.next(null);
});
this.socket.fromEvent('added')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
this.queue.set(data.url, data);
this.queueChanged.next();
this.queueChanged.next(null);
});
this.socket.fromEvent('updated')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
const dl: Download | undefined = this.queue.get(data.url);
// An 'added' event always precedes legitimate updates. If the row is
// gone (canceled/completed already processed), this update is stale —
// applying it would resurrect a ghost row until the next full refresh.
if (!dl) {
return;
}
data.checked = !!dl.checked;
data.deleting = !!dl.deleting;
data.checked = !!dl?.checked;
data.deleting = !!dl?.deleting;
this.queue.set(data.url, data);
this.updated.next();
this.updated.next(null);
});
this.socket.fromEvent('completed')
.pipe(takeUntilDestroyed())
@@ -87,22 +61,22 @@ export class DownloadsService {
const data: Download = JSON.parse(strdata);
this.queue.delete(data.url);
this.done.set(data.url, data);
this.queueChanged.next();
this.doneChanged.next();
this.queueChanged.next(null);
this.doneChanged.next(null);
});
this.socket.fromEvent('canceled')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: string = JSON.parse(strdata);
this.queue.delete(data);
this.queueChanged.next();
this.queueChanged.next(null);
});
this.socket.fromEvent('cleared')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: string = JSON.parse(strdata);
this.done.delete(data);
this.doneChanged.next();
this.doneChanged.next(null);
});
this.socket.fromEvent('configuration')
.pipe(takeUntilDestroyed())
@@ -129,58 +103,44 @@ export class DownloadsService {
}
handleHTTPError(error: HttpErrorResponse) {
const msg = error.error instanceof ErrorEvent
? error.error.message
: (typeof error.error === 'string'
? error.error
: (error.error?.msg || error.message || 'Request failed'));
return of({ status: 'error', msg });
const msg = error.error instanceof ErrorEvent ? error.error.message : error.error;
return of({status: 'error', msg: msg})
}
public add(payload: AddDownloadPayload) {
const body: Record<string, unknown> = {
url: payload.url,
download_type: payload.downloadType,
codec: payload.codec,
quality: payload.quality,
format: payload.format,
folder: payload.folder,
custom_name_prefix: payload.customNamePrefix,
playlist_item_limit: payload.playlistItemLimit,
auto_start: payload.autoStart,
split_by_chapters: payload.splitByChapters,
sponsorblock: payload.sponsorblock,
chapter_template: payload.chapterTemplate,
subtitle_language: payload.subtitleLanguage,
subtitle_mode: payload.subtitleMode,
ytdl_options_presets: payload.ytdlOptionsPresets,
ytdl_options_overrides: payload.ytdlOptionsOverrides,
};
const cs = payload.clipStart?.trim();
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)
);
}
public getPresets() {
return this.http.get<{ presets: string[] }>('presets').pipe(
catchError(() => of({ presets: [] }))
);
}
public retry(id: string) {
return this.http.post<Status>('retry', { id: id }).pipe(
public add(
url: string,
quality: string,
format: string,
folder: string,
customNamePrefix: string,
playlistItemLimit: number,
autoStart: boolean,
splitByChapters: boolean,
chapterTemplate: string,
subtitleFormat: string,
subtitleLanguage: string,
subtitleMode: string,
) {
return this.http.post<Status>('add', {
url: url,
quality: quality,
format: format,
folder: folder,
custom_name_prefix: customNamePrefix,
playlist_item_limit: playlistItemLimit,
auto_start: autoStart,
split_by_chapters: splitByChapters,
chapter_template: chapterTemplate,
subtitle_format: subtitleFormat,
subtitle_language: subtitleLanguage,
subtitle_mode: subtitleMode
}).pipe(
catchError(this.handleHTTPError)
);
}
public startById(ids: string[]) {
return this.http.post<Status>('start', {ids: ids}).pipe(
catchError(this.handleHTTPError)
);
return this.http.post('start', {ids: ids});
}
public delById(where: State, ids: string[]) {
@@ -193,22 +153,7 @@ export class DownloadsService {
}
}
}
return this.http.post<Status>('delete', {where: where, ids: ids}).pipe(
catchError((err: HttpErrorResponse) => {
// Request failed — the rows would otherwise stay disabled forever
// with no way to retry, since nothing ever clears `deleting`.
if (map) {
for (const id of ids) {
const obj = map.get(id);
if (obj) {
obj.deleting = false;
}
}
}
(where === 'queue' ? this.queueChanged : this.doneChanged).next();
return this.handleHTTPError(err);
})
);
return this.http.post('delete', {where: where, ids: ids});
}
public startByFilter(where: State, filter: (dl: Download) => boolean) {
@@ -222,6 +167,47 @@ export class DownloadsService {
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
return this.delById(where, ids);
}
public addDownloadByUrl(url: string): Promise<{
response: Status} | {
status: string;
msg?: string;
}> {
const defaultQuality = 'best';
const defaultFormat = 'mp4';
const defaultFolder = '';
const defaultCustomNamePrefix = '';
const defaultPlaylistItemLimit = 0;
const defaultAutoStart = true;
const defaultSplitByChapters = false;
const defaultChapterTemplate = this.configuration['OUTPUT_TEMPLATE_CHAPTER'];
const defaultSubtitleFormat = 'srt';
const defaultSubtitleLanguage = 'en';
const defaultSubtitleMode = 'prefer_manual';
return new Promise((resolve, reject) => {
this.add(
url,
defaultQuality,
defaultFormat,
defaultFolder,
defaultCustomNamePrefix,
defaultPlaylistItemLimit,
defaultAutoStart,
defaultSplitByChapters,
defaultChapterTemplate,
defaultSubtitleFormat,
defaultSubtitleLanguage,
defaultSubtitleMode,
)
.subscribe({
next: (response) => resolve(response),
error: (error) => reject(error)
});
});
}
public exportQueueUrls(): string[] {
return Array.from(this.queue.values()).map(download => download.url);
}
public cancelAdd() {
return this.http.post<Status>('cancel-add', {}).pipe(
catchError(this.handleHTTPError)
@@ -231,19 +217,19 @@ export class DownloadsService {
uploadCookies(file: File) {
const formData = new FormData();
formData.append('cookies', file);
return this.http.post<{ status: string; msg?: string }>('upload-cookies', formData).pipe(
return this.http.post<any>('upload-cookies', formData).pipe(
catchError(this.handleHTTPError)
);
}
deleteCookies() {
return this.http.post<{ status: string; msg?: string }>('delete-cookies', {}).pipe(
return this.http.post<any>('delete-cookies', {}).pipe(
catchError(this.handleHTTPError)
);
}
getCookieStatus() {
return this.http.get<{ status: string; has_cookies: boolean }>('cookie-status').pipe(
return this.http.get<any>('cookie-status').pipe(
catchError(this.handleHTTPError)
);
}
+2 -3
View File
@@ -1,4 +1,3 @@
export { DownloadsService } from './downloads.service';
export { MeTubeSocket } from './metube-socket.service';
export { ToastService } from './toast.service';
export { BatchUrlsService } from './batch-urls.service';
export { SpeedService } from './speed.service';
export { MeTubeSocket } from './metube-socket.service';
+39
View File
@@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, interval } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class SpeedService {
private speedBuffer = new BehaviorSubject<number[]>([]);
private readonly BUFFER_SIZE = 10; // Keep last 10 measurements (1 second at 100ms intervals)
// Observable that emits the mean speed every second
public meanSpeed$: Observable<number>;
constructor() {
// Calculate mean speed every second
this.meanSpeed$ = interval(1000).pipe(
map(() => {
const speeds = this.speedBuffer.value;
if (speeds.length === 0) return 0;
return speeds.reduce((sum, speed) => sum + speed, 0) / speeds.length;
})
);
}
// Add a new speed measurement
public addSpeedMeasurement(speed: number) {
const currentBuffer = this.speedBuffer.value;
const newBuffer = [...currentBuffer, speed].slice(-this.BUFFER_SIZE);
this.speedBuffer.next(newBuffer);
}
// Get the current mean speed
public getCurrentMeanSpeed(): number {
const speeds = this.speedBuffer.value;
if (speeds.length === 0) return 0;
return speeds.reduce((sum, speed) => sum + speed, 0) / speeds.length;
}
}
@@ -1,77 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { Subject } from 'rxjs';
import { SubscriptionsService, SubscribePayload } from './subscriptions.service';
import { MeTubeSocket } from './metube-socket.service';
class MeTubeSocketStub {
private subjects: Record<string, Subject<string>> = {};
fromEvent(event: string) {
if (!this.subjects[event]) {
this.subjects[event] = new Subject<string>();
}
return this.subjects[event].asObservable();
}
}
function basePayload(): SubscribePayload {
return {
url: 'https://example.com/channel',
downloadType: 'video',
codec: 'auto',
quality: 'best',
format: 'any',
folder: '',
customNamePrefix: '',
playlistItemLimit: 0,
autoStart: true,
splitByChapters: false,
sponsorblock: false,
chapterTemplate: '',
subtitleLanguage: 'en',
subtitleMode: 'prefer_manual',
ytdlOptionsPresets: [],
ytdlOptionsOverrides: '',
clipStart: '',
clipEnd: '',
checkIntervalMinutes: 60,
titleRegex: '',
skipSubscriberOnly: false,
};
}
describe('SubscriptionsService', () => {
let httpMock: HttpTestingController;
let service: SubscriptionsService;
beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [
SubscriptionsService,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: MeTubeSocket, useValue: new MeTubeSocketStub() },
],
}).compileComponents();
service = TestBed.inject(SubscriptionsService);
httpMock = TestBed.inject(HttpTestingController);
});
it('subscribe() carries the sponsorblock flag', () => {
service.subscribe({ ...basePayload(), sponsorblock: true }).subscribe();
const req = httpMock.expectOne('subscribe');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(expect.objectContaining({ sponsorblock: true }));
req.flush({ status: 'ok' });
});
it('subscribe() sends the flag off by default', () => {
service.subscribe(basePayload()).subscribe();
const req = httpMock.expectOne('subscribe');
expect(req.request.body).toEqual(expect.objectContaining({ sponsorblock: false }));
req.flush({ status: 'ok' });
});
});
@@ -1,149 +0,0 @@
import { DestroyRef, inject, Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { of, Subject } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MeTubeSocket } from './metube-socket.service';
import { SubscriptionRow } from '../interfaces/subscription';
import { Status } from '../interfaces';
import { AddDownloadPayload } from './downloads.service';
export interface SubscribePayload extends AddDownloadPayload {
checkIntervalMinutes: number;
titleRegex: string;
skipSubscriberOnly: boolean;
}
@Injectable({
providedIn: 'root',
})
export class SubscriptionsService {
private http = inject(HttpClient);
private socket = inject(MeTubeSocket);
private destroyRef = inject(DestroyRef);
subscriptions = new Map<string, SubscriptionRow>();
subscriptionsChanged = new Subject<void>();
private publishList(rows: SubscriptionRow[]) {
this.subscriptions.clear();
for (const row of rows) {
this.subscriptions.set(row.id, row);
}
this.subscriptionsChanged.next();
}
constructor() {
this.socket
.fromEvent('subscriptions_all')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((strdata: string) => {
const data: SubscriptionRow[] = JSON.parse(strdata);
this.publishList(data);
});
this.socket
.fromEvent('subscription_added')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((strdata: string) => {
const row: SubscriptionRow = JSON.parse(strdata);
this.subscriptions.set(row.id, row);
this.subscriptionsChanged.next();
});
this.socket
.fromEvent('subscription_updated')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((strdata: string) => {
const row: SubscriptionRow = JSON.parse(strdata);
this.subscriptions.set(row.id, row);
this.subscriptionsChanged.next();
});
this.socket
.fromEvent('subscription_removed')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((strdata: string) => {
const id: string = JSON.parse(strdata);
this.subscriptions.delete(id);
this.subscriptionsChanged.next();
});
}
handleHTTPError(error: HttpErrorResponse) {
const msg =
error.error instanceof ErrorEvent
? error.error.message
: typeof error.error === 'string'
? error.error
: error.error?.msg || error.message || 'Request failed';
return of({ status: 'error' as const, msg });
}
subscribe(payload: SubscribePayload) {
const body: Record<string, unknown> = {
url: payload.url,
download_type: payload.downloadType,
codec: payload.codec,
quality: payload.quality,
format: payload.format,
folder: payload.folder,
custom_name_prefix: payload.customNamePrefix,
playlist_item_limit: payload.playlistItemLimit,
auto_start: payload.autoStart,
split_by_chapters: payload.splitByChapters,
sponsorblock: payload.sponsorblock,
chapter_template: payload.chapterTemplate,
subtitle_language: payload.subtitleLanguage,
subtitle_mode: payload.subtitleMode,
ytdl_options_presets: payload.ytdlOptionsPresets,
ytdl_options_overrides: payload.ytdlOptionsOverrides,
check_interval_minutes: payload.checkIntervalMinutes,
title_regex: payload.titleRegex,
skip_subscriber_only: payload.skipSubscriberOnly,
};
// Send the clip fields only when actually filled in. The backend treats an
// absent field as "not requested", which is what stops a t= timestamp on the
// subscribed URL from clipping every future download.
const cs = payload.clipStart?.trim();
const ce = payload.clipEnd?.trim();
if (cs) body['clip_start'] = cs;
if (ce) body['clip_end'] = ce;
return this.http.post<Status>('subscribe', body).pipe(catchError((err) => this.handleHTTPError(err)));
}
delete(ids: string[]) {
return this.http.post('subscriptions/delete', { ids }).pipe(catchError((err) => this.handleHTTPError(err)));
}
update(
id: string,
changes: Partial<
Pick<
SubscriptionRow,
'enabled' | 'check_interval_minutes' | 'name' | 'title_regex' | 'skip_subscriber_only'
>
>,
) {
return this.http
.post('subscriptions/update', { id, ...changes })
.pipe(catchError((err) => this.handleHTTPError(err)));
}
checkNow(ids?: string[]) {
return this.http
.post('subscriptions/check', ids?.length ? { ids } : {})
.pipe(catchError((err) => this.handleHTTPError(err)));
}
fetchList() {
return this.http.get<SubscriptionRow[]>('subscriptions').pipe(catchError(() => of([])));
}
refreshList() {
return this.http.get<SubscriptionRow[]>('subscriptions').pipe(
tap((rows) => this.publishList(rows)),
catchError((err) => this.handleHTTPError(err)),
);
}
}
-86
View File
@@ -1,86 +0,0 @@
import { Injectable, signal } from '@angular/core';
export type ToastLevel = 'info' | 'success' | 'error';
export interface ToastAction {
label: string;
value: boolean;
primary?: boolean;
}
export interface Toast {
id: number;
level: ToastLevel;
message: string;
actions?: ToastAction[];
/** Resolver for confirm() toasts; resolved when the user picks an action or dismisses. */
_resolve?: (value: boolean) => void;
}
/**
* Lightweight non-blocking notification service. Replaces the blocking
* window.alert()/confirm() dialogs that previously littered the app component.
*/
@Injectable({ providedIn: 'root' })
export class ToastService {
private counter = 0;
readonly toasts = signal<Toast[]>([]);
info(message: string): void {
this.show('info', message, 4000);
}
success(message: string): void {
this.show('success', message, 4000);
}
error(message: string): void {
this.show('error', message, 8000);
}
/**
* Show a confirmation toast with confirm/cancel actions. Resolves true when
* confirmed, false when cancelled or auto-dismissed.
*/
confirm(message: string, confirmLabel = 'OK', cancelLabel = 'Cancel'): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const id = ++this.counter;
this.toasts.update((list) => [
...list,
{
id,
level: 'info',
message,
actions: [
{ label: cancelLabel, value: false },
{ label: confirmLabel, value: true, primary: true },
],
_resolve: resolve,
},
]);
});
}
respond(id: number, value: boolean): void {
const toast = this.toasts().find((t) => t.id === id);
toast?._resolve?.(value);
this.remove(id);
}
dismiss(id: number): void {
const toast = this.toasts().find((t) => t.id === id);
// A confirm toast dismissed without an explicit choice resolves to false.
toast?._resolve?.(false);
this.remove(id);
}
private remove(id: number): void {
this.toasts.update((list) => list.filter((t) => t.id !== id));
}
private show(level: ToastLevel, message: string, autoDismissMs: number): void {
const id = ++this.counter;
this.toasts.update((list) => [...list, { id, level, message }]);
setTimeout(() => this.remove(id), autoDismissMs);
}
}
-19
View File
@@ -5,22 +5,3 @@
[data-bs-theme="dark"] &
background-color: var(--bs-dark-bg-subtle) !important
.ng-select
flex: 1
.ng-select-container
min-height: 38px
.ng-value
white-space: nowrap
overflow: visible
.ng-dropdown-panel
.ng-dropdown-panel-items
max-height: 300px
.ng-option
white-space: nowrap
overflow: visible
text-overflow: ellipsis
+1 -9
View File
@@ -12,13 +12,5 @@
],
"exclude": [
"src/**/*.spec.ts"
],
"angularCompilerOptions": {
"extendedDiagnostics": {
"checks": {
"nullishCoalescingNotNullable": "suppress",
"optionalChainNotNullable": "suppress"
}
}
}
]
}
Generated
+495 -856
View File
File diff suppressed because it is too large Load Diff