mirror of
https://github.com/alexta69/metube.git
synced 2026-06-13 16:40:05 +00:00
Compare commits
77 Commits
2026.01.09
...
2026.04.02
| Author | SHA1 | Date | |
|---|---|---|---|
| b4d497f53d | |||
| 0cba61c9a4 | |||
| 9858157581 | |||
| d7eaaaa94b | |||
| 771ba52d53 | |||
| 1cc27d3f55 | |||
| 981e6c1003 | |||
| b17e1e5668 | |||
| c1b5540332 | |||
| 483575d24a | |||
| 84c6418f91 | |||
| a1f2fe3e73 | |||
| 0bf508dbc6 | |||
| 104d547150 | |||
| 289133e507 | |||
| 7fa1fc7938 | |||
| 04959a6189 | |||
| 8b0d682b35 | |||
| 475aeb91bf | |||
| 5c321bfaca | |||
| 56826d33fd | |||
| 3b0eaad67e | |||
| 2a166ccf1f | |||
| 3bbe1e8424 | |||
| a2740375be | |||
| 2736425e19 | |||
| 0d905c0b61 | |||
| 6de4a56f28 | |||
| 1f4c4df847 | |||
| d211f24e00 | |||
| 13acd5b309 | |||
| fc5f8cf8ca | |||
| 4565d5abb3 | |||
| 54e25484c5 | |||
| 7cfb0c3a1d | |||
| d2e6c079f9 | |||
| 3587098e80 | |||
| 1915bdfc46 | |||
| 58c317f7cd | |||
| 880eda8435 | |||
| fd3aaea9d9 | |||
| da84753e20 | |||
| 7427cbb0c0 | |||
| 053e41cf52 | |||
| 77da359234 | |||
| 8dff6448b2 | |||
| dd4e05325a | |||
| ce9703cd04 | |||
| 973a87ffc6 | |||
| e24890fd9b | |||
| 5170c708cd | |||
| 56258a4f1b | |||
| 3bf7fb51f4 | |||
| 8ae06c65d0 | |||
| 97378d8704 | |||
| de7e1418b5 | |||
| f47e5db284 | |||
| 76bdb376c3 | |||
| 9896ce6820 | |||
| 79d0c3895e | |||
| ffe1112dc6 | |||
| 393add34b1 | |||
| 96e1863a68 | |||
| 46fbf92c00 | |||
| 297cac378c | |||
| 9df7776c79 | |||
| c28cedacb7 | |||
| a77043bde9 | |||
| 3ce9021143 | |||
| c7ce543704 | |||
| 6b9461c8a8 | |||
| 38a77d19f5 | |||
| 6a9098ab32 | |||
| b179535711 | |||
| 3f1b89e04a | |||
| 846c4f0e52 | |||
| c13431c10d |
@@ -6,38 +6,80 @@ on:
|
||||
- 'master'
|
||||
|
||||
jobs:
|
||||
quality-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
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@v6
|
||||
- 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@0.35.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 "::set-output name=date::$(date +'%Y.%m.%d')"
|
||||
run: echo "date=$(date +'%Y.%m.%d')" >> "$GITHUB_OUTPUT"
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
# dependencies
|
||||
/ui/node_modules
|
||||
/ui/package-lock.json
|
||||
|
||||
# profiling files
|
||||
chrome-profiler-events*.json
|
||||
|
||||
+44
-12
@@ -3,10 +3,10 @@ FROM node:lts-alpine AS builder
|
||||
WORKDIR /metube
|
||||
COPY ui ./
|
||||
RUN corepack enable && corepack prepare pnpm --activate
|
||||
RUN pnpm install && pnpm run build
|
||||
RUN CI=true pnpm install && pnpm run build
|
||||
|
||||
|
||||
FROM python:3.13-alpine
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -16,28 +16,60 @@ COPY pyproject.toml uv.lock docker-entrypoint.sh ./
|
||||
# Install dependencies
|
||||
RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
|
||||
chmod +x docker-entrypoint.sh && \
|
||||
apk add --update ffmpeg aria2 coreutils shadow su-exec curl tini deno gdbm-tools sqlite file && \
|
||||
apk add --update --virtual .build-deps gcc g++ musl-dev uv && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
ffmpeg \
|
||||
unzip \
|
||||
aria2 \
|
||||
coreutils \
|
||||
gosu \
|
||||
curl \
|
||||
tini \
|
||||
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 && \
|
||||
apk del .build-deps && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
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 && \
|
||||
apt-get purge -y --auto-remove build-essential && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir /.cache && chmod 777 /.cache
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN BGUTIL_TAG="$(curl -Ls -o /dev/null -w '%{url_effective}' https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/latest | sed 's#.*/tag/##')" && \
|
||||
case "$TARGETARCH" in \
|
||||
amd64) BGUTIL_ARCH="x86_64" ;; \
|
||||
arm64) BGUTIL_ARCH="aarch64" ;; \
|
||||
*) echo "Unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \
|
||||
esac && \
|
||||
curl -L -o /usr/local/bin/bgutil-pot \
|
||||
"https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/download/${BGUTIL_TAG}/bgutil-pot-linux-${BGUTIL_ARCH}" && \
|
||||
chmod +x /usr/local/bin/bgutil-pot && \
|
||||
PLUGIN_DIR="$(python3 -c 'import site; print(site.getsitepackages()[0])')" && \
|
||||
curl -L -o /tmp/bgutil-ytdlp-pot-provider-rs.zip \
|
||||
"https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases/download/${BGUTIL_TAG}/bgutil-ytdlp-pot-provider-rs.zip" && \
|
||||
unzip -q /tmp/bgutil-ytdlp-pot-provider-rs.zip -d "${PLUGIN_DIR}" && \
|
||||
rm /tmp/bgutil-ytdlp-pot-provider-rs.zip
|
||||
|
||||
COPY app ./app
|
||||
COPY --from=builder /metube/dist/metube ./ui/dist/metube
|
||||
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV PUID=1000
|
||||
ENV PGID=1000
|
||||
ENV UMASK=022
|
||||
|
||||
ENV DOWNLOAD_DIR /downloads
|
||||
ENV STATE_DIR /downloads/.metube
|
||||
ENV TEMP_DIR /downloads
|
||||
ENV DOWNLOAD_DIR=/downloads
|
||||
ENV STATE_DIR=/downloads/.metube
|
||||
ENV TEMP_DIR=/downloads
|
||||
ENV PORT=8081
|
||||
VOLUME /downloads
|
||||
EXPOSE 8081
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS "http://localhost:${PORT}/" || exit 1
|
||||
|
||||
# Add build-time argument for version
|
||||
ARG VERSION=dev
|
||||
ENV METUBE_VERSION=$VERSION
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "-g", "--", "./docker-entrypoint.sh"]
|
||||
ENTRYPOINT ["/usr/bin/tini", "-g", "--", "./docker-entrypoint.sh"]
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||

|
||||

|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
Key capabilities:
|
||||
* Download videos, audio, captions, and thumbnails from a browser UI.
|
||||
* Download playlists and channels, with configurable output and download options.
|
||||
* Subscribe to channels and playlists, periodically check for new items, and queue new uploads automatically.
|
||||
|
||||

|
||||
|
||||
@@ -36,6 +41,10 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||
* __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
|
||||
|
||||
@@ -45,16 +54,18 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||
* __CREATE_CUSTOM_DIRS__: Whether to support automatically creating directories within the __DOWNLOAD_DIR__ (or __AUDIO_DOWNLOAD_DIR__) if they do not exist. When enabled, the download directory selector supports free-text input, and the specified directory will be created recursively. Defaults to `true`.
|
||||
* __CUSTOM_DIRS_EXCLUDE_REGEX__: Regular expression to exclude some custom directories from the dropdown. Empty regex disables exclusion. Defaults to `(^|/)[.@].*$`, which means directories starting with `.` or `@`.
|
||||
* __DOWNLOAD_DIRS_INDEXABLE__: If `true`, the download directories (__DOWNLOAD_DIR__ and __AUDIO_DOWNLOAD_DIR__) are indexable on the web server. Defaults to `false`.
|
||||
* __STATE_DIR__: Path to where the queue persistence files will be saved. Defaults to `/downloads/.metube` in the Docker image, and `.` otherwise.
|
||||
* __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.
|
||||
* __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
|
||||
* Set this to an SSD or RAM filesystem (e.g., `tmpfs`) for better performance.
|
||||
* __Note__: Using a RAM filesystem may prevent downloads from being resumed.
|
||||
* __CHOWN_DIRS__: If `false`, ownership of `DOWNLOAD_DIR`, `STATE_DIR`, and `TEMP_DIR` (and their contents) will not be set on container start. Ensure user under which MeTube runs has necessary access to these directories already. Defaults to `true`.
|
||||
|
||||
### 📝 File Naming & yt-dlp
|
||||
|
||||
* __OUTPUT_TEMPLATE__: The template for the filenames of the downloaded videos, formatted according to [this spec](https://github.com/yt-dlp/yt-dlp/blob/master/README.md#output-template). Defaults to `%(title)s.%(ext)s`.
|
||||
* __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`. When empty, then `OUTPUT_TEMPLATE` is used.
|
||||
* __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 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.
|
||||
|
||||
@@ -72,8 +83,8 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||
|
||||
### 🏠 Basic Setup
|
||||
|
||||
* __UID__: User under which MeTube will run. Defaults to `1000`.
|
||||
* __GID__: Group under which MeTube will run. Defaults to `1000`.
|
||||
* __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`.
|
||||
@@ -87,21 +98,13 @@ The project's Wiki contains examples of useful configurations contributed by use
|
||||
|
||||
In case you need to use your browser's cookies with MeTube, for example to download restricted or private videos:
|
||||
|
||||
* Add the following to your docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/cookies:/cookies
|
||||
environment:
|
||||
- YTDL_OPTIONS={"cookiefile":"/cookies/cookies.txt"}
|
||||
```
|
||||
|
||||
* Install in your browser an extension to extract cookies:
|
||||
* [Firefox](https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/)
|
||||
* [Chrome](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
||||
* Extract the cookies you need with the extension and rename the file `cookies.txt`
|
||||
* Drop the file in the folder you configured in the docker-compose.yml above
|
||||
* Restart the container
|
||||
* Extract the cookies you need with the extension and save/export them as `cookies.txt`.
|
||||
* In MeTube, open **Advanced Options** and use the **Upload Cookies** button to upload the file.
|
||||
* After upload, the cookie indicator should show as active.
|
||||
* Use **Delete Cookies** in the same section to remove uploaded cookies.
|
||||
|
||||
## 🔌 Browser extensions
|
||||
|
||||
|
||||
+95
-36
@@ -1,75 +1,108 @@
|
||||
import copy
|
||||
|
||||
AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac")
|
||||
CAPTION_MODES = ("auto_only", "manual_only", "prefer_manual", "prefer_auto")
|
||||
|
||||
CODEC_FILTER_MAP = {
|
||||
'h264': "[vcodec~='^(h264|avc)']",
|
||||
'h265': "[vcodec~='^(h265|hevc)']",
|
||||
'av1': "[vcodec~='^av0?1']",
|
||||
'vp9': "[vcodec~='^vp0?9']",
|
||||
}
|
||||
|
||||
|
||||
def get_format(format: str, quality: str) -> str:
|
||||
def _normalize_caption_mode(mode: str) -> str:
|
||||
mode = (mode or "").strip()
|
||||
return mode if mode in CAPTION_MODES else "prefer_manual"
|
||||
|
||||
|
||||
def _normalize_subtitle_language(language: str) -> str:
|
||||
language = (language or "").strip()
|
||||
return language or "en"
|
||||
|
||||
|
||||
def get_format(download_type: str, codec: str, format: str, quality: str) -> str:
|
||||
"""
|
||||
Returns format for download
|
||||
Returns yt-dlp format selector.
|
||||
|
||||
Args:
|
||||
format (str): format selected
|
||||
quality (str): quality selected
|
||||
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
|
||||
|
||||
Raises:
|
||||
Exception: unknown quality, unknown format
|
||||
Exception: unknown type/format
|
||||
|
||||
Returns:
|
||||
dl_format: Formatted download string
|
||||
str: yt-dlp format selector
|
||||
"""
|
||||
format = format or "any"
|
||||
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()
|
||||
|
||||
if format.startswith("custom:"):
|
||||
return format[7:]
|
||||
|
||||
if format == "thumbnail":
|
||||
# Quality is irrelevant in this case since we skip the download
|
||||
if download_type == "thumbnail":
|
||||
return "bestaudio/best"
|
||||
|
||||
if format in AUDIO_FORMATS:
|
||||
# Audio quality needs to be set post-download, set in opts
|
||||
if download_type == "captions":
|
||||
return "bestaudio/best"
|
||||
|
||||
if download_type == "audio":
|
||||
if format not in AUDIO_FORMATS:
|
||||
raise ValueError(f"Unknown audio format {format}")
|
||||
return f"bestaudio[ext={format}]/bestaudio/best"
|
||||
|
||||
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 ""
|
||||
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 ""
|
||||
vcombo = vres + vfmt
|
||||
codec_filter = CODEC_FILTER_MAP.get(codec, "")
|
||||
|
||||
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.
|
||||
if format == "ios":
|
||||
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 Exception(f"Unkown format {format}")
|
||||
raise ValueError(f"Unknown download_type {download_type}")
|
||||
|
||||
|
||||
def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict:
|
||||
def get_opts(
|
||||
download_type: str,
|
||||
_codec: str,
|
||||
format: str,
|
||||
quality: str,
|
||||
ytdl_opts: dict,
|
||||
subtitle_language: str = "en",
|
||||
subtitle_mode: str = "prefer_manual",
|
||||
) -> dict:
|
||||
"""
|
||||
Returns extra download options
|
||||
Mostly postprocessing options
|
||||
Returns extra yt-dlp options/postprocessors.
|
||||
|
||||
Args:
|
||||
format (str): format selected
|
||||
quality (str): quality of format selected (needed for some formats)
|
||||
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
|
||||
ytdl_opts (dict): current options selected
|
||||
|
||||
Returns:
|
||||
ytdl_opts: Extra options
|
||||
dict: extended options
|
||||
"""
|
||||
|
||||
download_type = (download_type or "video").strip().lower()
|
||||
format = (format or "any").strip().lower()
|
||||
opts = copy.deepcopy(ytdl_opts)
|
||||
|
||||
postprocessors = []
|
||||
|
||||
if format in AUDIO_FORMATS:
|
||||
if download_type == "audio":
|
||||
postprocessors.append(
|
||||
{
|
||||
"key": "FFmpegExtractAudio",
|
||||
@@ -78,8 +111,7 @@ def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict:
|
||||
}
|
||||
)
|
||||
|
||||
# Audio formats without thumbnail
|
||||
if format not in ("wav") and "writethumbnail" not in opts:
|
||||
if format != "wav" and "writethumbnail" not in opts:
|
||||
opts["writethumbnail"] = True
|
||||
postprocessors.append(
|
||||
{
|
||||
@@ -91,13 +123,40 @@ def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict:
|
||||
postprocessors.append({"key": "FFmpegMetadata"})
|
||||
postprocessors.append({"key": "EmbedThumbnail"})
|
||||
|
||||
if format == "thumbnail":
|
||||
if download_type == "thumbnail":
|
||||
opts["skip_download"] = True
|
||||
opts["writethumbnail"] = True
|
||||
postprocessors.append(
|
||||
{"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"}
|
||||
)
|
||||
|
||||
if download_type == "captions":
|
||||
mode = _normalize_caption_mode(subtitle_mode)
|
||||
language = _normalize_subtitle_language(subtitle_language)
|
||||
opts["skip_download"] = True
|
||||
requested_subtitle_format = (format or "srt").lower()
|
||||
if requested_subtitle_format == "txt":
|
||||
requested_subtitle_format = "srt"
|
||||
opts["subtitlesformat"] = requested_subtitle_format
|
||||
if mode == "manual_only":
|
||||
opts["writesubtitles"] = True
|
||||
opts["writeautomaticsub"] = False
|
||||
opts["subtitleslangs"] = [language]
|
||||
elif mode == "auto_only":
|
||||
opts["writesubtitles"] = False
|
||||
opts["writeautomaticsub"] = True
|
||||
# `-orig` captures common YouTube auto-sub tags. The plain language
|
||||
# fallback keeps behavior useful across other extractors.
|
||||
opts["subtitleslangs"] = [f"{language}-orig", language]
|
||||
elif mode == "prefer_auto":
|
||||
opts["writesubtitles"] = True
|
||||
opts["writeautomaticsub"] = True
|
||||
opts["subtitleslangs"] = [f"{language}-orig", language]
|
||||
else:
|
||||
opts["writesubtitles"] = True
|
||||
opts["writeautomaticsub"] = True
|
||||
opts["subtitleslangs"] = [language, f"{language}-orig"]
|
||||
|
||||
opts["postprocessors"] = postprocessors + (
|
||||
opts["postprocessors"] if "postprocessors" in opts else []
|
||||
)
|
||||
|
||||
+463
-38
@@ -16,25 +16,16 @@ import pathlib
|
||||
import re
|
||||
from watchfiles import DefaultFilter, Change, awatch
|
||||
|
||||
from ytdl import DownloadQueueNotifier, DownloadQueue
|
||||
from ytdl import DownloadQueueNotifier, DownloadQueue, Download
|
||||
from subscriptions import SubscriptionManager, SubscriptionNotifier, SubscriptionInfo
|
||||
from yt_dlp.version import __version__ as yt_dlp_version
|
||||
|
||||
log = logging.getLogger('main')
|
||||
|
||||
def parseLogLevel(logLevel):
|
||||
match logLevel:
|
||||
case 'DEBUG':
|
||||
return logging.DEBUG
|
||||
case 'INFO':
|
||||
return logging.INFO
|
||||
case 'WARNING':
|
||||
return logging.WARNING
|
||||
case 'ERROR':
|
||||
return logging.ERROR
|
||||
case 'CRITICAL':
|
||||
return logging.CRITICAL
|
||||
case _:
|
||||
return None
|
||||
if not isinstance(logLevel, str):
|
||||
return None
|
||||
return getattr(logging, logLevel.upper(), None)
|
||||
|
||||
# Configure logging before Config() uses it so early messages are not dropped.
|
||||
# Only configure if no handlers are set (avoid clobbering hosting app settings).
|
||||
@@ -58,7 +49,12 @@ class Config:
|
||||
'OUTPUT_TEMPLATE': '%(title)s.%(ext)s',
|
||||
'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)02d - %(section_title)s.%(ext)s',
|
||||
'OUTPUT_TEMPLATE_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s',
|
||||
'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s',
|
||||
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0',
|
||||
'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL': '60',
|
||||
'SUBSCRIPTION_SCAN_PLAYLIST_END': '50',
|
||||
'SUBSCRIPTION_MAX_SEEN_IDS': '50000',
|
||||
'CLEAR_COMPLETED_AFTER': '0',
|
||||
'YTDL_OPTIONS': '{}',
|
||||
'YTDL_OPTIONS_FILE': '',
|
||||
'ROBOTS_TXT': '',
|
||||
@@ -69,7 +65,7 @@ class Config:
|
||||
'KEYFILE': '',
|
||||
'BASE_DIR': '',
|
||||
'DEFAULT_THEME': 'auto',
|
||||
'MAX_CONCURRENT_DOWNLOADS': 3,
|
||||
'MAX_CONCURRENT_DOWNLOADS': '3',
|
||||
'LOGLEVEL': 'INFO',
|
||||
'ENABLE_ACCESSLOG': 'false',
|
||||
}
|
||||
@@ -96,10 +92,43 @@ class Config:
|
||||
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
|
||||
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
|
||||
|
||||
self._runtime_overrides = {}
|
||||
|
||||
success,_ = self.load_ytdl_options()
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
|
||||
def set_runtime_override(self, key, value):
|
||||
self._runtime_overrides[key] = value
|
||||
self.YTDL_OPTIONS[key] = value
|
||||
|
||||
def remove_runtime_override(self, key):
|
||||
self._runtime_overrides.pop(key, None)
|
||||
self.YTDL_OPTIONS.pop(key, None)
|
||||
|
||||
def _apply_runtime_overrides(self):
|
||||
self.YTDL_OPTIONS.update(self._runtime_overrides)
|
||||
|
||||
# Keys sent to the browser. Sensitive or server-only keys (YTDL_OPTIONS,
|
||||
# paths, TLS config, etc.) are intentionally excluded.
|
||||
_FRONTEND_KEYS = (
|
||||
'CUSTOM_DIRS',
|
||||
'CREATE_CUSTOM_DIRS',
|
||||
'OUTPUT_TEMPLATE_CHAPTER',
|
||||
'PUBLIC_HOST_URL',
|
||||
'PUBLIC_HOST_AUDIO_URL',
|
||||
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT',
|
||||
'SUBSCRIPTION_DEFAULT_CHECK_INTERVAL',
|
||||
)
|
||||
|
||||
def frontend_safe(self) -> dict:
|
||||
"""Return only the config keys that are safe to expose to browser clients.
|
||||
|
||||
Sensitive or server-only keys (YTDL_OPTIONS, file-system paths, TLS
|
||||
settings, etc.) are intentionally excluded.
|
||||
"""
|
||||
return {k: getattr(self, k) for k in self._FRONTEND_KEYS}
|
||||
|
||||
def load_ytdl_options(self) -> tuple[bool, str]:
|
||||
try:
|
||||
self.YTDL_OPTIONS = json.loads(os.environ.get('YTDL_OPTIONS', '{}'))
|
||||
@@ -110,6 +139,7 @@ class Config:
|
||||
return (False, msg)
|
||||
|
||||
if not self.YTDL_OPTIONS_FILE:
|
||||
self._apply_runtime_overrides()
|
||||
return (True, '')
|
||||
|
||||
log.info(f'Loading yt-dlp custom options from "{self.YTDL_OPTIONS_FILE}"')
|
||||
@@ -127,6 +157,7 @@ class Config:
|
||||
return (False, msg)
|
||||
|
||||
self.YTDL_OPTIONS.update(opts)
|
||||
self._apply_runtime_overrides()
|
||||
return (True, '')
|
||||
|
||||
config = Config()
|
||||
@@ -145,7 +176,7 @@ class ObjectSerializer(json.JSONEncoder):
|
||||
elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes)):
|
||||
try:
|
||||
return list(obj)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
# Fall back to default behavior
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
@@ -155,6 +186,71 @@ app = web.Application()
|
||||
sio = socketio.AsyncServer(cors_allowed_origins='*')
|
||||
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
|
||||
routes = web.RouteTableDef()
|
||||
VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'}
|
||||
VALID_SUBTITLE_MODES = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'}
|
||||
SUBTITLE_LANGUAGE_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9-]{0,34}$')
|
||||
VALID_DOWNLOAD_TYPES = {'video', 'audio', 'captions', 'thumbnail'}
|
||||
VALID_VIDEO_CODECS = {'auto', 'h264', 'h265', 'av1', 'vp9'}
|
||||
VALID_VIDEO_FORMATS = {'any', 'mp4', 'ios'}
|
||||
VALID_AUDIO_FORMATS = {'m4a', 'mp3', 'opus', 'wav', 'flac'}
|
||||
VALID_THUMBNAIL_FORMATS = {'jpg'}
|
||||
|
||||
|
||||
def _migrate_legacy_request(post: dict) -> dict:
|
||||
"""
|
||||
BACKWARD COMPATIBILITY: Translate old API request schema into the new one.
|
||||
|
||||
Old API:
|
||||
format (any/mp4/m4a/mp3/opus/wav/flac/thumbnail/captions)
|
||||
quality
|
||||
video_codec
|
||||
subtitle_format (only when format=captions)
|
||||
|
||||
New API:
|
||||
download_type (video/audio/captions/thumbnail)
|
||||
codec
|
||||
format
|
||||
quality
|
||||
"""
|
||||
if "download_type" in post:
|
||||
return post
|
||||
|
||||
old_format = str(post.get("format") or "any").strip().lower()
|
||||
old_quality = str(post.get("quality") or "best").strip().lower()
|
||||
old_video_codec = str(post.get("video_codec") or "auto").strip().lower()
|
||||
|
||||
if old_format in VALID_AUDIO_FORMATS:
|
||||
post["download_type"] = "audio"
|
||||
post["codec"] = "auto"
|
||||
post["format"] = old_format
|
||||
elif old_format == "thumbnail":
|
||||
post["download_type"] = "thumbnail"
|
||||
post["codec"] = "auto"
|
||||
post["format"] = "jpg"
|
||||
post["quality"] = "best"
|
||||
elif old_format == "captions":
|
||||
post["download_type"] = "captions"
|
||||
post["codec"] = "auto"
|
||||
post["format"] = str(post.get("subtitle_format") or "srt").strip().lower()
|
||||
post["quality"] = "best"
|
||||
else:
|
||||
# old_format is usually any/mp4 (legacy video path)
|
||||
post["download_type"] = "video"
|
||||
post["codec"] = old_video_codec
|
||||
if old_quality == "best_ios":
|
||||
post["format"] = "ios"
|
||||
post["quality"] = "best"
|
||||
elif old_quality == "audio":
|
||||
# Legacy "audio only" under video format maps to m4a audio.
|
||||
post["download_type"] = "audio"
|
||||
post["codec"] = "auto"
|
||||
post["format"] = "m4a"
|
||||
post["quality"] = "best"
|
||||
else:
|
||||
post["format"] = old_format
|
||||
post["quality"] = old_quality
|
||||
|
||||
return post
|
||||
|
||||
class Notifier(DownloadQueueNotifier):
|
||||
async def added(self, dl):
|
||||
@@ -179,6 +275,35 @@ class Notifier(DownloadQueueNotifier):
|
||||
|
||||
dqueue = DownloadQueue(config, Notifier())
|
||||
app.on_startup.append(lambda app: dqueue.initialize())
|
||||
app.on_cleanup.append(lambda app: Download.shutdown_manager())
|
||||
|
||||
|
||||
class MetubeSubscriptionNotifier(SubscriptionNotifier):
|
||||
async def subscription_added(self, sub: SubscriptionInfo):
|
||||
log.info("Subscription added: %s", sub.name)
|
||||
await sio.emit('subscription_added', serializer.encode(sub.to_public_dict()))
|
||||
|
||||
async def subscription_updated(self, sub: SubscriptionInfo):
|
||||
await sio.emit('subscription_updated', serializer.encode(sub.to_public_dict()))
|
||||
|
||||
async def subscription_removed(self, sub_id: str):
|
||||
log.info("Subscription removed: %s", sub_id)
|
||||
await sio.emit('subscription_removed', serializer.encode(sub_id))
|
||||
|
||||
async def subscriptions_all(self, subs: list[SubscriptionInfo]):
|
||||
await sio.emit('subscriptions_all', serializer.encode([s.to_public_dict() for s in subs]))
|
||||
|
||||
|
||||
submgr = SubscriptionManager(config, dqueue, MetubeSubscriptionNotifier())
|
||||
app.on_cleanup.append(lambda app: submgr.close())
|
||||
|
||||
|
||||
async def _subscription_loop_startup(app):
|
||||
"""aiohttp on_startup requires awaitable receivers; start_background_loop is sync."""
|
||||
submgr.start_background_loop()
|
||||
|
||||
|
||||
app.on_startup.append(_subscription_loop_startup)
|
||||
|
||||
class FileOpsFilter(DefaultFilter):
|
||||
def __call__(self, change_type: int, path: str) -> bool:
|
||||
@@ -229,26 +354,41 @@ async def watch_files():
|
||||
if config.YTDL_OPTIONS_FILE:
|
||||
app.on_startup.append(lambda app: watch_files())
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'add')
|
||||
async def add(request):
|
||||
log.info("Received request to add download")
|
||||
post = await request.json()
|
||||
log.info(f"Request data: {post}")
|
||||
|
||||
async def _read_json_request(request: web.Request) -> dict:
|
||||
try:
|
||||
post = await request.json()
|
||||
except json.JSONDecodeError as exc:
|
||||
raise web.HTTPBadRequest(reason='Invalid JSON request body') from exc
|
||||
if not isinstance(post, dict):
|
||||
raise web.HTTPBadRequest(reason='JSON request body must be an object')
|
||||
return post
|
||||
|
||||
|
||||
def parse_download_options(post: dict) -> dict:
|
||||
"""Validate add/subscribe body; raise HTTPBadRequest on invalid input."""
|
||||
post = _migrate_legacy_request(dict(post))
|
||||
url = post.get('url')
|
||||
quality = post.get('quality')
|
||||
if not url or not quality:
|
||||
log.error("Bad request: missing 'url' or 'quality'")
|
||||
raise web.HTTPBadRequest()
|
||||
download_type = post.get('download_type')
|
||||
codec = post.get('codec')
|
||||
format = post.get('format')
|
||||
quality = post.get('quality')
|
||||
if not url or not quality or not download_type:
|
||||
raise web.HTTPBadRequest(reason="missing 'url', 'download_type', or 'quality'")
|
||||
url = str(url).strip()
|
||||
folder = post.get('folder')
|
||||
custom_name_prefix = post.get('custom_name_prefix')
|
||||
playlist_item_limit = post.get('playlist_item_limit')
|
||||
auto_start = post.get('auto_start')
|
||||
split_by_chapters = post.get('split_by_chapters')
|
||||
chapter_template = post.get('chapter_template')
|
||||
subtitle_language = post.get('subtitle_language')
|
||||
subtitle_mode = post.get('subtitle_mode')
|
||||
|
||||
if custom_name_prefix is None:
|
||||
custom_name_prefix = ''
|
||||
if custom_name_prefix and ('..' in custom_name_prefix or custom_name_prefix.startswith('/') or custom_name_prefix.startswith('\\')):
|
||||
raise web.HTTPBadRequest(reason='custom_name_prefix must not contain ".." or start with a path separator')
|
||||
if auto_start is None:
|
||||
auto_start = True
|
||||
if playlist_item_limit is None:
|
||||
@@ -257,15 +397,196 @@ async def add(request):
|
||||
split_by_chapters = False
|
||||
if chapter_template is None:
|
||||
chapter_template = config.OUTPUT_TEMPLATE_CHAPTER
|
||||
if subtitle_language is None:
|
||||
subtitle_language = 'en'
|
||||
if subtitle_mode is None:
|
||||
subtitle_mode = 'prefer_manual'
|
||||
download_type = str(download_type).strip().lower()
|
||||
codec = str(codec or 'auto').strip().lower()
|
||||
format = str(format or '').strip().lower()
|
||||
quality = str(quality).strip().lower()
|
||||
subtitle_language = str(subtitle_language).strip()
|
||||
subtitle_mode = str(subtitle_mode).strip()
|
||||
|
||||
playlist_item_limit = int(playlist_item_limit)
|
||||
if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')):
|
||||
raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator')
|
||||
if not SUBTITLE_LANGUAGE_RE.fullmatch(subtitle_language):
|
||||
raise web.HTTPBadRequest(reason='subtitle_language must match pattern [A-Za-z0-9-] and be at most 35 characters')
|
||||
if subtitle_mode not in VALID_SUBTITLE_MODES:
|
||||
raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(VALID_SUBTITLE_MODES)}')
|
||||
|
||||
status = await dqueue.add(url, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template)
|
||||
if download_type not in VALID_DOWNLOAD_TYPES:
|
||||
raise web.HTTPBadRequest(reason=f'download_type must be one of {sorted(VALID_DOWNLOAD_TYPES)}')
|
||||
if codec not in VALID_VIDEO_CODECS:
|
||||
raise web.HTTPBadRequest(reason=f'codec must be one of {sorted(VALID_VIDEO_CODECS)}')
|
||||
|
||||
if download_type == 'video':
|
||||
if format not in VALID_VIDEO_FORMATS:
|
||||
raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_VIDEO_FORMATS)} for video')
|
||||
if quality not in {'best', 'worst', '2160', '1440', '1080', '720', '480', '360', '240'}:
|
||||
raise web.HTTPBadRequest(reason="quality must be one of ['best', '2160', '1440', '1080', '720', '480', '360', '240', 'worst'] for video")
|
||||
elif download_type == 'audio':
|
||||
if format not in VALID_AUDIO_FORMATS:
|
||||
raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_AUDIO_FORMATS)} for audio')
|
||||
allowed_audio_qualities = {'best'}
|
||||
if format == 'mp3':
|
||||
allowed_audio_qualities |= {'320', '192', '128'}
|
||||
elif format == 'm4a':
|
||||
allowed_audio_qualities |= {'192', '128'}
|
||||
if quality not in allowed_audio_qualities:
|
||||
raise web.HTTPBadRequest(reason=f'quality must be one of {sorted(allowed_audio_qualities)} for format {format}')
|
||||
codec = 'auto'
|
||||
elif download_type == 'captions':
|
||||
if format not in VALID_SUBTITLE_FORMATS:
|
||||
raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_SUBTITLE_FORMATS)} for captions')
|
||||
quality = 'best'
|
||||
codec = 'auto'
|
||||
elif download_type == 'thumbnail':
|
||||
if format not in VALID_THUMBNAIL_FORMATS:
|
||||
raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_THUMBNAIL_FORMATS)} for thumbnail')
|
||||
quality = 'best'
|
||||
codec = 'auto'
|
||||
|
||||
try:
|
||||
playlist_item_limit = int(playlist_item_limit)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise web.HTTPBadRequest(reason='playlist_item_limit must be an integer') from exc
|
||||
|
||||
return {
|
||||
'url': url,
|
||||
'download_type': download_type,
|
||||
'codec': codec,
|
||||
'format': format,
|
||||
'quality': quality,
|
||||
'folder': folder,
|
||||
'custom_name_prefix': custom_name_prefix,
|
||||
'playlist_item_limit': playlist_item_limit,
|
||||
'auto_start': auto_start,
|
||||
'split_by_chapters': split_by_chapters,
|
||||
'chapter_template': chapter_template,
|
||||
'subtitle_language': subtitle_language,
|
||||
'subtitle_mode': subtitle_mode,
|
||||
}
|
||||
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'add')
|
||||
async def add(request):
|
||||
log.info("Received request to add download")
|
||||
post = await _read_json_request(request)
|
||||
try:
|
||||
o = parse_download_options(post)
|
||||
except web.HTTPBadRequest as e:
|
||||
log.error("Bad request: %s", e.reason)
|
||||
raise
|
||||
log.info(
|
||||
"Add download request: type=%s quality=%s format=%s has_folder=%s auto_start=%s",
|
||||
o['download_type'],
|
||||
o['quality'],
|
||||
o['format'],
|
||||
bool(o.get('folder')),
|
||||
o['auto_start'],
|
||||
)
|
||||
status = await dqueue.add(
|
||||
o['url'],
|
||||
o['download_type'],
|
||||
o['codec'],
|
||||
o['format'],
|
||||
o['quality'],
|
||||
o['folder'],
|
||||
o['custom_name_prefix'],
|
||||
o['playlist_item_limit'],
|
||||
o['auto_start'],
|
||||
o['split_by_chapters'],
|
||||
o['chapter_template'],
|
||||
o['subtitle_language'],
|
||||
o['subtitle_mode'],
|
||||
)
|
||||
return web.Response(text=serializer.encode(status))
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'cancel-add')
|
||||
async def cancel_add(request):
|
||||
dqueue.cancel_add()
|
||||
return web.Response(text=serializer.encode({'status': 'ok'}), content_type='application/json')
|
||||
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'subscribe')
|
||||
async def subscribe(request):
|
||||
post = await _read_json_request(request)
|
||||
try:
|
||||
o = parse_download_options(post)
|
||||
except web.HTTPBadRequest:
|
||||
raise
|
||||
cic = post.get('check_interval_minutes')
|
||||
if cic is None:
|
||||
cic = config.SUBSCRIPTION_DEFAULT_CHECK_INTERVAL
|
||||
try:
|
||||
cic = int(cic)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise web.HTTPBadRequest(reason='check_interval_minutes must be an integer') from exc
|
||||
if cic < 1:
|
||||
raise web.HTTPBadRequest(reason='check_interval_minutes must be at least 1')
|
||||
|
||||
result = await submgr.add_subscription(
|
||||
o['url'],
|
||||
check_interval_minutes=cic,
|
||||
download_type=o['download_type'],
|
||||
codec=o['codec'],
|
||||
format=o['format'],
|
||||
quality=o['quality'],
|
||||
folder=o['folder'] or '',
|
||||
custom_name_prefix=o['custom_name_prefix'],
|
||||
auto_start=o['auto_start'],
|
||||
playlist_item_limit=o['playlist_item_limit'],
|
||||
split_by_chapters=o['split_by_chapters'],
|
||||
chapter_template=o['chapter_template'],
|
||||
subtitle_language=o['subtitle_language'],
|
||||
subtitle_mode=o['subtitle_mode'],
|
||||
)
|
||||
return web.Response(text=serializer.encode(result))
|
||||
|
||||
|
||||
@routes.get(config.URL_PREFIX + 'subscriptions')
|
||||
async def subscriptions_list(request):
|
||||
return web.Response(text=serializer.encode([s.to_public_dict() for s in submgr.list_all()]))
|
||||
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'subscriptions/update')
|
||||
async def subscriptions_update(request):
|
||||
post = await _read_json_request(request)
|
||||
sub_id = post.get('id')
|
||||
if not sub_id:
|
||||
raise web.HTTPBadRequest(reason='missing subscription id')
|
||||
changes = {k: v for k, v in post.items() if k != 'id' and k in ('enabled', 'check_interval_minutes', 'name')}
|
||||
if not changes:
|
||||
raise web.HTTPBadRequest(reason='no valid fields to update')
|
||||
log.info("Subscription update requested for %s: %s", sub_id, sorted(changes.keys()))
|
||||
result = await submgr.update_subscription(str(sub_id), changes)
|
||||
return web.Response(text=serializer.encode(result))
|
||||
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'subscriptions/delete')
|
||||
async def subscriptions_delete(request):
|
||||
post = await _read_json_request(request)
|
||||
ids = post.get('ids')
|
||||
if not ids or not isinstance(ids, list):
|
||||
raise web.HTTPBadRequest(reason='missing ids list')
|
||||
result = await submgr.delete_subscriptions([str(i) for i in ids])
|
||||
return web.Response(text=serializer.encode(result))
|
||||
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'subscriptions/check')
|
||||
async def subscriptions_check(request):
|
||||
post = await _read_json_request(request)
|
||||
ids = post.get('ids')
|
||||
if ids is not None and not isinstance(ids, list):
|
||||
raise web.HTTPBadRequest(reason='ids must be a list')
|
||||
log.info("Subscription check-now requested for ids=%s", ids if ids else "all-enabled")
|
||||
result = await submgr.check_now([str(i) for i in ids] if ids else None)
|
||||
return web.Response(text=serializer.encode(result))
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'delete')
|
||||
async def delete(request):
|
||||
post = await request.json()
|
||||
post = await _read_json_request(request)
|
||||
ids = post.get('ids')
|
||||
where = post.get('where')
|
||||
if not ids or where not in ['queue', 'done']:
|
||||
@@ -277,12 +598,77 @@ async def delete(request):
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'start')
|
||||
async def start(request):
|
||||
post = await request.json()
|
||||
post = await _read_json_request(request)
|
||||
ids = post.get('ids')
|
||||
log.info(f"Received request to start pending downloads for ids: {ids}")
|
||||
status = await dqueue.start_pending(ids)
|
||||
return web.Response(text=serializer.encode(status))
|
||||
|
||||
|
||||
COOKIES_PATH = os.path.join(config.STATE_DIR, 'cookies.txt')
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'upload-cookies')
|
||||
async def upload_cookies(request):
|
||||
reader = await request.multipart()
|
||||
field = await reader.next()
|
||||
if field is None or field.name != 'cookies':
|
||||
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No cookies file provided'}))
|
||||
|
||||
max_size = 1_000_000 # 1MB limit
|
||||
size = 0
|
||||
content = bytearray()
|
||||
while True:
|
||||
chunk = await field.read_chunk()
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > max_size:
|
||||
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'Cookie file too large (max 1MB)'}))
|
||||
content.extend(chunk)
|
||||
|
||||
tmp_cookie_path = f"{COOKIES_PATH}.tmp"
|
||||
with open(tmp_cookie_path, 'wb') as f:
|
||||
f.write(content)
|
||||
os.replace(tmp_cookie_path, COOKIES_PATH)
|
||||
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
||||
log.info(f'Cookies file uploaded ({size} bytes)')
|
||||
return web.Response(text=serializer.encode({'status': 'ok', 'msg': f'Cookies uploaded ({size} bytes)'}))
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'delete-cookies')
|
||||
async def delete_cookies(request):
|
||||
has_uploaded_cookies = os.path.exists(COOKIES_PATH)
|
||||
configured_cookiefile = config.YTDL_OPTIONS.get('cookiefile')
|
||||
has_manual_cookiefile = isinstance(configured_cookiefile, str) and configured_cookiefile and configured_cookiefile != COOKIES_PATH
|
||||
|
||||
if not has_uploaded_cookies:
|
||||
if has_manual_cookiefile:
|
||||
return web.Response(
|
||||
status=400,
|
||||
text=serializer.encode({
|
||||
'status': 'error',
|
||||
'msg': 'Cookies are configured manually via YTDL_OPTIONS (cookiefile). Remove or change that setting manually; UI delete only removes uploaded cookies.'
|
||||
})
|
||||
)
|
||||
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'No uploaded cookies to delete'}))
|
||||
|
||||
os.remove(COOKIES_PATH)
|
||||
config.remove_runtime_override('cookiefile')
|
||||
success, msg = config.load_ytdl_options()
|
||||
if not success:
|
||||
log.error(f'Cookies file deleted, but failed to reload YTDL_OPTIONS: {msg}')
|
||||
return web.Response(status=500, text=serializer.encode({'status': 'error', 'msg': f'Cookies file deleted, but failed to reload YTDL_OPTIONS: {msg}'}))
|
||||
|
||||
log.info('Cookies file deleted')
|
||||
return web.Response(text=serializer.encode({'status': 'ok'}))
|
||||
|
||||
@routes.get(config.URL_PREFIX + 'cookie-status')
|
||||
async def cookie_status(request):
|
||||
configured_cookiefile = config.YTDL_OPTIONS.get('cookiefile')
|
||||
has_configured_cookies = isinstance(configured_cookiefile, str) and os.path.exists(configured_cookiefile)
|
||||
has_uploaded_cookies = os.path.exists(COOKIES_PATH)
|
||||
exists = has_uploaded_cookies or has_configured_cookies
|
||||
return web.Response(text=serializer.encode({'status': 'ok', 'has_cookies': exists}))
|
||||
|
||||
@routes.get(config.URL_PREFIX + 'history')
|
||||
async def history(request):
|
||||
history = { 'done': [], 'queue': [], 'pending': []}
|
||||
@@ -301,13 +687,30 @@ async def history(request):
|
||||
async def connect(sid, environ):
|
||||
log.info(f"Client connected: {sid}")
|
||||
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
|
||||
await sio.emit('configuration', serializer.encode(config), to=sid)
|
||||
await sio.emit('subscriptions_all', serializer.encode([s.to_public_dict() for s in submgr.list_all()]), to=sid)
|
||||
await sio.emit('configuration', serializer.encode(config.frontend_safe()), to=sid)
|
||||
if config.CUSTOM_DIRS:
|
||||
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
|
||||
if config.YTDL_OPTIONS_FILE:
|
||||
await sio.emit('ytdl_options_changed', serializer.encode(get_options_update_time()), to=sid)
|
||||
|
||||
def get_custom_dirs():
|
||||
cache_ttl_seconds = 5
|
||||
now = asyncio.get_running_loop().time()
|
||||
cache_key = (
|
||||
config.DOWNLOAD_DIR,
|
||||
config.AUDIO_DOWNLOAD_DIR,
|
||||
config.CUSTOM_DIRS_EXCLUDE_REGEX,
|
||||
)
|
||||
if (
|
||||
hasattr(get_custom_dirs, "_cache_key")
|
||||
and hasattr(get_custom_dirs, "_cache_value")
|
||||
and hasattr(get_custom_dirs, "_cache_time")
|
||||
and get_custom_dirs._cache_key == cache_key
|
||||
and (now - get_custom_dirs._cache_time) < cache_ttl_seconds
|
||||
):
|
||||
return get_custom_dirs._cache_value
|
||||
|
||||
def recursive_dirs(base):
|
||||
path = pathlib.Path(base)
|
||||
|
||||
@@ -329,8 +732,12 @@ def get_custom_dirs():
|
||||
else:
|
||||
return re.search(config.CUSTOM_DIRS_EXCLUDE_REGEX, d) is None
|
||||
|
||||
# Recursively lists all subdirectories of DOWNLOAD_DIR
|
||||
# Recursively lists all subdirectories of DOWNLOAD_DIR.
|
||||
# Always include '' (the base directory itself) even when the
|
||||
# directory is empty or does not yet exist.
|
||||
dirs = list(filter(include_dir, map(convert, path.glob('**/'))))
|
||||
if '' not in dirs:
|
||||
dirs.insert(0, '')
|
||||
|
||||
return dirs
|
||||
|
||||
@@ -340,20 +747,24 @@ def get_custom_dirs():
|
||||
if config.DOWNLOAD_DIR != config.AUDIO_DOWNLOAD_DIR:
|
||||
audio_download_dir = recursive_dirs(config.AUDIO_DOWNLOAD_DIR)
|
||||
|
||||
return {
|
||||
result = {
|
||||
"download_dir": download_dir,
|
||||
"audio_download_dir": audio_download_dir
|
||||
}
|
||||
get_custom_dirs._cache_key = cache_key
|
||||
get_custom_dirs._cache_time = now
|
||||
get_custom_dirs._cache_value = result
|
||||
return result
|
||||
|
||||
@routes.get(config.URL_PREFIX)
|
||||
def index(request):
|
||||
async def index(request):
|
||||
response = web.FileResponse(os.path.join(config.BASE_DIR, 'ui/dist/metube/browser/index.html'))
|
||||
if 'metube_theme' not in request.cookies:
|
||||
response.set_cookie('metube_theme', config.DEFAULT_THEME)
|
||||
return response
|
||||
|
||||
@routes.get(config.URL_PREFIX + 'robots.txt')
|
||||
def robots(request):
|
||||
async def robots(request):
|
||||
if config.ROBOTS_TXT:
|
||||
response = web.FileResponse(os.path.join(config.BASE_DIR, config.ROBOTS_TXT))
|
||||
else:
|
||||
@@ -363,7 +774,7 @@ def robots(request):
|
||||
return response
|
||||
|
||||
@routes.get(config.URL_PREFIX + 'version')
|
||||
def version(request):
|
||||
async def version(request):
|
||||
return web.json_response({
|
||||
"yt-dlp": yt_dlp_version,
|
||||
"version": os.getenv("METUBE_VERSION", "dev")
|
||||
@@ -371,11 +782,11 @@ def version(request):
|
||||
|
||||
if config.URL_PREFIX != '/':
|
||||
@routes.get('/')
|
||||
def index_redirect_root(request):
|
||||
async def index_redirect_root(request):
|
||||
return web.HTTPFound(config.URL_PREFIX)
|
||||
|
||||
@routes.get(config.URL_PREFIX[:-1])
|
||||
def index_redirect_dir(request):
|
||||
async def index_redirect_dir(request):
|
||||
return web.HTTPFound(config.URL_PREFIX)
|
||||
|
||||
routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
|
||||
@@ -394,6 +805,14 @@ async def add_cors(request):
|
||||
return web.Response(text=serializer.encode({"status": "ok"}))
|
||||
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'add', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscribe', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/update', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/delete', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/check', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', add_cors)
|
||||
app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors)
|
||||
|
||||
async def on_prepare(request, response):
|
||||
if 'Origin' in request.headers:
|
||||
@@ -401,7 +820,7 @@ async def on_prepare(request, response):
|
||||
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
||||
|
||||
app.on_response_prepare.append(on_prepare)
|
||||
|
||||
|
||||
def supports_reuse_port():
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
@@ -421,6 +840,12 @@ if __name__ == '__main__':
|
||||
logging.getLogger().setLevel(parseLogLevel(config.LOGLEVEL) or logging.INFO)
|
||||
log.info(f"Listening on {config.HOST}:{config.PORT}")
|
||||
|
||||
|
||||
# Auto-detect cookie file on startup
|
||||
if os.path.exists(COOKIES_PATH):
|
||||
config.set_runtime_override('cookiefile', COOKIES_PATH)
|
||||
log.info(f'Cookie file detected at {COOKIES_PATH}')
|
||||
|
||||
if config.HTTPS:
|
||||
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||
ssl_context.load_cert_chain(certfile=config.CERTFILE, keyfile=config.KEYFILE)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import collections.abc
|
||||
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__"
|
||||
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
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:
|
||||
json.dump(payload, f, ensure_ascii=False, separators=(",", ":"))
|
||||
f.write("\n")
|
||||
f.flush()
|
||||
os.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 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)
|
||||
@@ -0,0 +1,666 @@
|
||||
"""Channel/playlist subscriptions: periodic yt-dlp flat extract + queue new videos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import types
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, fields
|
||||
from typing import Any, Optional
|
||||
|
||||
import yt_dlp
|
||||
import yt_dlp.networking.impersonate
|
||||
from state_store import AtomicJsonStore, read_legacy_shelf
|
||||
|
||||
log = logging.getLogger("subscriptions")
|
||||
|
||||
VIDEO_ONLY_MSG = (
|
||||
"This URL points to a single video, not a channel or playlist. Use Download instead."
|
||||
)
|
||||
_MEDIA_HINT_FIELDS = (
|
||||
"duration",
|
||||
"timestamp",
|
||||
"release_timestamp",
|
||||
"upload_date",
|
||||
"view_count",
|
||||
"live_status",
|
||||
"availability",
|
||||
)
|
||||
|
||||
|
||||
def _impersonate_opt(ytdl_options: dict) -> dict:
|
||||
opts = dict(ytdl_options)
|
||||
if "impersonate" in opts:
|
||||
opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(
|
||||
opts["impersonate"]
|
||||
)
|
||||
return opts
|
||||
|
||||
|
||||
def _build_ydl_params(config, *, playlistend: Optional[int] = None) -> dict:
|
||||
params: dict[str, Any] = {
|
||||
"quiet": not logging.getLogger().isEnabledFor(logging.DEBUG),
|
||||
"verbose": logging.getLogger().isEnabledFor(logging.DEBUG),
|
||||
"no_color": True,
|
||||
"extract_flat": True,
|
||||
"ignore_no_formats_error": True,
|
||||
"lazy_playlist": True,
|
||||
"paths": {"home": config.DOWNLOAD_DIR, "temp": config.TEMP_DIR},
|
||||
**config.YTDL_OPTIONS,
|
||||
}
|
||||
params = _impersonate_opt(params)
|
||||
if playlistend is not None and playlistend > 0:
|
||||
params["playlistend"] = playlistend
|
||||
return params
|
||||
|
||||
|
||||
def _is_media_entry(entry: Any) -> bool:
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
etype = str(entry.get("_type") or "")
|
||||
if etype in ("playlist", "multi_video", "channel"):
|
||||
return False
|
||||
if entry.get("entries"):
|
||||
return False
|
||||
url = _entry_video_url(entry)
|
||||
if not url:
|
||||
return False
|
||||
ie_key = str(entry.get("ie_key") or entry.get("extractor_key") or "").lower()
|
||||
if any(token in ie_key for token in ("playlist", "channel", "tab")):
|
||||
return any(entry.get(field) is not None for field in _MEDIA_HINT_FIELDS)
|
||||
return True
|
||||
|
||||
|
||||
def extract_flat_playlist(config, url: str, playlistend: int, *, _depth: int = 0):
|
||||
"""Return (info_dict, entries_list) for playlist/channel URLs."""
|
||||
params = _build_ydl_params(config, playlistend=playlistend)
|
||||
with yt_dlp.YoutubeDL(params=params) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
if not info:
|
||||
return None, []
|
||||
etype = info.get("_type") or "video"
|
||||
if etype == "video":
|
||||
return info, []
|
||||
if etype in ("playlist", "channel"):
|
||||
entries = info.get("entries") or []
|
||||
if isinstance(entries, types.GeneratorType):
|
||||
entries = list(entries)
|
||||
# Drop None placeholders from incomplete flat playlists
|
||||
entries = [e for e in entries if e]
|
||||
media_entries = [e for e in entries if _is_media_entry(e)]
|
||||
if media_entries:
|
||||
return info, media_entries
|
||||
if _depth < 1:
|
||||
for ent in entries[:5]:
|
||||
nested_url = _entry_video_url(ent)
|
||||
if not nested_url:
|
||||
continue
|
||||
nested_info, nested_entries = extract_flat_playlist(
|
||||
config,
|
||||
nested_url,
|
||||
playlistend,
|
||||
_depth=_depth + 1,
|
||||
)
|
||||
if nested_entries:
|
||||
return nested_info, nested_entries
|
||||
return info, entries
|
||||
if etype.startswith("url") and info.get("url"):
|
||||
# Single nested URL without playlist wrapper — treat as non-subscribable
|
||||
return info, []
|
||||
return info, []
|
||||
|
||||
|
||||
def _entry_video_url(entry: dict) -> Optional[str]:
|
||||
return entry.get("webpage_url") or entry.get("url")
|
||||
|
||||
|
||||
def _entry_id(entry: dict) -> Optional[str]:
|
||||
eid = entry.get("id")
|
||||
if eid is not None:
|
||||
return str(eid)
|
||||
url = _entry_video_url(entry)
|
||||
return url
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriptionInfo:
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
enabled: bool = True
|
||||
check_interval_minutes: int = 60
|
||||
download_type: str = "video"
|
||||
codec: str = "auto"
|
||||
format: str = "any"
|
||||
quality: str = "best"
|
||||
folder: str = ""
|
||||
custom_name_prefix: str = ""
|
||||
auto_start: bool = True
|
||||
playlist_item_limit: int = 0
|
||||
split_by_chapters: bool = False
|
||||
chapter_template: str = ""
|
||||
subtitle_language: str = "en"
|
||||
subtitle_mode: str = "prefer_manual"
|
||||
last_checked: Optional[float] = None
|
||||
seen_ids: list[str] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
def seen_set(self) -> set[str]:
|
||||
return set(self.seen_ids)
|
||||
|
||||
def to_public_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"url": self.url,
|
||||
"enabled": self.enabled,
|
||||
"check_interval_minutes": self.check_interval_minutes,
|
||||
"download_type": self.download_type,
|
||||
"codec": self.codec,
|
||||
"format": self.format,
|
||||
"quality": self.quality,
|
||||
"folder": self.folder,
|
||||
"last_checked": self.last_checked,
|
||||
"seen_count": len(self.seen_ids),
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
def _subscription_to_record(sub: SubscriptionInfo) -> dict[str, Any]:
|
||||
return {
|
||||
"id": sub.id,
|
||||
"name": sub.name,
|
||||
"url": sub.url,
|
||||
"enabled": sub.enabled,
|
||||
"check_interval_minutes": sub.check_interval_minutes,
|
||||
"download_type": sub.download_type,
|
||||
"codec": sub.codec,
|
||||
"format": sub.format,
|
||||
"quality": sub.quality,
|
||||
"folder": sub.folder,
|
||||
"custom_name_prefix": sub.custom_name_prefix,
|
||||
"auto_start": sub.auto_start,
|
||||
"playlist_item_limit": sub.playlist_item_limit,
|
||||
"split_by_chapters": sub.split_by_chapters,
|
||||
"chapter_template": sub.chapter_template,
|
||||
"subtitle_language": sub.subtitle_language,
|
||||
"subtitle_mode": sub.subtitle_mode,
|
||||
"last_checked": sub.last_checked,
|
||||
"seen_ids": list(sub.seen_ids),
|
||||
"error": sub.error,
|
||||
}
|
||||
|
||||
|
||||
def _subscription_from_record(record: Any) -> Optional[SubscriptionInfo]:
|
||||
field_names = {f.name for f in fields(SubscriptionInfo)}
|
||||
if isinstance(record, SubscriptionInfo):
|
||||
return record
|
||||
if isinstance(record, dict):
|
||||
try:
|
||||
return SubscriptionInfo(**{k: v for k, v in record.items() if k in field_names})
|
||||
except TypeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class SubscriptionNotifier:
|
||||
"""Hook for Socket.IO / UI updates."""
|
||||
|
||||
async def subscription_added(self, sub: SubscriptionInfo) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def subscription_updated(self, sub: SubscriptionInfo) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def subscription_removed(self, sub_id: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def subscriptions_all(self, subs: list[SubscriptionInfo]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class SubscriptionManager:
|
||||
def __init__(self, config, download_queue, notifier: SubscriptionNotifier):
|
||||
self.config = config
|
||||
self.dqueue = download_queue
|
||||
self.notifier = notifier
|
||||
pdir = config.STATE_DIR
|
||||
if not os.path.isdir(pdir):
|
||||
os.makedirs(pdir, exist_ok=True)
|
||||
self._legacy_path = os.path.join(pdir, "subscriptions")
|
||||
self._path = os.path.join(pdir, "subscriptions.json")
|
||||
self._store = AtomicJsonStore(self._path, kind="subscriptions")
|
||||
self._subs: dict[str, SubscriptionInfo] = {}
|
||||
self._url_index: dict[str, str] = {} # normalized url -> id
|
||||
self._pending_urls: set[str] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._loop_task: Optional[asyncio.Task] = None
|
||||
self._load_all()
|
||||
|
||||
def close(self) -> None:
|
||||
# No persistent shelf handle to close.
|
||||
return
|
||||
|
||||
def _normalize_url(self, url: str) -> str:
|
||||
return (url or "").strip()
|
||||
|
||||
def _normalize_seen_ids(self, seen_ids: list[str]) -> list[str]:
|
||||
max_seen = int(getattr(self.config, "SUBSCRIPTION_MAX_SEEN_IDS", 50000))
|
||||
normalized = [str(sid) for sid in dict.fromkeys(seen_ids)]
|
||||
if len(normalized) > max_seen:
|
||||
normalized = normalized[:max_seen]
|
||||
return normalized
|
||||
|
||||
def _load_all(self) -> None:
|
||||
payload = self._store.load()
|
||||
loaded_from_legacy = False
|
||||
if payload is not None:
|
||||
records = payload.get("items") or []
|
||||
else:
|
||||
legacy_items = read_legacy_shelf(self._legacy_path)
|
||||
records = [raw for _key, raw in legacy_items] if legacy_items else []
|
||||
if records:
|
||||
loaded_from_legacy = True
|
||||
|
||||
loaded_subs = self._iter_valid_subs(records)
|
||||
compact_records = []
|
||||
for sub in loaded_subs:
|
||||
sub.seen_ids = self._normalize_seen_ids(sub.seen_ids)
|
||||
self._subs[sub.id] = sub
|
||||
self._url_index[self._normalize_url(sub.url)] = sub.id
|
||||
compact_records.append(_subscription_to_record(sub))
|
||||
|
||||
if loaded_from_legacy or (
|
||||
payload is not None
|
||||
and (
|
||||
payload.get("schema_version") != self._store.schema_version
|
||||
or compact_records != records
|
||||
)
|
||||
):
|
||||
self._store.save({"items": compact_records})
|
||||
|
||||
def _iter_valid_subs(self, records: list[Any]) -> list[SubscriptionInfo]:
|
||||
subs: list[SubscriptionInfo] = []
|
||||
for record in records:
|
||||
sub = _subscription_from_record(record)
|
||||
if sub is not None:
|
||||
subs.append(sub)
|
||||
return subs
|
||||
|
||||
def _save_locked(self) -> None:
|
||||
self._store.save({"items": [_subscription_to_record(sub) for sub in self._subs.values()]})
|
||||
|
||||
async def _queue_subscription_entries(
|
||||
self,
|
||||
entries: list[dict],
|
||||
*,
|
||||
download_type: str,
|
||||
codec: str,
|
||||
format: str,
|
||||
quality: str,
|
||||
folder: str,
|
||||
custom_name_prefix: str,
|
||||
playlist_item_limit: int,
|
||||
auto_start: bool,
|
||||
split_by_chapters: bool,
|
||||
chapter_template: str,
|
||||
subtitle_language: str,
|
||||
subtitle_mode: str,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
queued_ids: list[str] = []
|
||||
queue_errors: list[str] = []
|
||||
for ent in entries:
|
||||
eid = _entry_id(ent)
|
||||
vurl = _entry_video_url(ent)
|
||||
if not eid or not vurl:
|
||||
continue
|
||||
queue_entry = dict(ent)
|
||||
queue_entry["_type"] = "video"
|
||||
queue_entry["webpage_url"] = vurl
|
||||
result = await self.dqueue.add_entry(
|
||||
queue_entry,
|
||||
download_type,
|
||||
codec,
|
||||
format,
|
||||
quality,
|
||||
folder or None,
|
||||
custom_name_prefix,
|
||||
playlist_item_limit,
|
||||
auto_start,
|
||||
split_by_chapters,
|
||||
chapter_template or None,
|
||||
subtitle_language,
|
||||
subtitle_mode,
|
||||
)
|
||||
if isinstance(result, dict) and result.get("status") == "error":
|
||||
msg = str(result.get("msg") or f"Queueing failed for {vurl}")
|
||||
queue_errors.append(msg)
|
||||
log.warning("Subscription queueing failed for %s: %s", vurl, msg)
|
||||
continue
|
||||
queued_ids.append(eid)
|
||||
return queued_ids, queue_errors
|
||||
|
||||
def list_all(self) -> list[SubscriptionInfo]:
|
||||
return list(self._subs.values())
|
||||
|
||||
def get(self, sub_id: str) -> Optional[SubscriptionInfo]:
|
||||
return self._subs.get(sub_id)
|
||||
|
||||
def start_background_loop(self) -> None:
|
||||
if self._loop_task is not None and not self._loop_task.done():
|
||||
return
|
||||
self._loop_task = asyncio.create_task(self._periodic_loop())
|
||||
self._loop_task.add_done_callback(
|
||||
lambda t: log.error("Subscription loop failed: %s", t.exception())
|
||||
if not t.cancelled() and t.exception()
|
||||
else None
|
||||
)
|
||||
|
||||
async def _periodic_loop(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
try:
|
||||
await self.run_due_checks()
|
||||
except Exception as e:
|
||||
log.exception("Subscription periodic check error: %s", e)
|
||||
|
||||
async def run_due_checks(self) -> None:
|
||||
now = time.time()
|
||||
due: list[SubscriptionInfo] = []
|
||||
async with self._lock:
|
||||
for sub in list(self._subs.values()):
|
||||
if not sub.enabled:
|
||||
continue
|
||||
interval_sec = max(60, int(sub.check_interval_minutes) * 60)
|
||||
if sub.last_checked is None:
|
||||
due.append(sub)
|
||||
continue
|
||||
if now - sub.last_checked < interval_sec:
|
||||
continue
|
||||
due.append(sub)
|
||||
for sub in due:
|
||||
await self._check_one_unlocked(sub)
|
||||
|
||||
async def add_subscription(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
check_interval_minutes: int,
|
||||
download_type: str,
|
||||
codec: str,
|
||||
format: str,
|
||||
quality: str,
|
||||
folder: str,
|
||||
custom_name_prefix: str,
|
||||
auto_start: bool,
|
||||
playlist_item_limit: int,
|
||||
split_by_chapters: bool,
|
||||
chapter_template: str,
|
||||
subtitle_language: str,
|
||||
subtitle_mode: str,
|
||||
) -> dict:
|
||||
url = self._normalize_url(url)
|
||||
if not url:
|
||||
return {"status": "error", "msg": "Missing URL"}
|
||||
|
||||
async with self._lock:
|
||||
if url in self._url_index or url in self._pending_urls:
|
||||
return {"status": "error", "msg": "This URL is already subscribed"}
|
||||
self._pending_urls.add(url)
|
||||
|
||||
try:
|
||||
scan_first = max(int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50)), 1)
|
||||
try:
|
||||
info, entries = extract_flat_playlist(self.config, url, scan_first)
|
||||
except yt_dlp.utils.YoutubeDLError as exc:
|
||||
return {"status": "error", "msg": str(exc)}
|
||||
|
||||
if not info:
|
||||
return {"status": "error", "msg": "Could not resolve URL"}
|
||||
|
||||
etype = info.get("_type") or "video"
|
||||
if etype not in ("playlist", "channel"):
|
||||
return {"status": "error", "msg": VIDEO_ONLY_MSG}
|
||||
|
||||
name = (
|
||||
info.get("title")
|
||||
or info.get("channel")
|
||||
or info.get("playlist_title")
|
||||
or info.get("uploader")
|
||||
or url
|
||||
)
|
||||
|
||||
seen_entries = [ent for ent in entries if _is_media_entry(ent)]
|
||||
all_ids: list[str] = []
|
||||
for ent in seen_entries:
|
||||
eid = _entry_id(ent)
|
||||
if eid:
|
||||
all_ids.append(eid)
|
||||
|
||||
sub = SubscriptionInfo(
|
||||
id=str(uuid.uuid4()),
|
||||
name=str(name),
|
||||
url=url,
|
||||
enabled=True,
|
||||
check_interval_minutes=max(1, int(check_interval_minutes)),
|
||||
download_type=download_type,
|
||||
codec=codec,
|
||||
format=format,
|
||||
quality=quality,
|
||||
folder=folder or "",
|
||||
custom_name_prefix=custom_name_prefix or "",
|
||||
auto_start=bool(auto_start),
|
||||
playlist_item_limit=int(playlist_item_limit),
|
||||
split_by_chapters=bool(split_by_chapters),
|
||||
chapter_template=chapter_template or "",
|
||||
subtitle_language=subtitle_language,
|
||||
subtitle_mode=subtitle_mode,
|
||||
last_checked=time.time(),
|
||||
seen_ids=list(dict.fromkeys(all_ids)),
|
||||
error=None,
|
||||
)
|
||||
|
||||
async with self._lock:
|
||||
if url in self._url_index:
|
||||
return {"status": "error", "msg": "This URL is already subscribed"}
|
||||
self._subs[sub.id] = sub
|
||||
self._url_index[url] = sub.id
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
self._subs.pop(sub.id, None)
|
||||
self._url_index.pop(url, None)
|
||||
raise
|
||||
|
||||
await self.notifier.subscription_added(sub)
|
||||
return {"status": "ok", "subscription": sub.to_public_dict()}
|
||||
finally:
|
||||
async with self._lock:
|
||||
self._pending_urls.discard(url)
|
||||
|
||||
async def delete_subscriptions(self, ids: list[str]) -> dict:
|
||||
removed: list[str] = []
|
||||
async with self._lock:
|
||||
previous_subs = self._subs.copy()
|
||||
previous_index = self._url_index.copy()
|
||||
for sid in ids:
|
||||
sub = self._subs.pop(sid, None)
|
||||
if sub:
|
||||
normalized_url = self._normalize_url(sub.url)
|
||||
self._url_index.pop(normalized_url, None)
|
||||
removed.append(sid)
|
||||
if removed:
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
self._subs = previous_subs
|
||||
self._url_index = previous_index
|
||||
raise
|
||||
for sid in removed:
|
||||
await self.notifier.subscription_removed(sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
async def update_subscription(self, sub_id: str, changes: dict) -> dict:
|
||||
async with self._lock:
|
||||
sub = self._subs.get(sub_id)
|
||||
if not sub:
|
||||
return {"status": "error", "msg": "Subscription not found"}
|
||||
previous = copy.deepcopy(sub)
|
||||
old_enabled = sub.enabled
|
||||
|
||||
if "enabled" in changes:
|
||||
sub.enabled = bool(changes["enabled"])
|
||||
if "check_interval_minutes" in changes:
|
||||
sub.check_interval_minutes = max(1, int(changes["check_interval_minutes"]))
|
||||
if "name" in changes and changes["name"]:
|
||||
sub.name = str(changes["name"])
|
||||
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
self._subs[sub_id] = previous
|
||||
raise
|
||||
updated = sub
|
||||
if "enabled" in changes and updated.enabled != old_enabled:
|
||||
log.info(
|
||||
"Subscription %s %s",
|
||||
updated.name,
|
||||
"resumed" if updated.enabled else "paused",
|
||||
)
|
||||
await self.notifier.subscription_updated(updated)
|
||||
return {"status": "ok", "subscription": updated.to_public_dict()}
|
||||
|
||||
async def check_now(self, ids: Optional[list[str]] = None) -> dict:
|
||||
async with self._lock:
|
||||
targets = (
|
||||
[self._subs[i] for i in ids if i in self._subs]
|
||||
if ids
|
||||
else [s for s in self._subs.values() if s.enabled]
|
||||
)
|
||||
log.info(
|
||||
"Manual subscription check requested for %d subscription(s)",
|
||||
len(targets),
|
||||
)
|
||||
for sub in targets:
|
||||
await self._check_one_unlocked(sub)
|
||||
return {"status": "ok"}
|
||||
|
||||
async def _check_one_unlocked(self, sub: SubscriptionInfo) -> None:
|
||||
sid = sub.id
|
||||
scan = int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50))
|
||||
log.info("Checking subscription: %s", sub.name)
|
||||
try:
|
||||
info, entries = extract_flat_playlist(self.config, sub.url, scan)
|
||||
except yt_dlp.utils.YoutubeDLError as exc:
|
||||
async with self._lock:
|
||||
cur = self._subs.get(sid)
|
||||
if cur:
|
||||
previous = copy.deepcopy(cur)
|
||||
cur.error = str(exc)
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
self._subs[sid] = previous
|
||||
raise
|
||||
sub = cur
|
||||
log.warning("Subscription check failed for %s: %s", sub.name, exc)
|
||||
await self.notifier.subscription_updated(sub)
|
||||
return
|
||||
entries = [ent for ent in entries if _is_media_entry(ent)]
|
||||
|
||||
etype = (info or {}).get("_type") or "video"
|
||||
if etype == "video" or not entries:
|
||||
async with self._lock:
|
||||
cur = self._subs.get(sid)
|
||||
if cur:
|
||||
previous = copy.deepcopy(cur)
|
||||
cur.error = VIDEO_ONLY_MSG
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
self._subs[sid] = previous
|
||||
raise
|
||||
sub = cur
|
||||
log.warning("Subscription %s no longer resolves to a subscribable feed", sub.name)
|
||||
await self.notifier.subscription_updated(sub)
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
cur = self._subs.get(sid)
|
||||
if not cur:
|
||||
return
|
||||
seen = cur.seen_set()
|
||||
seen_ids_snapshot = list(cur.seen_ids)
|
||||
dl_type = cur.download_type
|
||||
dl_codec = cur.codec
|
||||
dl_format = cur.format
|
||||
dl_quality = cur.quality
|
||||
dl_folder = cur.folder
|
||||
dl_prefix = cur.custom_name_prefix
|
||||
dl_plimit = cur.playlist_item_limit
|
||||
dl_autostart = cur.auto_start
|
||||
dl_split = cur.split_by_chapters
|
||||
dl_chapter = cur.chapter_template
|
||||
dl_sublang = cur.subtitle_language
|
||||
dl_submode = cur.subtitle_mode
|
||||
|
||||
new_entries: list[dict] = []
|
||||
new_ids: list[str] = []
|
||||
for ent in entries:
|
||||
eid = _entry_id(ent)
|
||||
if not eid or eid in seen:
|
||||
continue
|
||||
new_entries.append(ent)
|
||||
new_ids.append(eid)
|
||||
|
||||
queued_ids, queue_errors = await self._queue_subscription_entries(
|
||||
new_entries,
|
||||
download_type=dl_type,
|
||||
codec=dl_codec,
|
||||
format=dl_format,
|
||||
quality=dl_quality,
|
||||
folder=dl_folder,
|
||||
custom_name_prefix=dl_prefix,
|
||||
playlist_item_limit=dl_plimit,
|
||||
auto_start=dl_autostart,
|
||||
split_by_chapters=dl_split,
|
||||
chapter_template=dl_chapter or "",
|
||||
subtitle_language=dl_sublang,
|
||||
subtitle_mode=dl_submode,
|
||||
)
|
||||
log.info(
|
||||
"Subscription check finished for %s: %d new, %d queued, %d failed",
|
||||
sub.name,
|
||||
len(new_entries),
|
||||
len(queued_ids),
|
||||
len(queue_errors),
|
||||
)
|
||||
|
||||
merged = list(dict.fromkeys(queued_ids + seen_ids_snapshot))
|
||||
max_seen = int(getattr(self.config, "SUBSCRIPTION_MAX_SEEN_IDS", 50000))
|
||||
if len(merged) > max_seen:
|
||||
merged = merged[:max_seen]
|
||||
|
||||
async with self._lock:
|
||||
cur = self._subs.get(sid)
|
||||
if not cur:
|
||||
return
|
||||
previous = copy.deepcopy(cur)
|
||||
cur.seen_ids = merged
|
||||
cur.last_checked = time.time()
|
||||
cur.error = "; ".join(queue_errors[:3]) if queue_errors else None
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
self._subs[sid] = previous
|
||||
raise
|
||||
sub = cur
|
||||
await self.notifier.subscription_updated(sub)
|
||||
|
||||
async def emit_all(self) -> None:
|
||||
await self.notifier.subscriptions_all(self.list_all())
|
||||
@@ -0,0 +1,32 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,207 @@
|
||||
"""HTTP handler tests for ``main`` using mocked ``web.Request`` (no TestServer)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
import main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dqueue(monkeypatch):
|
||||
d = MagicMock()
|
||||
d.initialize = AsyncMock(return_value=None)
|
||||
d.add = AsyncMock(return_value={"status": "ok"})
|
||||
d.cancel = 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.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",
|
||||
}
|
||||
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_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_custom_name_prefix_path_traversal(mock_dqueue):
|
||||
req = _json_request(_valid_video_add_body(custom_name_prefix="../evil"))
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.add(req)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_chapter_template_path_traversal(mock_dqueue):
|
||||
req = _json_request(
|
||||
_valid_video_add_body(
|
||||
split_by_chapters=True,
|
||||
chapter_template="/etc/passwd%(title)s",
|
||||
)
|
||||
)
|
||||
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_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
|
||||
async def test_history_shape(mock_dqueue):
|
||||
mock_dqueue.queue.saved_items.return_value = []
|
||||
mock_dqueue.done.saved_items.return_value = []
|
||||
mock_dqueue.pending.saved_items.return_value = []
|
||||
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_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_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"
|
||||
@@ -0,0 +1,78 @@
|
||||
"""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_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_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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Tests for ``DownloadQueue`` with mocked yt-dlp extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from ytdl import DownloadQueue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dq_env():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
dl = os.path.join(tmp, "downloads")
|
||||
st = os.path.join(tmp, "state")
|
||||
os.makedirs(dl, exist_ok=True)
|
||||
os.makedirs(st, exist_ok=True)
|
||||
cfg = MagicMock()
|
||||
cfg.STATE_DIR = st
|
||||
cfg.DOWNLOAD_DIR = dl
|
||||
cfg.AUDIO_DOWNLOAD_DIR = dl
|
||||
cfg.TEMP_DIR = dl
|
||||
cfg.MAX_CONCURRENT_DOWNLOADS = "3"
|
||||
cfg.YTDL_OPTIONS = {}
|
||||
cfg.CUSTOM_DIRS = True
|
||||
cfg.CREATE_CUSTOM_DIRS = True
|
||||
cfg.CLEAR_COMPLETED_AFTER = "0"
|
||||
cfg.DELETE_FILE_ON_TRASHCAN = False
|
||||
cfg.OUTPUT_TEMPLATE = "%(title)s.%(ext)s"
|
||||
cfg.OUTPUT_TEMPLATE_CHAPTER = "%(title)s.%(ext)s"
|
||||
cfg.OUTPUT_TEMPLATE_PLAYLIST = ""
|
||||
cfg.OUTPUT_TEMPLATE_CHANNEL = ""
|
||||
yield cfg
|
||||
|
||||
|
||||
def test_cancel_add_increments_generation(dq_env):
|
||||
notifier = MagicMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
before = dq._add_generation
|
||||
dq.cancel_add()
|
||||
assert dq._add_generation == before + 1
|
||||
|
||||
|
||||
def test_get_returns_tuple_of_lists(dq_env):
|
||||
notifier = MagicMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
q, done = dq.get()
|
||||
assert q == [] and done == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": url,
|
||||
"webpage_url": url,
|
||||
}
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
||||
result = await dq.add(
|
||||
"https://example.com/watch?v=1",
|
||||
"video",
|
||||
"auto",
|
||||
"any",
|
||||
"best",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
auto_start=False,
|
||||
)
|
||||
assert result["status"] == "ok"
|
||||
assert dq.pending.exists("https://example.com/watch?v=1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_removes_from_pending(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": url,
|
||||
"webpage_url": url,
|
||||
}
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
||||
await dq.add(
|
||||
"https://example.com/pending",
|
||||
"video",
|
||||
"auto",
|
||||
"any",
|
||||
"best",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
auto_start=False,
|
||||
)
|
||||
url = "https://example.com/pending"
|
||||
await dq.cancel([url])
|
||||
assert not dq.pending.exists(url)
|
||||
notifier.canceled.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_pending_moves_to_queue(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": url,
|
||||
"webpage_url": url,
|
||||
}
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
||||
await dq.add(
|
||||
"https://example.com/startme",
|
||||
"video",
|
||||
"auto",
|
||||
"any",
|
||||
"best",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
auto_start=False,
|
||||
)
|
||||
url = "https://example.com/startme"
|
||||
# Starting will spawn real download — cancel immediately before worker runs much
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
|
||||
await dq.start_pending([url])
|
||||
assert not dq.pending.exists(url)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_entry_queues_single_video_without_reextracting(dq_env):
|
||||
notifier = AsyncMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
entry = {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": "https://example.com/watch?v=1",
|
||||
"webpage_url": "https://example.com/watch?v=1",
|
||||
"playlist_index": "01",
|
||||
"playlist_title": "Playlist",
|
||||
}
|
||||
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", side_effect=AssertionError("should not re-extract")):
|
||||
result = await dq.add_entry(
|
||||
entry,
|
||||
"video",
|
||||
"auto",
|
||||
"any",
|
||||
"best",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
auto_start=False,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
assert dq.pending.exists("https://example.com/watch?v=1")
|
||||
@@ -0,0 +1,105 @@
|
||||
"""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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Integration tests for ``PersistentQueue`` using the JSON state store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
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.TestCase):
|
||||
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"))
|
||||
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")
|
||||
pq.delete("http://a.example")
|
||||
self.assertFalse(pq.exists("http://a.example"))
|
||||
|
||||
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
|
||||
pq.put(a)
|
||||
pq.put(b)
|
||||
keys = [k for k, _ in pq.saved_items()]
|
||||
self.assertEqual(keys, ["http://first.example", "http://second.example"])
|
||||
|
||||
def test_load_restores_from_json(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "queue")
|
||||
pq1 = PersistentQueue("queue", path)
|
||||
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"))
|
||||
|
||||
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",
|
||||
}
|
||||
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"])
|
||||
|
||||
def test_completed_queue_does_not_persist_entry_or_transient_progress(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "completed")
|
||||
pq = PersistentQueue("completed", path)
|
||||
info = _make_info("http://done.example")
|
||||
info.status = "finished"
|
||||
info.percent = 88
|
||||
info.speed = 123
|
||||
info.eta = 9
|
||||
info.entry = {
|
||||
"playlist_index": "01",
|
||||
"playlist_title": "Playlist",
|
||||
"formats": [{"id": "huge"}],
|
||||
}
|
||||
info.filename = "done.mp4"
|
||||
pq.put(_FakeDownload(info))
|
||||
|
||||
with open(path + ".json", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
|
||||
record = payload["items"][0]["info"]
|
||||
self.assertNotIn("entry", record)
|
||||
self.assertNotIn("percent", record)
|
||||
self.assertNotIn("speed", record)
|
||||
self.assertNotIn("eta", record)
|
||||
self.assertEqual(record["filename"], "done.mp4")
|
||||
|
||||
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)
|
||||
|
||||
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):
|
||||
pq.put(dl)
|
||||
|
||||
self.assertFalse(pq.exists("http://rollback.example"))
|
||||
|
||||
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"
|
||||
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):
|
||||
pq.put(second)
|
||||
|
||||
self.assertEqual(pq.get("http://same.example").info.title, "Title")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
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_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()
|
||||
@@ -0,0 +1,443 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
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")
|
||||
|
||||
|
||||
class _ImpersonateTarget:
|
||||
@staticmethod
|
||||
def from_str(value):
|
||||
return value
|
||||
|
||||
|
||||
fake_impersonate.ImpersonateTarget = _ImpersonateTarget
|
||||
fake_networking.impersonate = fake_impersonate
|
||||
fake_yt_dlp.networking = fake_networking
|
||||
fake_yt_dlp.utils = types.SimpleNamespace(YoutubeDLError=Exception)
|
||||
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)
|
||||
|
||||
from subscriptions import SubscriptionManager, extract_flat_playlist
|
||||
|
||||
|
||||
class _Config:
|
||||
def __init__(self, state_dir: str):
|
||||
self.STATE_DIR = state_dir
|
||||
self.SUBSCRIPTION_SCAN_PLAYLIST_END = 50
|
||||
self.SUBSCRIPTION_MAX_SEEN_IDS = 50000
|
||||
self.DOWNLOAD_DIR = state_dir
|
||||
self.TEMP_DIR = state_dir
|
||||
self.YTDL_OPTIONS = {}
|
||||
|
||||
|
||||
class _Queue:
|
||||
def __init__(self):
|
||||
self.entries = []
|
||||
self.fail = False
|
||||
|
||||
async def add(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
async def add_entry(self, entry, *args, **kwargs):
|
||||
if self.fail:
|
||||
return {"status": "error", "msg": "queue failed"}
|
||||
self.entries.append((entry, args, kwargs))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
class _Notifier:
|
||||
async def subscription_added(self, sub):
|
||||
return None
|
||||
|
||||
async def subscription_updated(self, sub):
|
||||
return None
|
||||
|
||||
async def subscription_removed(self, sub_id):
|
||||
return None
|
||||
|
||||
async def subscriptions_all(self, subs):
|
||||
return None
|
||||
|
||||
|
||||
def _create_legacy_shelf(path: str, record) -> None:
|
||||
with shelve.open(path, "c") as shelf:
|
||||
shelf["sub-1"] = record
|
||||
|
||||
|
||||
class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_load_imports_legacy_subscription_shelf(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
legacy_path = os.path.join(tmp, "subscriptions")
|
||||
json_path = os.path.join(tmp, "subscriptions.json")
|
||||
_create_legacy_shelf(
|
||||
legacy_path,
|
||||
{
|
||||
"id": "sub-1",
|
||||
"name": "Channel",
|
||||
"url": "https://example.com/channel",
|
||||
"timestamp": 1.0,
|
||||
},
|
||||
)
|
||||
|
||||
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||
|
||||
self.assertEqual(len(mgr.list_all()), 1)
|
||||
self.assertTrue(os.path.exists(json_path))
|
||||
with open(json_path, encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
self.assertEqual(payload["schema_version"], 2)
|
||||
self.assertNotIn("timestamp", payload["items"][0])
|
||||
|
||||
def test_invalid_json_is_quarantined_and_legacy_is_imported(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
legacy_path = os.path.join(tmp, "subscriptions")
|
||||
json_path = os.path.join(tmp, "subscriptions.json")
|
||||
_create_legacy_shelf(
|
||||
legacy_path,
|
||||
{
|
||||
"id": "sub-1",
|
||||
"name": "Channel",
|
||||
"url": "https://example.com/channel",
|
||||
"timestamp": 1.0,
|
||||
},
|
||||
)
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
f.write("{not valid json")
|
||||
|
||||
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||
|
||||
self.assertEqual(len(mgr.list_all()), 1)
|
||||
self.assertTrue(
|
||||
any(name.startswith("subscriptions.json.invalid.") for name in os.listdir(tmp))
|
||||
)
|
||||
|
||||
def test_load_rewrites_old_json_and_trims_seen_ids(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
json_path = os.path.join(tmp, "subscriptions.json")
|
||||
cfg = _Config(tmp)
|
||||
cfg.SUBSCRIPTION_MAX_SEEN_IDS = 2
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"kind": "subscriptions",
|
||||
"items": [
|
||||
{
|
||||
"id": "sub-1",
|
||||
"name": "Channel",
|
||||
"url": "https://example.com/channel",
|
||||
"enabled": True,
|
||||
"check_interval_minutes": 60,
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
"folder": "",
|
||||
"custom_name_prefix": "",
|
||||
"auto_start": True,
|
||||
"playlist_item_limit": 0,
|
||||
"split_by_chapters": False,
|
||||
"chapter_template": "",
|
||||
"subtitle_language": "en",
|
||||
"subtitle_mode": "prefer_manual",
|
||||
"last_checked": None,
|
||||
"seen_ids": ["a", "b", "a", "c"],
|
||||
"error": None,
|
||||
"timestamp": 123,
|
||||
}
|
||||
],
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||
self.assertEqual(mgr.list_all()[0].seen_ids, ["a", "b"])
|
||||
|
||||
with open(json_path, encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
|
||||
self.assertEqual(payload["schema_version"], 2)
|
||||
self.assertEqual(payload["items"][0]["seen_ids"], ["a", "b"])
|
||||
self.assertNotIn("timestamp", payload["items"][0])
|
||||
|
||||
async def test_add_subscription_rolls_back_when_state_write_fails(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||
|
||||
orig_save = __import__("state_store").AtomicJsonStore.save
|
||||
|
||||
def bad_save(store, data):
|
||||
if store.path == mgr._path:
|
||||
raise OSError("simulated shelf failure")
|
||||
return orig_save(store, data)
|
||||
|
||||
with patch(
|
||||
"subscriptions.extract_flat_playlist",
|
||||
return_value=(
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[{"id": "v1", "webpage_url": "https://example.com/v1"}],
|
||||
),
|
||||
):
|
||||
with patch("subscriptions.AtomicJsonStore.save", bad_save):
|
||||
with self.assertRaises(OSError):
|
||||
await mgr.add_subscription(
|
||||
"https://example.com/channel",
|
||||
check_interval_minutes=60,
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
auto_start=True,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
)
|
||||
|
||||
self.assertEqual(mgr.list_all(), [])
|
||||
self.assertNotIn("https://example.com/channel", mgr._url_index)
|
||||
|
||||
async def test_add_subscription_marks_existing_videos_seen_without_queueing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
queue = _Queue()
|
||||
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
|
||||
|
||||
with patch(
|
||||
"subscriptions.extract_flat_playlist",
|
||||
return_value=(
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[
|
||||
{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"},
|
||||
{"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"},
|
||||
{"id": "v3", "title": "Three", "webpage_url": "https://example.com/v3"},
|
||||
],
|
||||
),
|
||||
):
|
||||
result = await mgr.add_subscription(
|
||||
"https://example.com/channel",
|
||||
check_interval_minutes=60,
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
auto_start=True,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
sub = mgr.list_all()[0]
|
||||
self.assertEqual(sub.seen_ids, ["v1", "v2", "v3"])
|
||||
self.assertIsNone(sub.error)
|
||||
self.assertEqual(queue.entries, [])
|
||||
|
||||
async def test_add_subscription_skips_collection_tab_entries(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
queue = _Queue()
|
||||
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
|
||||
|
||||
with patch(
|
||||
"subscriptions.extract_flat_playlist",
|
||||
return_value=(
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[
|
||||
{
|
||||
"_type": "url",
|
||||
"ie_key": "YoutubeTab",
|
||||
"title": "Channel - Live",
|
||||
"url": "https://example.com/live",
|
||||
"webpage_url": "https://example.com/live",
|
||||
},
|
||||
{
|
||||
"_type": "url",
|
||||
"ie_key": "Youtube",
|
||||
"id": "v1",
|
||||
"title": "One",
|
||||
"duration": 10,
|
||||
"webpage_url": "https://example.com/v1",
|
||||
},
|
||||
],
|
||||
),
|
||||
):
|
||||
result = await mgr.add_subscription(
|
||||
"https://example.com/channel",
|
||||
check_interval_minutes=60,
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
auto_start=True,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "ok")
|
||||
sub = mgr.list_all()[0]
|
||||
self.assertEqual(sub.seen_ids, ["v1"])
|
||||
self.assertEqual(queue.entries, [])
|
||||
|
||||
async def test_check_now_keeps_failed_queue_items_unseen_and_sets_error(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
queue = _Queue()
|
||||
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
|
||||
|
||||
with patch(
|
||||
"subscriptions.extract_flat_playlist",
|
||||
side_effect=[
|
||||
(
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||
),
|
||||
(
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[{"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"}],
|
||||
),
|
||||
],
|
||||
):
|
||||
result = await mgr.add_subscription(
|
||||
"https://example.com/channel",
|
||||
check_interval_minutes=60,
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
auto_start=True,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
)
|
||||
queue.fail = True
|
||||
await mgr.check_now([result["subscription"]["id"]])
|
||||
|
||||
sub = mgr.list_all()[0]
|
||||
self.assertEqual(sub.error, "queue failed")
|
||||
self.assertEqual(sub.seen_ids, ["v1"])
|
||||
|
||||
async def test_check_now_queues_new_video_and_updates_seen_ids(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
queue = _Queue()
|
||||
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
|
||||
|
||||
with patch(
|
||||
"subscriptions.extract_flat_playlist",
|
||||
side_effect=[
|
||||
(
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||
),
|
||||
(
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[
|
||||
{"id": "v2", "title": "Two", "webpage_url": "https://example.com/v2"},
|
||||
{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"},
|
||||
],
|
||||
),
|
||||
],
|
||||
):
|
||||
result = await mgr.add_subscription(
|
||||
"https://example.com/channel",
|
||||
check_interval_minutes=60,
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
auto_start=True,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
)
|
||||
await mgr.check_now([result["subscription"]["id"]])
|
||||
|
||||
sub = mgr.list_all()[0]
|
||||
self.assertIsNotNone(sub.last_checked)
|
||||
self.assertIsNone(sub.error)
|
||||
self.assertEqual(sub.seen_ids[:2], ["v2", "v1"])
|
||||
self.assertEqual([entry["webpage_url"] for entry, _, _ in queue.entries], ["https://example.com/v2"])
|
||||
|
||||
class ExtractFlatPlaylistTests(unittest.TestCase):
|
||||
def test_descends_one_level_when_root_entries_are_nested_collections(self):
|
||||
responses = iter(
|
||||
[
|
||||
{
|
||||
"_type": "channel",
|
||||
"entries": [
|
||||
{
|
||||
"_type": "url",
|
||||
"ie_key": "YoutubeTab",
|
||||
"title": "Channel - Videos",
|
||||
"url": "https://example.com/videos",
|
||||
"webpage_url": "https://example.com/videos",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"_type": "playlist",
|
||||
"entries": [
|
||||
{
|
||||
"_type": "url",
|
||||
"ie_key": "Youtube",
|
||||
"id": "v1",
|
||||
"title": "One",
|
||||
"duration": 10,
|
||||
"webpage_url": "https://example.com/v1",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
class _FakeYDL:
|
||||
def __init__(self, params):
|
||||
self.params = params
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def extract_info(self, url, download=False):
|
||||
return next(responses)
|
||||
|
||||
cfg = _Config(tempfile.mkdtemp())
|
||||
with patch("subscriptions.yt_dlp.YoutubeDL", _FakeYDL, create=True):
|
||||
info, entries = extract_flat_playlist(cfg, "https://example.com/channel", 50)
|
||||
|
||||
self.assertEqual(info.get("_type"), "playlist")
|
||||
self.assertEqual([entry["webpage_url"] for entry in entries], ["https://example.com/v1"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Tests for pure helpers and migration logic in ``ytdl``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pickle
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
# The inner ``key`` group mirrors the real ``STR_FORMAT_RE_TMPL`` so that
|
||||
# ``_OUTTMPL_FIELD_RE`` (compiled at import time) has the named group that
|
||||
# ``_resolve_outtmpl_fields`` reads via ``match.group('key')``.
|
||||
fake_utils.STR_FORMAT_RE_TMPL = r"(?P<prefix>)%\((?P<has_key>(?P<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,
|
||||
_compact_persisted_entry,
|
||||
_convert_srt_to_txt_file,
|
||||
_resolve_outtmpl_fields,
|
||||
_sanitize_entry_for_pickle,
|
||||
_sanitize_path_component,
|
||||
)
|
||||
|
||||
# Detect whether the real yt-dlp is loaded (as opposed to the minimal fake
|
||||
# shim above). _resolve_outtmpl_fields needs YoutubeDL at runtime.
|
||||
_has_real_ytdlp = hasattr(sys.modules.get("yt_dlp"), "YoutubeDL")
|
||||
|
||||
|
||||
class SanitizePathComponentTests(unittest.TestCase):
|
||||
def test_replaces_windows_invalid_chars(self):
|
||||
self.assertEqual(_sanitize_path_component('a:b*c?d"e<f>g|h'), "a_b_c_d_e_f_g_h")
|
||||
|
||||
def test_non_string_passthrough(self):
|
||||
self.assertIs(_sanitize_path_component(None), None)
|
||||
self.assertEqual(_sanitize_path_component(42), 42)
|
||||
|
||||
|
||||
@unittest.skipUnless(_has_real_ytdlp, "requires real yt-dlp")
|
||||
class ResolveOuttmplFieldsTests(unittest.TestCase):
|
||||
"""Tests for _resolve_outtmpl_fields (delegates to yt-dlp's template engine)."""
|
||||
|
||||
def test_simple_playlist_substitution(self):
|
||||
info = {"playlist_title": "My PL", "playlist_index": "03"}
|
||||
result = _resolve_outtmpl_fields("%(playlist_title)s/%(title)s.%(ext)s", info, ("playlist",))
|
||||
self.assertEqual(result, "My PL/%(title)s.%(ext)s")
|
||||
|
||||
def test_format_spec_int(self):
|
||||
info = {"playlist_index": "3"}
|
||||
result = _resolve_outtmpl_fields("%(playlist_index)02d-%(title)s", info, ("playlist",))
|
||||
self.assertEqual(result, "03-%(title)s")
|
||||
|
||||
def test_non_targeted_fields_unchanged(self):
|
||||
info = {"playlist_title": "PL"}
|
||||
result = _resolve_outtmpl_fields("%(title)s/%(ext)s", info, ("playlist",))
|
||||
self.assertEqual(result, "%(title)s/%(ext)s")
|
||||
|
||||
def test_default_value(self):
|
||||
info = {"playlist_index": "1"}
|
||||
result = _resolve_outtmpl_fields("%(playlist_title|Unknown)s/%(playlist_index)s", info, ("playlist",))
|
||||
self.assertEqual(result, "Unknown/1")
|
||||
|
||||
def test_channel_prefix(self):
|
||||
info = {"channel": "MyChan", "channel_index": "05"}
|
||||
result = _resolve_outtmpl_fields("%(channel)s/%(channel_index)02d-%(title)s", info, ("channel",))
|
||||
self.assertEqual(result, "MyChan/05-%(title)s")
|
||||
|
||||
def test_math_operation(self):
|
||||
info = {"playlist_index": "3"}
|
||||
result = _resolve_outtmpl_fields("%(playlist_index+100)d", info, ("playlist",))
|
||||
self.assertEqual(result, "103")
|
||||
|
||||
def test_playlist_count_and_autonumber(self):
|
||||
info = {
|
||||
"playlist_title": "My PL",
|
||||
"playlist_index": "03",
|
||||
"playlist_count": 10,
|
||||
"playlist_autonumber": 3,
|
||||
"n_entries": 10,
|
||||
"__last_playlist_index": 10,
|
||||
}
|
||||
result = _resolve_outtmpl_fields(
|
||||
"%(playlist_title)s/%(playlist_autonumber)s of %(playlist_count)s - %(title)s.%(ext)s",
|
||||
info,
|
||||
("playlist",),
|
||||
)
|
||||
# playlist_autonumber is auto-padded by yt-dlp using __last_playlist_index
|
||||
self.assertEqual(result, "My PL/03 of 10 - %(title)s.%(ext)s")
|
||||
|
||||
def test_conditional_playlist_index(self):
|
||||
info = {
|
||||
"playlist_index": "5",
|
||||
"playlist_count": 10,
|
||||
}
|
||||
result = _resolve_outtmpl_fields(
|
||||
"%(playlist_index&{} - |)s%(title)s.%(ext)s",
|
||||
info,
|
||||
("playlist",),
|
||||
)
|
||||
self.assertEqual(result, "5 - %(title)s.%(ext)s")
|
||||
|
||||
|
||||
class SanitizeEntryForPickleTests(unittest.TestCase):
|
||||
def test_nested(self):
|
||||
def g():
|
||||
yield 1
|
||||
|
||||
obj = {"a": g(), "b": [g()]}
|
||||
out = _sanitize_entry_for_pickle(obj)
|
||||
self.assertEqual(out, {"a": [1], "b": [[1]]})
|
||||
pickle.dumps(out)
|
||||
|
||||
def test_plain(self):
|
||||
self.assertEqual(_sanitize_entry_for_pickle(5), 5)
|
||||
|
||||
def test_set_converted_to_list(self):
|
||||
obj = {"s": {1, 2}}
|
||||
out = _sanitize_entry_for_pickle(obj)
|
||||
self.assertEqual(sorted(out["s"]), [1, 2])
|
||||
pickle.dumps(out)
|
||||
|
||||
def test_map_iterator(self):
|
||||
out = _sanitize_entry_for_pickle({"m": map(int, ["1", "2"])})
|
||||
self.assertEqual(out, {"m": [1, 2]})
|
||||
|
||||
def test_lock_replaced_with_none(self):
|
||||
lock = threading.Lock()
|
||||
out = _sanitize_entry_for_pickle({"k": lock})
|
||||
self.assertIsNone(out["k"])
|
||||
pickle.dumps(out)
|
||||
|
||||
def test_ordered_dict(self):
|
||||
from collections import OrderedDict
|
||||
|
||||
od = OrderedDict([("z", 1), ("a", 2)])
|
||||
out = _sanitize_entry_for_pickle(od)
|
||||
self.assertEqual(out, {"z": 1, "a": 2})
|
||||
|
||||
|
||||
class ConvertSrtToTxtTests(unittest.TestCase):
|
||||
def test_basic_conversion(self):
|
||||
srt = """1
|
||||
00:00:01,000 --> 00:00:02,000
|
||||
Hello <b>world</b>
|
||||
|
||||
2
|
||||
00:00:03,000 --> 00:00:04,000
|
||||
Second line
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "sub.srt"
|
||||
path.write_text(srt, encoding="utf-8")
|
||||
txt_path = _convert_srt_to_txt_file(str(path))
|
||||
self.assertIsNotNone(txt_path)
|
||||
self.assertTrue(txt_path.endswith(".txt"))
|
||||
content = Path(txt_path).read_text(encoding="utf-8")
|
||||
self.assertIn("Hello world", content)
|
||||
self.assertIn("Second line", content)
|
||||
|
||||
|
||||
class DownloadInfoSetstateTests(unittest.TestCase):
|
||||
def _base_state(self, **kwargs):
|
||||
base = {
|
||||
"id": "id1",
|
||||
"title": "t",
|
||||
"url": "http://example.com/v",
|
||||
"folder": "",
|
||||
"custom_name_prefix": "",
|
||||
"error": None,
|
||||
"entry": None,
|
||||
"playlist_item_limit": 0,
|
||||
"split_by_chapters": False,
|
||||
"chapter_template": "",
|
||||
"msg": None,
|
||||
"percent": None,
|
||||
"speed": None,
|
||||
"eta": None,
|
||||
"status": "pending",
|
||||
"size": None,
|
||||
"timestamp": 0,
|
||||
}
|
||||
base.update(kwargs)
|
||||
return base
|
||||
|
||||
def test_migrates_old_audio_format(self):
|
||||
state = self._base_state(format="m4a", quality="best")
|
||||
di = DownloadInfo.__new__(DownloadInfo)
|
||||
di.__setstate__(state)
|
||||
self.assertEqual(di.download_type, "audio")
|
||||
self.assertEqual(di.codec, "auto")
|
||||
|
||||
def test_migrates_thumbnail(self):
|
||||
state = self._base_state(format="thumbnail", quality="best")
|
||||
di = DownloadInfo.__new__(DownloadInfo)
|
||||
di.__setstate__(state)
|
||||
self.assertEqual(di.download_type, "thumbnail")
|
||||
self.assertEqual(di.format, "jpg")
|
||||
|
||||
def test_migrates_captions(self):
|
||||
state = self._base_state(format="captions", subtitle_format="vtt", quality="best")
|
||||
di = DownloadInfo.__new__(DownloadInfo)
|
||||
di.__setstate__(state)
|
||||
self.assertEqual(di.download_type, "captions")
|
||||
self.assertEqual(di.format, "vtt")
|
||||
|
||||
def test_migrates_best_ios(self):
|
||||
state = self._base_state(
|
||||
format="any", quality="best_ios", video_codec="auto"
|
||||
)
|
||||
di = DownloadInfo.__new__(DownloadInfo)
|
||||
di.__setstate__(state)
|
||||
self.assertEqual(di.format, "ios")
|
||||
self.assertEqual(di.quality, "best")
|
||||
|
||||
def test_migrates_quality_audio(self):
|
||||
state = self._base_state(format="mp4", quality="audio", video_codec="h264")
|
||||
di = DownloadInfo.__new__(DownloadInfo)
|
||||
di.__setstate__(state)
|
||||
self.assertEqual(di.download_type, "audio")
|
||||
self.assertEqual(di.format, "m4a")
|
||||
|
||||
def test_new_state_has_subtitle_files(self):
|
||||
state = self._base_state(
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
)
|
||||
di = DownloadInfo.__new__(DownloadInfo)
|
||||
di.__setstate__(state)
|
||||
self.assertEqual(di.subtitle_files, [])
|
||||
|
||||
def test_missing_optional_fields_are_defaulted(self):
|
||||
state = self._base_state(
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
)
|
||||
state.pop("folder")
|
||||
state.pop("custom_name_prefix")
|
||||
state.pop("playlist_item_limit")
|
||||
state.pop("split_by_chapters")
|
||||
state.pop("chapter_template")
|
||||
di = DownloadInfo.__new__(DownloadInfo)
|
||||
di.__setstate__(state)
|
||||
self.assertEqual(di.folder, "")
|
||||
self.assertEqual(di.custom_name_prefix, "")
|
||||
self.assertEqual(di.playlist_item_limit, 0)
|
||||
self.assertFalse(di.split_by_chapters)
|
||||
self.assertEqual(di.chapter_template, "")
|
||||
|
||||
|
||||
class CompactPersistedEntryTests(unittest.TestCase):
|
||||
def test_keeps_only_playlist_and_channel_keys(self):
|
||||
entry = {
|
||||
"playlist_index": "01",
|
||||
"playlist_title": "Playlist",
|
||||
"playlist_count": 10,
|
||||
"playlist_autonumber": 1,
|
||||
"channel_index": "02",
|
||||
"channel_title": "Channel",
|
||||
"n_entries": 10,
|
||||
"__last_playlist_index": 10,
|
||||
"formats": [{"id": "huge"}],
|
||||
"description": "big blob",
|
||||
}
|
||||
|
||||
compact = _compact_persisted_entry(entry)
|
||||
|
||||
self.assertEqual(
|
||||
compact,
|
||||
{
|
||||
"playlist_index": "01",
|
||||
"playlist_title": "Playlist",
|
||||
"playlist_count": 10,
|
||||
"playlist_autonumber": 1,
|
||||
"channel_index": "02",
|
||||
"channel_title": "Channel",
|
||||
"n_entries": 10,
|
||||
"__last_playlist_index": 10,
|
||||
},
|
||||
)
|
||||
|
||||
def test_returns_none_when_no_restart_relevant_keys_exist(self):
|
||||
self.assertIsNone(_compact_persisted_entry({"id": "x", "title": "y"}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+678
-144
File diff suppressed because it is too large
Load Diff
+15
-6
@@ -1,19 +1,28 @@
|
||||
#!/bin/sh
|
||||
|
||||
PUID="${UID:-$PUID}"
|
||||
PGID="${GID:-$PGID}"
|
||||
|
||||
echo "Setting umask to ${UMASK}"
|
||||
umask ${UMASK}
|
||||
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 [ "${UID}" -eq 0 ]; then
|
||||
echo "Warning: it is not recommended to run as root user, please check your setting of the UID environment variable"
|
||||
if [ "${PUID}" -eq 0 ]; then
|
||||
echo "Warning: it is not recommended to run as root user, please check your setting of the PUID/PGID (or legacy UID/GID) environment variables"
|
||||
fi
|
||||
echo "Changing ownership of download and state directories to ${UID}:${GID}"
|
||||
chown -R "${UID}":"${GID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
||||
echo "Running MeTube as user ${UID}:${GID}"
|
||||
exec su-exec "${UID}":"${GID}" python3 app/main.py
|
||||
if [ "${CHOWN_DIRS:-true}" != "false" ]; then
|
||||
echo "Changing ownership of download and state directories to ${PUID}:${PGID}"
|
||||
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}"
|
||||
exec gosu "${PUID}":"${PGID}" python3 app/main.py
|
||||
else
|
||||
echo "User set by docker; running MeTube as `id -u`:`id -g`"
|
||||
echo "Starting BgUtils POT Provider"
|
||||
bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
|
||||
exec python3 app/main.py
|
||||
fi
|
||||
|
||||
+10
-1
@@ -6,7 +6,7 @@ requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
"python-socketio>=5.0,<6.0",
|
||||
"yt-dlp[default,curl-cffi]",
|
||||
"yt-dlp[default,curl-cffi,deno]",
|
||||
"mutagen",
|
||||
"curl-cffi",
|
||||
"watchfiles",
|
||||
@@ -15,4 +15,13 @@ 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"
|
||||
|
||||
+3
-4
@@ -33,9 +33,7 @@
|
||||
"node_modules/@ng-select/ng-select/themes/default.theme.css",
|
||||
"src/styles.sass"
|
||||
],
|
||||
"scripts": [
|
||||
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
|
||||
],
|
||||
"scripts": [],
|
||||
"serviceWorker": "ngsw-config.json",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
@@ -77,7 +75,8 @@
|
||||
"buildTarget": "metube:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "metube:build:development"
|
||||
"buildTarget": "metube:build:development",
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
|
||||
+28
-27
@@ -23,40 +23,41 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.0.0",
|
||||
"@angular/common": "^21.0.0",
|
||||
"@angular/compiler": "^21.0.0",
|
||||
"@angular/core": "^21.0.0",
|
||||
"@angular/forms": "^21.0.0",
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"@angular/platform-browser-dynamic": "^21.0.0",
|
||||
"@angular/service-worker": "^21.0.0",
|
||||
"@angular/animations": "^21.2.6",
|
||||
"@angular/common": "^21.2.6",
|
||||
"@angular/compiler": "^21.2.6",
|
||||
"@angular/core": "^21.2.6",
|
||||
"@angular/forms": "^21.2.6",
|
||||
"@angular/platform-browser": "^21.2.6",
|
||||
"@angular/platform-browser-dynamic": "^21.2.6",
|
||||
"@angular/service-worker": "^21.2.6",
|
||||
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.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.1.0",
|
||||
"bootstrap": "^5.3.6",
|
||||
"ngx-cookie-service": "^21.1.0",
|
||||
"ngx-socket-io": "~4.9.3",
|
||||
"rxjs": "~7.8.0",
|
||||
"@ng-select/ng-select": "^21.7.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"ngx-cookie-service": "^21.3.1",
|
||||
"ngx-socket-io": "~4.10.0",
|
||||
"rxjs": "~7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"zone.js": "0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-eslint/builder": "21.1.0",
|
||||
"@angular/build": "^21.0.3",
|
||||
"@angular/cli": "^21.0.3",
|
||||
"@angular/compiler-cli": "^21.0.0",
|
||||
"@angular/localize": "^21.0.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@angular/build": "^21.2.5",
|
||||
"@angular/cli": "^21.2.5",
|
||||
"@angular/compiler-cli": "^21.2.6",
|
||||
"@angular/localize": "^21.2.6",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"angular-eslint": "21.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"jsdom": "^27.1.0",
|
||||
"typescript": "~5.9.2",
|
||||
"eslint": "^9.39.4",
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "8.47.0",
|
||||
"vitest": "^4.0.8"
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1859
-2175
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZonelessChangeDetection, provideZoneChangeDetection } from '@angular/core';
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
|
||||
+568
-118
@@ -49,22 +49,22 @@
|
||||
</div>
|
||||
-->
|
||||
<div class="navbar-nav ms-auto">
|
||||
<div class="nav-item dropdown">
|
||||
<div class="nav-item dropdown" ngbDropdown placement="bottom-end">
|
||||
<button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center"
|
||||
id="theme-select"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
data-bs-toggle="dropdown"
|
||||
data-bs-display="static">
|
||||
ngbDropdownToggle>
|
||||
@if(activeTheme){
|
||||
<fa-icon [icon]="activeTheme.icon" />
|
||||
}
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select">
|
||||
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select" ngbDropdownMenu>
|
||||
@for (theme of themes; track theme) {
|
||||
<li>
|
||||
<button type="button" class="dropdown-item d-flex align-items-center"
|
||||
<button type="button" class="dropdown-item d-flex align-items-center"
|
||||
[class.active]="activeTheme === theme"
|
||||
ngbDropdownItem
|
||||
(click)="themeChanged(theme)">
|
||||
<span class="me-2 opacity-50">
|
||||
<fa-icon [icon]="theme.icon" />
|
||||
@@ -89,63 +89,278 @@
|
||||
<!-- Main URL Input with Download Button -->
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<div class="input-group input-group-lg shadow-sm">
|
||||
<ng-template #urlBarActions>
|
||||
@if (addInProgress && cancelRequested) {
|
||||
<button class="btn btn-warning btn-lg px-3" type="button" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
Canceling...
|
||||
</button>
|
||||
} @else if (addInProgress) {
|
||||
<button class="btn btn-secondary btn-lg px-3 add-progress-btn" type="button" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Adding...
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-lg px-3 add-cancel-btn"
|
||||
type="button"
|
||||
(click)="cancelAdding()"
|
||||
aria-label="Cancel adding URL"
|
||||
title="Cancel adding URL">
|
||||
<fa-icon [icon]="faTimesCircle" class="me-1" /> Cancel
|
||||
</button>
|
||||
} @else if (subscribeInProgress) {
|
||||
<button class="btn btn-primary btn-lg px-4" type="button" disabled>
|
||||
Download
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-lg px-3" type="button" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Subscribing...
|
||||
</button>
|
||||
} @else {
|
||||
<button class="btn btn-primary btn-lg px-4" type="submit"
|
||||
(click)="addDownload()"
|
||||
[disabled]="downloads.loading">
|
||||
Download
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-lg px-3" type="button"
|
||||
(click)="addSubscription()"
|
||||
[disabled]="downloads.loading">
|
||||
Subscribe
|
||||
</button>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<!-- Narrow viewports: full-width field, then Bootstrap btn-group (no faux input-group strip) -->
|
||||
<div class="vstack gap-2 d-md-none">
|
||||
<input type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="form-control form-control-lg"
|
||||
placeholder="Enter video or playlist URL"
|
||||
name="addUrl"
|
||||
placeholder="Enter video, channel, or playlist URL"
|
||||
[(ngModel)]="addUrl"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
<button class="btn btn-primary btn-lg px-4"
|
||||
type="submit"
|
||||
(click)="addDownload()"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
@if (addInProgress) {
|
||||
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner"></span>
|
||||
}
|
||||
{{ addInProgress ? "Adding..." : "Download" }}
|
||||
</button>
|
||||
[ngModelOptions]="{standalone: true}"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
<div class="btn-group w-100" role="group" aria-label="Download or subscribe">
|
||||
<ng-container [ngTemplateOutlet]="urlBarActions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- md and up: standard input-group so Bootstrap handles fused borders -->
|
||||
<div class="input-group input-group-lg shadow-sm d-none d-md-flex">
|
||||
<input type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="form-control form-control-lg"
|
||||
placeholder="Enter video, channel, or playlist URL"
|
||||
[(ngModel)]="addUrl"
|
||||
[ngModelOptions]="{standalone: true}"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
<ng-container [ngTemplateOutlet]="urlBarActions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options Row -->
|
||||
<div class="row mb-3 g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Quality</span>
|
||||
<select class="form-select"
|
||||
name="quality"
|
||||
[(ngModel)]="quality"
|
||||
(change)="qualityChanged()"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
@for (q of qualities; track q) {
|
||||
<option [ngValue]="q.id">{{ q.text }}</option>
|
||||
}
|
||||
</select>
|
||||
@if (downloadType === 'video') {
|
||||
<div class="col-md-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Type</span>
|
||||
<select class="form-select"
|
||||
name="downloadType"
|
||||
[(ngModel)]="downloadType"
|
||||
(change)="downloadTypeChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
@for (type of downloadTypes; track type.id) {
|
||||
<option [ngValue]="type.id">{{ type.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Format</span>
|
||||
<select class="form-select"
|
||||
name="format"
|
||||
[(ngModel)]="format"
|
||||
(change)="formatChanged()"
|
||||
[disabled]="addInProgress || downloads.loading">
|
||||
@for (f of formats; track f) {
|
||||
<option [ngValue]="f.id">{{ f.text }}</option>
|
||||
}
|
||||
</select>
|
||||
<div class="col-md-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Codec</span>
|
||||
<select class="form-select"
|
||||
name="codec"
|
||||
[(ngModel)]="codec"
|
||||
(change)="codecChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
@for (vc of videoCodecs; track vc.id) {
|
||||
<option [ngValue]="vc.id">{{ vc.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Format</span>
|
||||
<select class="form-select"
|
||||
name="format"
|
||||
[(ngModel)]="format"
|
||||
(change)="formatChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
@for (f of formatOptions; track f.id) {
|
||||
<option [ngValue]="f.id">{{ f.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Quality</span>
|
||||
<select class="form-select"
|
||||
name="quality"
|
||||
[(ngModel)]="quality"
|
||||
(change)="qualityChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading || !showQualitySelector()">
|
||||
@for (q of qualities; track q.id) {
|
||||
<option [ngValue]="q.id">{{ q.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (downloadType === 'audio') {
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Type</span>
|
||||
<select class="form-select"
|
||||
name="downloadType"
|
||||
[(ngModel)]="downloadType"
|
||||
(change)="downloadTypeChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
@for (type of downloadTypes; track type.id) {
|
||||
<option [ngValue]="type.id">{{ type.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Format</span>
|
||||
<select class="form-select"
|
||||
name="format"
|
||||
[(ngModel)]="format"
|
||||
(change)="formatChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
@for (f of formatOptions; track f.id) {
|
||||
<option [ngValue]="f.id">{{ f.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Quality</span>
|
||||
<select class="form-select"
|
||||
name="quality"
|
||||
[(ngModel)]="quality"
|
||||
(change)="qualityChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
@for (q of qualities; track q.id) {
|
||||
<option [ngValue]="q.id">{{ q.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (downloadType === 'captions') {
|
||||
<!-- 4× col-md-3 is too tight at ~768px (long addons wrap the 4th field); 2×2 md–lg, one row lg+ -->
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Type</span>
|
||||
<select class="form-select"
|
||||
name="downloadType"
|
||||
[(ngModel)]="downloadType"
|
||||
(change)="downloadTypeChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
@for (type of downloadTypes; track type.id) {
|
||||
<option [ngValue]="type.id">{{ type.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Format</span>
|
||||
<select class="form-select"
|
||||
name="format"
|
||||
[(ngModel)]="format"
|
||||
(change)="formatChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Subtitle output format for captions mode">
|
||||
@for (f of formatOptions; track f.id) {
|
||||
<option [ngValue]="f.id">{{ f.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Language</span>
|
||||
<input class="form-control"
|
||||
type="text"
|
||||
list="subtitleLanguageOptions"
|
||||
name="subtitleLanguage"
|
||||
[(ngModel)]="subtitleLanguage"
|
||||
(change)="subtitleLanguageChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
placeholder="e.g. en, es, zh-Hans"
|
||||
ngbTooltip="Subtitle language (you can type any language code)">
|
||||
<datalist id="subtitleLanguageOptions">
|
||||
@for (lang of subtitleLanguages; track lang.id) {
|
||||
<option [value]="lang.id">{{ lang.text }}</option>
|
||||
}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Subtitle Source</span>
|
||||
<select class="form-select"
|
||||
name="subtitleMode"
|
||||
[(ngModel)]="subtitleMode"
|
||||
(change)="subtitleModeChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Choose manual, auto, or fallback preference for captions mode">
|
||||
@for (mode of subtitleModes; track mode.id) {
|
||||
<option [ngValue]="mode.id">{{ mode.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Type</span>
|
||||
<select class="form-select"
|
||||
name="downloadType"
|
||||
[(ngModel)]="downloadType"
|
||||
(change)="downloadTypeChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
|
||||
@for (type of downloadTypes; track type.id) {
|
||||
<option [ngValue]="type.id">{{ type.text }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Format</span>
|
||||
<input class="form-control" value="JPG" disabled>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row mb-3 g-3">
|
||||
<div class="col-12 text-start">
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary w-100 h-100"
|
||||
(click)="toggleAdvanced()">
|
||||
class="btn btn-link p-0 text-decoration-none"
|
||||
(click)="toggleAdvanced()"
|
||||
[attr.aria-expanded]="isAdvancedOpen"
|
||||
aria-controls="advancedOptions">
|
||||
Advanced Options
|
||||
<fa-icon
|
||||
[icon]="isAdvancedOpen ? faChevronDown : faChevronRight"
|
||||
class="ms-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,7 +369,7 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="collapse show" id="advancedOptions" [ngbCollapse]="!isAdvancedOpen">
|
||||
<div class="card card-body">
|
||||
<div class="py-2">
|
||||
<!-- Advanced Settings -->
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-md-6">
|
||||
@@ -164,7 +379,7 @@
|
||||
name="autoStart"
|
||||
[(ngModel)]="autoStart"
|
||||
(change)="autoStartChanged()"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Automatically start downloads when added">
|
||||
<option [ngValue]="true">Yes</option>
|
||||
<option [ngValue]="false">No</option>
|
||||
@@ -181,7 +396,7 @@
|
||||
addTagText="Create directory"
|
||||
bindLabel="folder"
|
||||
[(ngModel)]="folder"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
[virtualScroll]="true"
|
||||
[clearable]="true"
|
||||
[loading]="downloads.loading"
|
||||
@@ -190,7 +405,7 @@
|
||||
ngbTooltip="Choose where to save downloads. Type to create a new folder." />
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
@@ -200,7 +415,7 @@
|
||||
placeholder="Default"
|
||||
name="customNamePrefix"
|
||||
[(ngModel)]="customNamePrefix"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Add a prefix to downloaded filenames">
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,8 +429,22 @@
|
||||
name="playlistItemLimit"
|
||||
(keydown)="isNumber($event)"
|
||||
[(ngModel)]="playlistItemLimit"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
ngbTooltip="Maximum number of items to download from a playlist (0 = no limit)">
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Subscription Check (min)</span>
|
||||
<input type="number"
|
||||
min="1"
|
||||
class="form-control"
|
||||
name="checkIntervalMinutes"
|
||||
(keydown)="isNumber($event)"
|
||||
[(ngModel)]="checkIntervalMinutes"
|
||||
(ngModelChange)="checkIntervalChanged()"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="How often to poll subscriptions for new videos">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
@@ -224,7 +453,7 @@
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
|
||||
name="splitByChapters" [(ngModel)]="splitByChapters" (change)="splitByChaptersChanged()"
|
||||
[disabled]="addInProgress || downloads.loading"
|
||||
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Split video into separate files by chapters">
|
||||
<label class="form-check-label" for="checkbox-split-chapters">Split by chapters</label>
|
||||
</div>
|
||||
@@ -234,7 +463,7 @@
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">Template</span>
|
||||
<input type="text" class="form-control" name="chapterTemplate" [(ngModel)]="chapterTemplate"
|
||||
(change)="chapterTemplateChanged()" [disabled]="addInProgress || downloads.loading"
|
||||
(change)="chapterTemplateChanged()" [disabled]="addInProgress || subscribeInProgress || downloads.loading"
|
||||
ngbTooltip="Output template for chapter files">
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,30 +476,71 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<hr class="my-3">
|
||||
<div class="row g-2">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="openBatchImportModal()">
|
||||
<fa-icon [icon]="faFileImport" class="me-2" />
|
||||
Import URLs
|
||||
</button>
|
||||
<div class="action-group-label">Cookies</div>
|
||||
<input type="file" id="cookie-upload" class="d-none" accept=".txt"
|
||||
(change)="onCookieFileSelect($event)"
|
||||
[disabled]="cookieUploadInProgress || addInProgress">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<label class="btn mb-0"
|
||||
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
|
||||
[class.disabled]="cookieUploadInProgress || addInProgress"
|
||||
for="cookie-upload"
|
||||
ngbTooltip="Upload a cookies.txt file for authenticated downloads">
|
||||
@if (cookieUploadInProgress) {
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
} @else {
|
||||
<fa-icon [icon]="faUpload" class="me-2" />
|
||||
}
|
||||
{{ hasCookies ? 'Replace Cookies' : 'Upload Cookies' }}
|
||||
</label>
|
||||
@if (hasCookies) {
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
(click)="deleteCookies()"
|
||||
[disabled]="cookieUploadInProgress || addInProgress"
|
||||
ngbTooltip="Remove uploaded cookies">
|
||||
<fa-icon [icon]="faTrashAlt" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="cookie-status" [class.active]="hasCookies">
|
||||
@if (hasCookies) {
|
||||
<fa-icon [icon]="faCheckCircle" class="me-1" />
|
||||
Cookies active
|
||||
} @else {
|
||||
No cookies configured
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="exportBatchUrls('all')">
|
||||
<fa-icon [icon]="faFileExport" class="me-2" />
|
||||
Export URLs
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="copyBatchUrls('all')">
|
||||
<fa-icon [icon]="faCopy" class="me-2" />
|
||||
Copy URLs
|
||||
</button>
|
||||
<div class="col-md-8">
|
||||
<div class="action-group-label">Bulk Actions</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="openBatchImportModal()">
|
||||
<fa-icon [icon]="faFileImport" class="me-2" />
|
||||
Import URLs
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="exportBatchUrls('all')">
|
||||
<fa-icon [icon]="faFileExport" class="me-2" />
|
||||
Export URLs
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button type="button"
|
||||
class="btn btn-secondary w-100"
|
||||
(click)="copyBatchUrls('all')">
|
||||
<fa-icon [icon]="faCopy" class="me-2" />
|
||||
Copy URLs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,17 +553,19 @@
|
||||
</form>
|
||||
|
||||
<!-- Batch Import Modal -->
|
||||
<div class="modal fade" tabindex="-1" role="dialog"
|
||||
<div class="modal fade" tabindex="-1" role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="batch-import-modal-title"
|
||||
[class.show]="batchImportModalOpen"
|
||||
[style.display]="batchImportModalOpen ? 'block' : 'none'">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Batch Import URLs</h5>
|
||||
<h5 id="batch-import-modal-title" class="modal-title">Batch Import URLs</h5>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="closeBatchImportModal()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<textarea [(ngModel)]="batchImportText" class="form-control" rows="6"
|
||||
<textarea id="batch-import-textarea" [(ngModel)]="batchImportText" class="form-control" rows="6"
|
||||
placeholder="Paste one video URL per line"></textarea>
|
||||
<div class="mt-2">
|
||||
@if (batchImportStatus) {
|
||||
@@ -332,7 +604,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<app-master-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
|
||||
<app-select-all-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col" style="width: 8rem;">Speed</th>
|
||||
@@ -344,12 +616,12 @@
|
||||
@for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) {
|
||||
<tr [class.disabled]='download.value.deleting'>
|
||||
<td>
|
||||
<app-slave-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
|
||||
<app-item-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
|
||||
</td>
|
||||
<td title="{{ download.value.filename }}">
|
||||
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
|
||||
<div>{{ download.value.title }} </div>
|
||||
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success"
|
||||
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success"
|
||||
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
|
||||
</div>
|
||||
</td>
|
||||
@@ -358,10 +630,10 @@
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
@if (download.value.status === 'pending') {
|
||||
<button type="button" class="btn btn-link" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
|
||||
}
|
||||
<button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
<a href="{{download.value.url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + download.value.title"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -372,10 +644,11 @@
|
||||
|
||||
<div class="metube-section-header">Completed</div>
|
||||
<div class="px-2 py-3 border-bottom">
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" /> {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" /> Clear selected</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" /> Clear completed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" /> Clear failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneRetryFailed (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" /> Retry failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasCompletedDone" (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" /> Clear completed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" /> Clear failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" [disabled]="!hasFailedDone" (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" /> Retry failed</button>
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload" /> Download Selected</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
@@ -383,77 +656,133 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<app-master-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" />
|
||||
<app-select-all-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" />
|
||||
</th>
|
||||
<th scope="col">Video</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Quality</th>
|
||||
<th scope="col">Codec / Format</th>
|
||||
<th scope="col">File Size</th>
|
||||
<th scope="col">Downloaded</th>
|
||||
<th scope="col" style="width: 8rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (download of downloads.done | keyvalue: asIsOrder; track download.value.id) {
|
||||
<tr [class.disabled]='download.value.deleting'>
|
||||
@for (entry of cachedSortedDone; track entry[1].id) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<td>
|
||||
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckboxRef" [checkable]="download.value" />
|
||||
<app-item-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: inline-block; width: 1.5rem;">
|
||||
@if (download.value.status === 'finished') {
|
||||
@if (entry[1].status === 'finished') {
|
||||
<fa-icon [icon]="faCheckCircle" class="text-success" />
|
||||
}
|
||||
@if (download.value.status === 'error') {
|
||||
<fa-icon [icon]="faTimesCircle" class="text-danger" />
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link p-0"
|
||||
(click)="toggleErrorDetail(entry[0])"
|
||||
[attr.aria-label]="'Toggle error details for ' + entry[1].title"
|
||||
[attr.aria-expanded]="isErrorExpanded(entry[0])">
|
||||
<fa-icon [icon]="faTimesCircle" class="text-danger" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span ngbTooltip="{{buildResultItemTooltip(download.value)}}">@if (!!download.value.filename) {
|
||||
<a href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a>
|
||||
<span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) {
|
||||
<a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
|
||||
} @else {
|
||||
{{download.value.title}}
|
||||
@if (download.value.msg) {
|
||||
<span><br>{{download.value.msg}}</span>
|
||||
}
|
||||
@if (download.value.error) {
|
||||
<span><br>Error: {{download.value.error}}</span>
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link p-0 text-start align-baseline" (click)="toggleErrorDetail(entry[0])">
|
||||
{{entry[1].title}}
|
||||
@if (!isErrorExpanded(entry[0])) {
|
||||
<small class="text-danger ms-2">
|
||||
<fa-icon [icon]="faChevronRight" size="xs" class="me-1" />Click for details
|
||||
</small>
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<span>{{entry[1].title}}</span>
|
||||
}
|
||||
}</span>
|
||||
@if (entry[1].status === 'error' && isErrorExpanded(entry[0])) {
|
||||
<div class="alert alert-danger py-2 px-3 mt-2 mb-0 small" style="border-left: 4px solid var(--bs-danger);">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
@if (entry[1].msg) {
|
||||
<div class="mb-1"><strong>Message:</strong> {{entry[1].msg}}</div>
|
||||
}
|
||||
@if (entry[1].error) {
|
||||
<div class="mb-1"><strong>Error:</strong> {{entry[1].error}}</div>
|
||||
}
|
||||
<div class="text-muted" style="word-break: break-all;"><strong>URL:</strong> {{entry[1].url}}</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger ms-2 flex-shrink-0"
|
||||
(click)="copyErrorMessage(entry[0], entry[1]); $event.stopPropagation()"
|
||||
ngbTooltip="Copy error details to clipboard">
|
||||
@if (lastCopiedErrorId === entry[0]) {
|
||||
<span class="text-success">Copied!</span>
|
||||
} @else {
|
||||
<fa-icon [icon]="faCopy" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ downloadTypeLabel(entry[1]) }}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ formatQualityLabel(entry[1]) }}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ formatCodecLabel(entry[1]) }}
|
||||
</td>
|
||||
<td>
|
||||
@if (download.value.size) {
|
||||
<span>{{ download.value.size | fileSize }}</span>
|
||||
@if (entry[1].size) {
|
||||
<span>{{ entry[1].size | fileSize }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
@if (entry[1].timestamp) {
|
||||
<span>{{ entry[1].timestamp / 1000000 | date:'yyyy-MM-dd HH:mm' }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
@if (download.value.status === 'error') {
|
||||
<button type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt" /></button>
|
||||
@if (entry[1].status === 'error') {
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Retry download for ' + entry[1].title" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
|
||||
}
|
||||
@if (download.value.filename) {
|
||||
<a href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
|
||||
@if (entry[1].filename) {
|
||||
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link" [attr.aria-label]="'Download result file for ' + entry[1].title"><fa-icon [icon]="faDownload" /></a>
|
||||
}
|
||||
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + entry[1].title"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Delete completed item ' + entry[1].title" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@if (download.value.chapter_files && download.value.chapter_files.length > 0) {
|
||||
@for (chapterFile of download.value.chapter_files; track chapterFile.filename) {
|
||||
<tr [class.disabled]='download.value.deleting'>
|
||||
@if (entry[1].chapter_files && entry[1].chapter_files.length > 0) {
|
||||
@for (chapterFile of entry[1].chapter_files; track chapterFile.filename) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<td></td>
|
||||
<td>
|
||||
<div style="padding-left: 2rem;">
|
||||
<fa-icon [icon]="faCheckCircle" class="text-success me-2" />
|
||||
<a href="{{buildChapterDownloadLink(download.value, chapterFile.filename)}}" target="_blank">{{
|
||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank" [attr.aria-label]="'Open chapter file ' + getChapterFileName(chapterFile.filename)">{{
|
||||
getChapterFileName(chapterFile.filename) }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>
|
||||
@if (chapterFile.size) {
|
||||
<span>{{ chapterFile.size | fileSize }}</span>
|
||||
}
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="d-flex">
|
||||
<a href="{{buildChapterDownloadLink(download.value, chapterFile.filename)}}" download
|
||||
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download [attr.aria-label]="'Download chapter file ' + getChapterFileName(chapterFile.filename)"
|
||||
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
|
||||
</div>
|
||||
</td>
|
||||
@@ -464,6 +793,127 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="metube-section-header">Subscriptions</div>
|
||||
<div class="px-2 py-3 border-bottom">
|
||||
@if (checkingAllSubscriptions) {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check all now
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="checkAllSubscriptions()"
|
||||
[disabled]="downloads.loading || cachedSubs.length === 0 || checkingSelectedSubscriptions">
|
||||
<fa-icon [icon]="faRedoAlt" /> Check all now
|
||||
</button>
|
||||
}
|
||||
@if (checkingSelectedSubscriptions) {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
|
||||
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check selected
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="checkSelectedSubscriptions()"
|
||||
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0 || checkingAllSubscriptions">
|
||||
<fa-icon [icon]="faRedoAlt" /> Check selected
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
|
||||
(click)="deleteSelectedSubscriptions()"
|
||||
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0">
|
||||
<fa-icon [icon]="faTrashAlt" /> Delete selected
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 1rem;">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
[checked]="allSubsSelected()"
|
||||
(change)="toggleSubMaster($event)"
|
||||
[disabled]="downloads.loading || cachedSubs.length === 0"
|
||||
aria-label="Select all subscriptions" />
|
||||
</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">URL</th>
|
||||
<th scope="col" class="text-nowrap">Interval (min)</th>
|
||||
<th scope="col" class="text-nowrap">Last checked</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col" style="width: 8rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of cachedSubs; track entry[0]) {
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input"
|
||||
[checked]="isSubSelected(entry[0])"
|
||||
(change)="toggleSubSelected(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Select subscription ' + entry[1].name" />
|
||||
</td>
|
||||
<td>{{ entry[1].name }}</td>
|
||||
<td class="text-break"><a [href]="entry[1].url" target="_blank" rel="noopener">{{ entry[1].url }}</a></td>
|
||||
<td>{{ entry[1].check_interval_minutes }}</td>
|
||||
<td class="text-nowrap">
|
||||
@if (entry[1].last_checked !== null) {
|
||||
<span>{{ entry[1].last_checked! * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</span>
|
||||
} @else {
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (entry[1].error) {
|
||||
<span class="text-danger small">{{ entry[1].error }}</span>
|
||||
} @else if (entry[1].enabled) {
|
||||
<span class="text-success">Active</span>
|
||||
} @else {
|
||||
<span class="text-secondary">Paused</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
@if (isSubscriptionChecking(entry[0])) {
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
disabled
|
||||
[attr.aria-label]="'Checking ' + entry[1].name"
|
||||
ngbTooltip="Checking now">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
(click)="checkSubscriptionNow(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Check now ' + entry[1].name"
|
||||
ngbTooltip="Check now">
|
||||
<fa-icon [icon]="faRedoAlt" />
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="btn btn-link btn-sm p-0 me-2"
|
||||
(click)="toggleSubscriptionEnabled(entry[1])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="(entry[1].enabled ? 'Pause ' : 'Resume ') + entry[1].name"
|
||||
[ngbTooltip]="entry[1].enabled ? 'Pause' : 'Resume'">
|
||||
@if (entry[1].enabled) {
|
||||
<fa-icon [icon]="faPause" />
|
||||
} @else {
|
||||
<fa-icon [icon]="faPlay" />
|
||||
}
|
||||
</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-danger"
|
||||
(click)="deleteSubscription(entry[0])"
|
||||
[disabled]="downloads.loading"
|
||||
[attr.aria-label]="'Delete subscription ' + entry[1].name">
|
||||
<fa-icon [icon]="faTrashAlt" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main><!-- /.container -->
|
||||
|
||||
<footer class="footer navbar-dark bg-dark py-3 mt-5">
|
||||
|
||||
+52
-65
@@ -1,29 +1,7 @@
|
||||
.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
|
||||
@@ -66,39 +44,11 @@ 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 #eee
|
||||
border-bottom: 1px solid var(--bs-border-color)
|
||||
|
||||
.modal-body
|
||||
textarea.form-control
|
||||
@@ -112,20 +62,12 @@ td
|
||||
.spinner-border
|
||||
margin-right: 0.5rem
|
||||
|
||||
::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
|
||||
.add-progress-btn
|
||||
min-width: 9.5rem
|
||||
cursor: default
|
||||
|
||||
.add-cancel-btn
|
||||
min-width: 3.25rem
|
||||
|
||||
:host
|
||||
display: flex
|
||||
@@ -209,3 +151,48 @@ main
|
||||
|
||||
span
|
||||
white-space: nowrap
|
||||
|
||||
.cookie-btn
|
||||
flex: 1 1 auto
|
||||
background-color: var(--bs-secondary-bg)
|
||||
border-color: var(--bs-border-color)
|
||||
color: var(--bs-emphasis-color)
|
||||
|
||||
&:hover
|
||||
background-color: var(--bs-tertiary-bg)
|
||||
border-color: var(--bs-secondary)
|
||||
color: var(--bs-emphasis-color)
|
||||
|
||||
&.disabled
|
||||
opacity: 0.65
|
||||
pointer-events: none
|
||||
|
||||
.cookie-active-btn
|
||||
flex: 1 1 auto
|
||||
background-color: var(--bs-success-bg-subtle)
|
||||
border-color: var(--bs-success-border-subtle)
|
||||
color: var(--bs-success-text-emphasis)
|
||||
|
||||
&:hover
|
||||
background-color: var(--bs-success-bg-subtle)
|
||||
border-color: var(--bs-success)
|
||||
color: var(--bs-success-text-emphasis)
|
||||
|
||||
&.disabled
|
||||
opacity: 0.65
|
||||
pointer-events: none
|
||||
|
||||
.action-group-label
|
||||
font-size: 0.7rem
|
||||
text-transform: uppercase
|
||||
letter-spacing: 0.05em
|
||||
color: var(--bs-secondary-color)
|
||||
margin-bottom: 0.4rem
|
||||
|
||||
.cookie-status
|
||||
font-size: 0.8rem
|
||||
margin-top: 0.35rem
|
||||
color: var(--bs-secondary-color)
|
||||
|
||||
&.active
|
||||
color: var(--bs-success-text-emphasis)
|
||||
|
||||
+12
-17
@@ -1,24 +1,20 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
|
||||
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(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('App', () => {
|
||||
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(),
|
||||
})),
|
||||
});
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
}).compileComponents();
|
||||
@@ -29,5 +25,4 @@ describe('App', () => {
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
+894
-98
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,2 @@
|
||||
export { MasterCheckboxComponent } from './master-checkbox.component';
|
||||
export { SlaveCheckboxComponent } from './slave-checkbox.component';
|
||||
export { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||
export { ItemCheckboxComponent } from './slave-checkbox.component';
|
||||
@@ -0,0 +1,23 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||
import { Checkable } from '../interfaces';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -3,18 +3,18 @@ import { Checkable } from "../interfaces";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
|
||||
@Component({
|
||||
selector: 'app-master-checkbox',
|
||||
selector: 'app-select-all-checkbox',
|
||||
template: `
|
||||
<div class="form-check">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
`,
|
||||
imports: [
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class MasterCheckboxComponent {
|
||||
export class SelectAllCheckboxComponent {
|
||||
readonly id = input.required<string>();
|
||||
readonly list = input.required<Map<string, Checkable>>();
|
||||
readonly changed = output<number>();
|
||||
@@ -33,7 +33,7 @@ export class MasterCheckboxComponent {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,22 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { MasterCheckboxComponent } from './master-checkbox.component';
|
||||
import { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||
import { Checkable } from '../interfaces';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'app-slave-checkbox',
|
||||
selector: 'app-item-checkbox',
|
||||
template: `
|
||||
<div class="form-check">
|
||||
<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>
|
||||
<input type="checkbox" class="form-check-input" id="{{master().id()}}-{{id()}}-select" [(ngModel)]="checkable().checked" (change)="master().selectionChanged()" [attr.aria-label]="'Select item ' + id()">
|
||||
<label class="form-check-label visually-hidden" for="{{master().id()}}-{{id()}}-select">Select item</label>
|
||||
</div>
|
||||
`,
|
||||
imports: [
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class SlaveCheckboxComponent {
|
||||
export class ItemCheckboxComponent {
|
||||
readonly id = input.required<string>();
|
||||
readonly master = input.required<MasterCheckboxComponent>();
|
||||
readonly master = input.required<SelectAllCheckboxComponent>();
|
||||
readonly checkable = input.required<Checkable>();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface Download {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
download_type: string;
|
||||
codec?: string;
|
||||
quality: string;
|
||||
format: string;
|
||||
folder: string;
|
||||
@@ -10,6 +12,8 @@ export interface Download {
|
||||
playlist_item_limit: number;
|
||||
split_by_chapters?: boolean;
|
||||
chapter_template?: string;
|
||||
subtitle_language?: string;
|
||||
subtitle_mode?: string;
|
||||
status: string;
|
||||
msg: string;
|
||||
percent: number;
|
||||
@@ -17,8 +21,9 @@ export interface Download {
|
||||
eta: number;
|
||||
filename: string;
|
||||
checked: boolean;
|
||||
timestamp?: number;
|
||||
size?: number;
|
||||
error?: string;
|
||||
deleting?: boolean;
|
||||
chapter_files?: Array<{ filename: string, size: number }>;
|
||||
chapter_files?: { filename: string, size: number }[];
|
||||
}
|
||||
|
||||
@@ -1,76 +1,77 @@
|
||||
import { Format } from "./format";
|
||||
import { Quality } from "./quality";
|
||||
|
||||
export interface Option {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const Formats: Format[] = [
|
||||
{
|
||||
id: 'any',
|
||||
text: 'Any',
|
||||
qualities: [
|
||||
{ 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: 'mp4',
|
||||
text: 'MP4',
|
||||
qualities: [
|
||||
{ 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: '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' }],
|
||||
},
|
||||
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[] = [
|
||||
{
|
||||
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" }] },
|
||||
];
|
||||
|
||||
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" }];
|
||||
|
||||
@@ -6,4 +6,4 @@ export * from './download';
|
||||
export * from './checkable';
|
||||
export * from './format';
|
||||
export * from './formats';
|
||||
|
||||
export * from './subscription';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
last_checked: number | null;
|
||||
seen_count: number;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
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('KB');
|
||||
expect(pipe.transform(1000 * 1000)).toContain('MB');
|
||||
expect(pipe.transform(1000 ** 3)).toContain('GB');
|
||||
});
|
||||
|
||||
it('handles boundaries between units', () => {
|
||||
const pipe = new FileSizePipe();
|
||||
expect(pipe.transform(999)).toContain('Bytes');
|
||||
expect(pipe.transform(1000)).toContain('KB');
|
||||
expect(pipe.transform(1001)).toContain('KB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,43 +1,19 @@
|
||||
import { Pipe, PipeTransform } from "@angular/core";
|
||||
import { BehaviorSubject, throttleTime } from "rxjs";
|
||||
|
||||
@Pipe({
|
||||
name: 'speed',
|
||||
pure: false // Make the pipe impure so it can handle async updates
|
||||
pure: true
|
||||
})
|
||||
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 '';
|
||||
}
|
||||
|
||||
// Update the speed subject
|
||||
this.speedSubject.next(value);
|
||||
|
||||
// Return the last formatted speed
|
||||
return this.formattedSpeed;
|
||||
|
||||
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]}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
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,
|
||||
chapterTemplate: '',
|
||||
subtitleLanguage: 'en',
|
||||
subtitleMode: 'prefer_manual',
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
}),
|
||||
);
|
||||
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('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 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();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,22 @@ 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;
|
||||
chapterTemplate: string;
|
||||
subtitleLanguage: string;
|
||||
subtitleMode: string;
|
||||
}
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@@ -14,16 +30,15 @@ export class DownloadsService {
|
||||
loading = true;
|
||||
queue = new Map<string, Download>();
|
||||
done = new Map<string, Download>();
|
||||
queueChanged = new Subject();
|
||||
doneChanged = new Subject();
|
||||
customDirsChanged = new Subject();
|
||||
ytdlOptionsChanged = new Subject();
|
||||
configurationChanged = new Subject();
|
||||
updated = new Subject();
|
||||
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>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
configuration: any = {};
|
||||
customDirs = {};
|
||||
configuration: Record<string, unknown> = {};
|
||||
customDirs: Record<string, string[]> = {};
|
||||
|
||||
constructor() {
|
||||
this.socket.fromEvent('all')
|
||||
@@ -35,15 +50,15 @@ 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(null);
|
||||
this.doneChanged.next(null);
|
||||
this.queueChanged.next();
|
||||
this.doneChanged.next();
|
||||
});
|
||||
this.socket.fromEvent('added')
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((strdata: string) => {
|
||||
const data: Download = JSON.parse(strdata);
|
||||
this.queue.set(data.url, data);
|
||||
this.queueChanged.next(null);
|
||||
this.queueChanged.next();
|
||||
});
|
||||
this.socket.fromEvent('updated')
|
||||
.pipe(takeUntilDestroyed())
|
||||
@@ -53,7 +68,7 @@ export class DownloadsService {
|
||||
data.checked = !!dl?.checked;
|
||||
data.deleting = !!dl?.deleting;
|
||||
this.queue.set(data.url, data);
|
||||
this.updated.next(null);
|
||||
this.updated.next();
|
||||
});
|
||||
this.socket.fromEvent('completed')
|
||||
.pipe(takeUntilDestroyed())
|
||||
@@ -61,22 +76,22 @@ export class DownloadsService {
|
||||
const data: Download = JSON.parse(strdata);
|
||||
this.queue.delete(data.url);
|
||||
this.done.set(data.url, data);
|
||||
this.queueChanged.next(null);
|
||||
this.doneChanged.next(null);
|
||||
this.queueChanged.next();
|
||||
this.doneChanged.next();
|
||||
});
|
||||
this.socket.fromEvent('canceled')
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((strdata: string) => {
|
||||
const data: string = JSON.parse(strdata);
|
||||
this.queue.delete(data);
|
||||
this.queueChanged.next(null);
|
||||
this.queueChanged.next();
|
||||
});
|
||||
this.socket.fromEvent('cleared')
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((strdata: string) => {
|
||||
const data: string = JSON.parse(strdata);
|
||||
this.done.delete(data);
|
||||
this.doneChanged.next(null);
|
||||
this.doneChanged.next();
|
||||
});
|
||||
this.socket.fromEvent('configuration')
|
||||
.pipe(takeUntilDestroyed())
|
||||
@@ -103,12 +118,30 @@ export class DownloadsService {
|
||||
}
|
||||
|
||||
handleHTTPError(error: HttpErrorResponse) {
|
||||
const msg = error.error instanceof ErrorEvent ? error.error.message : error.error;
|
||||
return of({status: 'error', msg: msg})
|
||||
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 });
|
||||
}
|
||||
|
||||
public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistItemLimit: number, autoStart: boolean, splitByChapters: boolean, chapterTemplate: 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 }).pipe(
|
||||
public add(payload: AddDownloadPayload) {
|
||||
return this.http.post<Status>('add', {
|
||||
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,
|
||||
chapter_template: payload.chapterTemplate,
|
||||
subtitle_language: payload.subtitleLanguage,
|
||||
subtitle_mode: payload.subtitleMode,
|
||||
}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
@@ -118,12 +151,15 @@ export class DownloadsService {
|
||||
}
|
||||
|
||||
public delById(where: State, ids: string[]) {
|
||||
ids.forEach(id => {
|
||||
const obj = this[where].get(id)
|
||||
if (obj) {
|
||||
obj.deleting = true
|
||||
const map = this[where];
|
||||
if (map) {
|
||||
for (const id of ids) {
|
||||
const obj = map.get(id);
|
||||
if (obj) {
|
||||
obj.deleting = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.http.post('delete', {where: where, ids: ids});
|
||||
}
|
||||
|
||||
@@ -138,31 +174,29 @@ 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'];
|
||||
public cancelAdd() {
|
||||
return this.http.post<Status>('cancel-add', {}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistItemLimit, defaultAutoStart, defaultSplitByChapters, defaultChapterTemplate)
|
||||
.subscribe({
|
||||
next: (response) => resolve(response),
|
||||
error: (error) => reject(error)
|
||||
});
|
||||
});
|
||||
uploadCookies(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('cookies', file);
|
||||
return this.http.post<{ status: string; msg?: string }>('upload-cookies', formData).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
public exportQueueUrls(): string[] {
|
||||
return Array.from(this.queue.values()).map(download => download.url);
|
||||
|
||||
deleteCookies() {
|
||||
return this.http.post<{ status: string; msg?: string }>('delete-cookies', {}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
getCookieStatus() {
|
||||
return this.http.get<{ status: string; has_cookies: boolean }>('cookie-status').pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { DownloadsService } from './downloads.service';
|
||||
export { SpeedService } from './speed.service';
|
||||
export { MeTubeSocket } from './metube-socket.service';
|
||||
@@ -1,39 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@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) {
|
||||
return this.http
|
||||
.post<Status>('subscribe', {
|
||||
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,
|
||||
chapter_template: payload.chapterTemplate,
|
||||
subtitle_language: payload.subtitleLanguage,
|
||||
subtitle_mode: payload.subtitleMode,
|
||||
check_interval_minutes: payload.checkIntervalMinutes,
|
||||
})
|
||||
.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'>>) {
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,22 @@
|
||||
|
||||
[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
|
||||
|
||||
Reference in New Issue
Block a user