mirror of
https://github.com/alexta69/metube.git
synced 2026-06-16 16:20:07 +00:00
Compare commits
98 Commits
2025.12.09
...
2026.03.21
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 9be0781c7f | |||
| e378179e05 | |||
| 5a7dd8769b | |||
| e601ce99f5 | |||
| a74b201ed8 | |||
| 191f17ee38 | |||
| a002af9bf2 | |||
| 37aaa29efb | |||
| d10f2a0358 | |||
| c7008763d7 | |||
| 351058e9f4 | |||
| d799a4a8eb | |||
| df87a1aa2b | |||
| 02480afddf | |||
| d51f2ce628 | |||
| 962929d42d | |||
| 179452b4f4 | |||
| 4fce74d1ed | |||
| 09a2e95515 | |||
| d947876a71 | |||
| 6ba681a3cd | |||
| 1f8fa7744e | |||
| 092765535f | |||
| 90299b227e | |||
| 6445517751 | |||
| dae710a339 | |||
| 318f4f9f21 | |||
| ca8e9e7907 | |||
| 183c4ba898 | |||
| c6d487e48a | |||
| 77c3c93157 |
@@ -0,0 +1,13 @@
|
|||||||
|
# Keep GitHub Actions up to date with GitHub's Dependabot...
|
||||||
|
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
|
||||||
|
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#package-ecosystem
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
groups:
|
||||||
|
github-actions:
|
||||||
|
patterns:
|
||||||
|
- "*" # Group all Actions updates into a single larger pull request
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
@@ -6,38 +6,80 @@ on:
|
|||||||
- 'master'
|
- 'master'
|
||||||
|
|
||||||
jobs:
|
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:
|
dockerhub-build-push:
|
||||||
|
needs: quality-checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Get current date
|
name: Get current date
|
||||||
id: date
|
id: date
|
||||||
run: echo "::set-output name=date::$(date +'%Y.%m.%d')"
|
run: echo "date=$(date +'%Y.%m.%d')" >> "$GITHUB_OUTPUT"
|
||||||
-
|
-
|
||||||
name: Checkout
|
name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
-
|
-
|
||||||
name: Login to GitHub Container Registry
|
name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -74,7 +116,7 @@ jobs:
|
|||||||
id: date
|
id: date
|
||||||
run: echo "date=$(date +'%Y.%m.%d')" >> $GITHUB_OUTPUT
|
run: echo "date=$(date +'%Y.%m.%d')" >> $GITHUB_OUTPUT
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Get commits since last release
|
- name: Get commits since last release
|
||||||
@@ -167,7 +209,7 @@ jobs:
|
|||||||
git push origin ":refs/tags/$TAG_NAME" || true
|
git push origin ":refs/tags/$TAG_NAME" || true
|
||||||
fi
|
fi
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.date.outputs.date }}
|
tag_name: ${{ steps.date.outputs.date }}
|
||||||
name: Release ${{ steps.date.outputs.date }}
|
name: Release ${{ steps.date.outputs.date }}
|
||||||
|
|||||||
@@ -10,17 +10,17 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout
|
name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.AUTOUPDATE_PAT }}
|
token: ${{ secrets.AUTOUPDATE_PAT }}
|
||||||
-
|
-
|
||||||
name: Set up Python
|
name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.13'
|
python-version: '3.13'
|
||||||
-
|
-
|
||||||
name: Install uv
|
name: Install uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v7
|
||||||
-
|
-
|
||||||
name: Update yt-dlp
|
name: Update yt-dlp
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/ui/node_modules
|
/ui/node_modules
|
||||||
|
/ui/package-lock.json
|
||||||
|
|
||||||
# profiling files
|
# profiling files
|
||||||
chrome-profiler-events*.json
|
chrome-profiler-events*.json
|
||||||
|
|||||||
+47
-13
@@ -2,11 +2,11 @@ FROM node:lts-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /metube
|
WORKDIR /metube
|
||||||
COPY ui ./
|
COPY ui ./
|
||||||
RUN npm ci && \
|
RUN corepack enable && corepack prepare pnpm --activate
|
||||||
node_modules/.bin/ng build --configuration production
|
RUN CI=true pnpm install && pnpm run build
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.13-alpine
|
FROM python:3.13-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -16,28 +16,62 @@ COPY pyproject.toml uv.lock docker-entrypoint.sh ./
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
|
RUN sed -i 's/\r$//g' docker-entrypoint.sh && \
|
||||||
chmod +x docker-entrypoint.sh && \
|
chmod +x docker-entrypoint.sh && \
|
||||||
apk add --update ffmpeg aria2 coreutils shadow su-exec curl tini deno && \
|
apt-get update && \
|
||||||
apk add --update --virtual .build-deps gcc g++ musl-dev uv && \
|
apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
ffmpeg \
|
||||||
|
unzip \
|
||||||
|
aria2 \
|
||||||
|
coreutils \
|
||||||
|
gosu \
|
||||||
|
curl \
|
||||||
|
tini \
|
||||||
|
file \
|
||||||
|
gdbmtool \
|
||||||
|
sqlite3 \
|
||||||
|
build-essential && \
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh && \
|
||||||
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \
|
UV_PROJECT_ENVIRONMENT=/usr/local uv sync --frozen --no-dev --compile-bytecode && \
|
||||||
apk del .build-deps && \
|
uv cache clean && \
|
||||||
rm -rf /var/cache/apk/* && \
|
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
|
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 app ./app
|
||||||
COPY --from=builder /metube/dist/metube ./ui/dist/metube
|
COPY --from=builder /metube/dist/metube ./ui/dist/metube
|
||||||
|
|
||||||
ENV UID=1000
|
ENV PUID=1000
|
||||||
ENV GID=1000
|
ENV PGID=1000
|
||||||
ENV UMASK=022
|
ENV UMASK=022
|
||||||
|
|
||||||
ENV DOWNLOAD_DIR /downloads
|
ENV DOWNLOAD_DIR=/downloads
|
||||||
ENV STATE_DIR /downloads/.metube
|
ENV STATE_DIR=/downloads/.metube
|
||||||
ENV TEMP_DIR /downloads
|
ENV TEMP_DIR=/downloads
|
||||||
VOLUME /downloads
|
VOLUME /downloads
|
||||||
EXPOSE 8081
|
EXPOSE 8081
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS "http://localhost:8081/" || exit 1
|
||||||
|
|
||||||
# Add build-time argument for version
|
# Add build-time argument for version
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ENV METUBE_VERSION=$VERSION
|
ENV METUBE_VERSION=$VERSION
|
||||||
|
|
||||||
ENTRYPOINT ["/sbin/tini", "-g", "--", "./docker-entrypoint.sh"]
|
ENTRYPOINT ["/usr/bin/tini", "-g", "--", "./docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -33,15 +33,10 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||||||
|
|
||||||
### ⬇️ Download Behavior
|
### ⬇️ Download Behavior
|
||||||
|
|
||||||
* __DOWNLOAD_MODE__: This flag controls how downloads are scheduled and executed. Options are `sequential`, `concurrent`, and `limited`. Defaults to `limited`:
|
* __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`.
|
||||||
* `sequential`: Downloads are processed one at a time. A new download won't start until the previous one has finished. This mode is useful for conserving system resources or ensuring downloads occur in strict order.
|
|
||||||
* `concurrent`: Downloads are started immediately as they are added, with no built-in limit on how many run simultaneously. This mode may overwhelm your system if too many downloads start at once.
|
|
||||||
* `limited`: Downloads are started concurrently but are capped by a concurrency limit. In this mode, a semaphore is used so that at most a fixed number of downloads run at any given time.
|
|
||||||
* __MAX_CONCURRENT_DOWNLOADS__: This flag is used only when `DOWNLOAD_MODE` is set to `limited`.
|
|
||||||
It specifies the 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`.
|
* __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_STRICT_MODE__: if `true`, the "Strict Playlist mode" switch will be enabled by default. In this mode the playlists will be downloaded only if the URL strictly points to a playlist. URLs to videos inside a playlist will be treated same as direct video URL. Defaults to `false` .
|
|
||||||
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
|
* __DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT__: Maximum number of playlist items that can be downloaded. Defaults to `0` (no limit).
|
||||||
|
* __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
|
### 📁 Storage & Directories
|
||||||
|
|
||||||
@@ -55,17 +50,21 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||||||
* __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
|
* __TEMP_DIR__: Path where intermediary download files will be saved. Defaults to `/downloads` in the Docker image, and `.` otherwise.
|
||||||
* Set this to an SSD or RAM filesystem (e.g., `tmpfs`) for better performance.
|
* Set this to an SSD or RAM filesystem (e.g., `tmpfs`) for better performance.
|
||||||
* __Note__: Using a RAM filesystem may prevent downloads from being resumed.
|
* __Note__: Using a RAM filesystem may prevent downloads from being resumed.
|
||||||
|
* __CHOWN_DIRS__: If `false`, ownership of `DOWNLOAD_DIR`, `STATE_DIR`, and `TEMP_DIR` (and their contents) will not be set on container start. Ensure user under which MeTube runs has necessary access to these directories already. Defaults to `true`.
|
||||||
|
|
||||||
### 📝 File Naming & yt-dlp
|
### 📝 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__: 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_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__: 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.
|
* __YTDL_OPTIONS_FILE__: A path to a JSON file that will be loaded and used for populating `YTDL_OPTIONS` above. Please note that if both `YTDL_OPTIONS_FILE` and `YTDL_OPTIONS` are specified, the options in `YTDL_OPTIONS` take precedence. The file will be monitored for changes and reloaded automatically when changes are detected.
|
||||||
|
|
||||||
### 🌐 Web Server & URLs
|
### 🌐 Web Server & URLs
|
||||||
|
|
||||||
|
* __HOST__: The host address the web server will bind to. Defaults to `0.0.0.0` (all interfaces).
|
||||||
|
* __PORT__: The port number the web server will listen on. Defaults to `8081`.
|
||||||
* __URL_PREFIX__: Base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`.
|
* __URL_PREFIX__: Base path for the web server (for use when hosting behind a reverse proxy). Defaults to `/`.
|
||||||
* __PUBLIC_HOST_URL__: Base URL for the download links shown in the UI for completed files. By default, MeTube serves them under its own URL. If your download directory is accessible on another URL and you want the download links to be based there, use this variable to set it.
|
* __PUBLIC_HOST_URL__: Base URL for the download links shown in the UI for completed files. By default, MeTube serves them under its own URL. If your download directory is accessible on another URL and you want the download links to be based there, use this variable to set it.
|
||||||
* __PUBLIC_HOST_AUDIO_URL__: Same as PUBLIC_HOST_URL but for audio downloads.
|
* __PUBLIC_HOST_AUDIO_URL__: Same as PUBLIC_HOST_URL but for audio downloads.
|
||||||
@@ -76,8 +75,8 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
|||||||
|
|
||||||
### 🏠 Basic Setup
|
### 🏠 Basic Setup
|
||||||
|
|
||||||
* __UID__: User under which MeTube will run. Defaults to `1000`.
|
* __PUID__: User under which MeTube will run. Defaults to `1000` (legacy `UID` also supported).
|
||||||
* __GID__: Group under which MeTube will run. Defaults to `1000`.
|
* __PGID__: Group under which MeTube will run. Defaults to `1000` (legacy `GID` also supported).
|
||||||
* __UMASK__: Umask value used by MeTube. Defaults to `022`.
|
* __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`.
|
* __DEFAULT_THEME__: Default theme to use for the UI, can be set to `light`, `dark`, or `auto`. Defaults to `auto`.
|
||||||
* __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or `NONE`. Defaults to `INFO`.
|
* __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or `NONE`. Defaults to `INFO`.
|
||||||
@@ -91,21 +90,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:
|
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:
|
* Install in your browser an extension to extract cookies:
|
||||||
* [Firefox](https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/)
|
* [Firefox](https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/)
|
||||||
* [Chrome](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
* [Chrome](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
||||||
* Extract the cookies you need with the extension and rename the file `cookies.txt`
|
* Extract the cookies you need with the extension and save/export them as `cookies.txt`.
|
||||||
* Drop the file in the folder you configured in the docker-compose.yml above
|
* In MeTube, open **Advanced Options** and use the **Upload Cookies** button to upload the file.
|
||||||
* Restart the container
|
* After upload, the cookie indicator should show as active.
|
||||||
|
* Use **Delete Cookies** in the same section to remove uploaded cookies.
|
||||||
|
|
||||||
## 🔌 Browser extensions
|
## 🔌 Browser extensions
|
||||||
|
|
||||||
@@ -246,7 +237,7 @@ The engine which powers the actual video downloads in MeTube is [yt-dlp](https:/
|
|||||||
|
|
||||||
There's an automatic nightly build of MeTube which looks for a new version of yt-dlp, and if one exists, the build pulls it and publishes an updated docker image. Therefore, in order to keep up with the changes, it's recommended that you update your MeTube container regularly with the latest image.
|
There's an automatic nightly build of MeTube which looks for a new version of yt-dlp, and if one exists, the build pulls it and publishes an updated docker image. Therefore, in order to keep up with the changes, it's recommended that you update your MeTube container regularly with the latest image.
|
||||||
|
|
||||||
I recommend installing and setting up [watchtower](https://github.com/containrrr/watchtower) for this purpose.
|
I recommend installing and setting up [watchtower](https://github.com/nicholas-fedor/watchtower) for this purpose.
|
||||||
|
|
||||||
## 🔧 Troubleshooting and submitting issues
|
## 🔧 Troubleshooting and submitting issues
|
||||||
|
|
||||||
@@ -267,13 +258,14 @@ MeTube development relies on code contributions by the community. The program as
|
|||||||
|
|
||||||
## 🛠️ Building and running locally
|
## 🛠️ Building and running locally
|
||||||
|
|
||||||
Make sure you have Node.js and Python 3.13 installed.
|
Make sure you have Node.js 22+ and Python 3.13 installed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd metube/ui
|
|
||||||
# install Angular and build the UI
|
# install Angular and build the UI
|
||||||
npm install
|
cd ui
|
||||||
node_modules/.bin/ng build
|
curl -fsSL https://get.pnpm.io/install.sh | sh -
|
||||||
|
pnpm install
|
||||||
|
pnpm run build
|
||||||
# install python dependencies
|
# install python dependencies
|
||||||
cd ..
|
cd ..
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|||||||
+95
-36
@@ -1,75 +1,108 @@
|
|||||||
import copy
|
import copy
|
||||||
|
|
||||||
AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac")
|
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:
|
Args:
|
||||||
format (str): format selected
|
download_type (str): selected content type (video, audio, captions, thumbnail)
|
||||||
quality (str): quality selected
|
codec (str): selected video codec (auto, h264, h265, av1, vp9)
|
||||||
|
format (str): selected output format/profile for type
|
||||||
|
quality (str): selected quality
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: unknown quality, unknown format
|
Exception: unknown type/format
|
||||||
|
|
||||||
Returns:
|
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:"):
|
if format.startswith("custom:"):
|
||||||
return format[7:]
|
return format[7:]
|
||||||
|
|
||||||
if format == "thumbnail":
|
if download_type == "thumbnail":
|
||||||
# Quality is irrelevant in this case since we skip the download
|
|
||||||
return "bestaudio/best"
|
return "bestaudio/best"
|
||||||
|
|
||||||
if format in AUDIO_FORMATS:
|
if download_type == "captions":
|
||||||
# Audio quality needs to be set post-download, set in opts
|
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"
|
return f"bestaudio[ext={format}]/bestaudio/best"
|
||||||
|
|
||||||
if format in ("mp4", "any"):
|
if download_type == "video":
|
||||||
if quality == "audio":
|
if format not in ("any", "mp4", "ios"):
|
||||||
return "bestaudio/best"
|
raise ValueError(f"Unknown video format {format}")
|
||||||
# video {res} {vfmt} + audio {afmt} {res} {vfmt}
|
vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format in ("mp4", "ios") else ("", "")
|
||||||
vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format == "mp4" else ("", "")
|
vres = f"[height<={quality}]" if quality not in ("best", "worst") else ""
|
||||||
vres = f"[height<={quality}]" if quality not in ("best", "best_ios", "worst") else ""
|
|
||||||
vcombo = vres + vfmt
|
vcombo = vres + vfmt
|
||||||
|
codec_filter = CODEC_FILTER_MAP.get(codec, "")
|
||||||
|
|
||||||
if quality == "best_ios":
|
if format == "ios":
|
||||||
# iOS has strict requirements for video files, requiring h264 or h265
|
|
||||||
# video codec and aac audio codec in MP4 container. This format string
|
|
||||||
# attempts to get the fully compatible formats first, then the h264/h265
|
|
||||||
# video codec with any M4A audio codec (because audio is faster to
|
|
||||||
# convert if needed), and falls back to getting the best available MP4
|
|
||||||
# file.
|
|
||||||
return f"bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio[acodec=aac]/bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}"
|
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}"
|
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
|
Returns extra yt-dlp options/postprocessors.
|
||||||
Mostly postprocessing options
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
format (str): format selected
|
download_type (str): selected content type
|
||||||
quality (str): quality of format selected (needed for some formats)
|
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
|
ytdl_opts (dict): current options selected
|
||||||
|
|
||||||
Returns:
|
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)
|
opts = copy.deepcopy(ytdl_opts)
|
||||||
|
|
||||||
postprocessors = []
|
postprocessors = []
|
||||||
|
|
||||||
if format in AUDIO_FORMATS:
|
if download_type == "audio":
|
||||||
postprocessors.append(
|
postprocessors.append(
|
||||||
{
|
{
|
||||||
"key": "FFmpegExtractAudio",
|
"key": "FFmpegExtractAudio",
|
||||||
@@ -78,8 +111,7 @@ def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Audio formats without thumbnail
|
if format != "wav" and "writethumbnail" not in opts:
|
||||||
if format not in ("wav") and "writethumbnail" not in opts:
|
|
||||||
opts["writethumbnail"] = True
|
opts["writethumbnail"] = True
|
||||||
postprocessors.append(
|
postprocessors.append(
|
||||||
{
|
{
|
||||||
@@ -91,13 +123,40 @@ def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict:
|
|||||||
postprocessors.append({"key": "FFmpegMetadata"})
|
postprocessors.append({"key": "FFmpegMetadata"})
|
||||||
postprocessors.append({"key": "EmbedThumbnail"})
|
postprocessors.append({"key": "EmbedThumbnail"})
|
||||||
|
|
||||||
if format == "thumbnail":
|
if download_type == "thumbnail":
|
||||||
opts["skip_download"] = True
|
opts["skip_download"] = True
|
||||||
opts["writethumbnail"] = True
|
opts["writethumbnail"] = True
|
||||||
postprocessors.append(
|
postprocessors.append(
|
||||||
{"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"}
|
{"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"] = postprocessors + (
|
||||||
opts["postprocessors"] if "postprocessors" in opts else []
|
opts["postprocessors"] if "postprocessors" in opts else []
|
||||||
)
|
)
|
||||||
|
|||||||
+340
-44
@@ -16,11 +16,21 @@ import pathlib
|
|||||||
import re
|
import re
|
||||||
from watchfiles import DefaultFilter, Change, awatch
|
from watchfiles import DefaultFilter, Change, awatch
|
||||||
|
|
||||||
from ytdl import DownloadQueueNotifier, DownloadQueue
|
from ytdl import DownloadQueueNotifier, DownloadQueue, Download
|
||||||
from yt_dlp.version import __version__ as yt_dlp_version
|
from yt_dlp.version import __version__ as yt_dlp_version
|
||||||
|
|
||||||
log = logging.getLogger('main')
|
log = logging.getLogger('main')
|
||||||
|
|
||||||
|
def parseLogLevel(logLevel):
|
||||||
|
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).
|
||||||
|
if not logging.getLogger().hasHandlers():
|
||||||
|
logging.basicConfig(level=parseLogLevel(os.environ.get('LOGLEVEL', 'INFO')) or logging.INFO)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
_DEFAULTS = {
|
_DEFAULTS = {
|
||||||
'DOWNLOAD_DIR': '.',
|
'DOWNLOAD_DIR': '.',
|
||||||
@@ -36,10 +46,11 @@ class Config:
|
|||||||
'PUBLIC_HOST_URL': 'download/',
|
'PUBLIC_HOST_URL': 'download/',
|
||||||
'PUBLIC_HOST_AUDIO_URL': 'audio_download/',
|
'PUBLIC_HOST_AUDIO_URL': 'audio_download/',
|
||||||
'OUTPUT_TEMPLATE': '%(title)s.%(ext)s',
|
'OUTPUT_TEMPLATE': '%(title)s.%(ext)s',
|
||||||
'OUTPUT_TEMPLATE_CHAPTER': '%(title)s - %(section_number)s %(section_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_PLAYLIST': '%(playlist_title)s/%(title)s.%(ext)s',
|
||||||
'DEFAULT_OPTION_PLAYLIST_STRICT_MODE' : 'false',
|
'OUTPUT_TEMPLATE_CHANNEL': '%(channel)s/%(title)s.%(ext)s',
|
||||||
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0',
|
'DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT' : '0',
|
||||||
|
'CLEAR_COMPLETED_AFTER': '0',
|
||||||
'YTDL_OPTIONS': '{}',
|
'YTDL_OPTIONS': '{}',
|
||||||
'YTDL_OPTIONS_FILE': '',
|
'YTDL_OPTIONS_FILE': '',
|
||||||
'ROBOTS_TXT': '',
|
'ROBOTS_TXT': '',
|
||||||
@@ -50,13 +61,12 @@ class Config:
|
|||||||
'KEYFILE': '',
|
'KEYFILE': '',
|
||||||
'BASE_DIR': '',
|
'BASE_DIR': '',
|
||||||
'DEFAULT_THEME': 'auto',
|
'DEFAULT_THEME': 'auto',
|
||||||
'DOWNLOAD_MODE': 'limited',
|
'MAX_CONCURRENT_DOWNLOADS': '3',
|
||||||
'MAX_CONCURRENT_DOWNLOADS': 3,
|
|
||||||
'LOGLEVEL': 'INFO',
|
'LOGLEVEL': 'INFO',
|
||||||
'ENABLE_ACCESSLOG': 'false',
|
'ENABLE_ACCESSLOG': 'false',
|
||||||
}
|
}
|
||||||
|
|
||||||
_BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'DEFAULT_OPTION_PLAYLIST_STRICT_MODE', 'HTTPS', 'ENABLE_ACCESSLOG')
|
_BOOLEAN = ('DOWNLOAD_DIRS_INDEXABLE', 'CUSTOM_DIRS', 'CREATE_CUSTOM_DIRS', 'DELETE_FILE_ON_TRASHCAN', 'HTTPS', 'ENABLE_ACCESSLOG')
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
for k, v in self._DEFAULTS.items():
|
for k, v in self._DEFAULTS.items():
|
||||||
@@ -78,10 +88,42 @@ class Config:
|
|||||||
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
|
if self.YTDL_OPTIONS_FILE and self.YTDL_OPTIONS_FILE.startswith('.'):
|
||||||
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
|
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
|
||||||
|
|
||||||
|
self._runtime_overrides = {}
|
||||||
|
|
||||||
success,_ = self.load_ytdl_options()
|
success,_ = self.load_ytdl_options()
|
||||||
if not success:
|
if not success:
|
||||||
sys.exit(1)
|
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',
|
||||||
|
)
|
||||||
|
|
||||||
|
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]:
|
def load_ytdl_options(self) -> tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
self.YTDL_OPTIONS = json.loads(os.environ.get('YTDL_OPTIONS', '{}'))
|
self.YTDL_OPTIONS = json.loads(os.environ.get('YTDL_OPTIONS', '{}'))
|
||||||
@@ -92,6 +134,7 @@ class Config:
|
|||||||
return (False, msg)
|
return (False, msg)
|
||||||
|
|
||||||
if not self.YTDL_OPTIONS_FILE:
|
if not self.YTDL_OPTIONS_FILE:
|
||||||
|
self._apply_runtime_overrides()
|
||||||
return (True, '')
|
return (True, '')
|
||||||
|
|
||||||
log.info(f'Loading yt-dlp custom options from "{self.YTDL_OPTIONS_FILE}"')
|
log.info(f'Loading yt-dlp custom options from "{self.YTDL_OPTIONS_FILE}"')
|
||||||
@@ -109,9 +152,14 @@ class Config:
|
|||||||
return (False, msg)
|
return (False, msg)
|
||||||
|
|
||||||
self.YTDL_OPTIONS.update(opts)
|
self.YTDL_OPTIONS.update(opts)
|
||||||
|
self._apply_runtime_overrides()
|
||||||
return (True, '')
|
return (True, '')
|
||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
# Align root logger level with Config (keeps a single source of truth).
|
||||||
|
# This re-applies the log level after Config loads, in case LOGLEVEL was
|
||||||
|
# overridden by config file settings or differs from the environment variable.
|
||||||
|
logging.getLogger().setLevel(parseLogLevel(str(config.LOGLEVEL)) or logging.INFO)
|
||||||
|
|
||||||
class ObjectSerializer(json.JSONEncoder):
|
class ObjectSerializer(json.JSONEncoder):
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
@@ -123,7 +171,7 @@ class ObjectSerializer(json.JSONEncoder):
|
|||||||
elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes)):
|
elif hasattr(obj, '__iter__') and not isinstance(obj, (str, bytes)):
|
||||||
try:
|
try:
|
||||||
return list(obj)
|
return list(obj)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Fall back to default behavior
|
# Fall back to default behavior
|
||||||
return json.JSONEncoder.default(self, obj)
|
return json.JSONEncoder.default(self, obj)
|
||||||
@@ -133,6 +181,71 @@ app = web.Application()
|
|||||||
sio = socketio.AsyncServer(cors_allowed_origins='*')
|
sio = socketio.AsyncServer(cors_allowed_origins='*')
|
||||||
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
|
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
|
||||||
routes = web.RouteTableDef()
|
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):
|
class Notifier(DownloadQueueNotifier):
|
||||||
async def added(self, dl):
|
async def added(self, dl):
|
||||||
@@ -140,7 +253,7 @@ class Notifier(DownloadQueueNotifier):
|
|||||||
await sio.emit('added', serializer.encode(dl))
|
await sio.emit('added', serializer.encode(dl))
|
||||||
|
|
||||||
async def updated(self, dl):
|
async def updated(self, dl):
|
||||||
log.info(f"Notifier: Download updated - {dl.title}")
|
log.debug(f"Notifier: Download updated - {dl.title}")
|
||||||
await sio.emit('updated', serializer.encode(dl))
|
await sio.emit('updated', serializer.encode(dl))
|
||||||
|
|
||||||
async def completed(self, dl):
|
async def completed(self, dl):
|
||||||
@@ -157,6 +270,7 @@ class Notifier(DownloadQueueNotifier):
|
|||||||
|
|
||||||
dqueue = DownloadQueue(config, Notifier())
|
dqueue = DownloadQueue(config, Notifier())
|
||||||
app.on_startup.append(lambda app: dqueue.initialize())
|
app.on_startup.append(lambda app: dqueue.initialize())
|
||||||
|
app.on_cleanup.append(lambda app: Download.shutdown_manager())
|
||||||
|
|
||||||
class FileOpsFilter(DefaultFilter):
|
class FileOpsFilter(DefaultFilter):
|
||||||
def __call__(self, change_type: int, path: str) -> bool:
|
def __call__(self, change_type: int, path: str) -> bool:
|
||||||
@@ -207,40 +321,139 @@ async def watch_files():
|
|||||||
if config.YTDL_OPTIONS_FILE:
|
if config.YTDL_OPTIONS_FILE:
|
||||||
app.on_startup.append(lambda app: watch_files())
|
app.on_startup.append(lambda app: watch_files())
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@routes.post(config.URL_PREFIX + 'add')
|
@routes.post(config.URL_PREFIX + 'add')
|
||||||
async def add(request):
|
async def add(request):
|
||||||
log.info("Received request to add download")
|
log.info("Received request to add download")
|
||||||
post = await request.json()
|
post = await _read_json_request(request)
|
||||||
log.info(f"Request data: {post}")
|
post = _migrate_legacy_request(post)
|
||||||
|
log.info(
|
||||||
|
"Add download request: type=%s quality=%s format=%s has_folder=%s auto_start=%s",
|
||||||
|
post.get('download_type'),
|
||||||
|
post.get('quality'),
|
||||||
|
post.get('format'),
|
||||||
|
bool(post.get('folder')),
|
||||||
|
post.get('auto_start'),
|
||||||
|
)
|
||||||
url = post.get('url')
|
url = post.get('url')
|
||||||
quality = post.get('quality')
|
download_type = post.get('download_type')
|
||||||
if not url or not quality:
|
codec = post.get('codec')
|
||||||
log.error("Bad request: missing 'url' or 'quality'")
|
|
||||||
raise web.HTTPBadRequest()
|
|
||||||
format = post.get('format')
|
format = post.get('format')
|
||||||
|
quality = post.get('quality')
|
||||||
|
if not url or not quality or not download_type:
|
||||||
|
log.error("Bad request: missing 'url', 'download_type', or 'quality'")
|
||||||
|
raise web.HTTPBadRequest()
|
||||||
folder = post.get('folder')
|
folder = post.get('folder')
|
||||||
custom_name_prefix = post.get('custom_name_prefix')
|
custom_name_prefix = post.get('custom_name_prefix')
|
||||||
playlist_strict_mode = post.get('playlist_strict_mode')
|
|
||||||
playlist_item_limit = post.get('playlist_item_limit')
|
playlist_item_limit = post.get('playlist_item_limit')
|
||||||
auto_start = post.get('auto_start')
|
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:
|
if custom_name_prefix is None:
|
||||||
custom_name_prefix = ''
|
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:
|
if auto_start is None:
|
||||||
auto_start = True
|
auto_start = True
|
||||||
if playlist_strict_mode is None:
|
|
||||||
playlist_strict_mode = config.DEFAULT_OPTION_PLAYLIST_STRICT_MODE
|
|
||||||
if playlist_item_limit is None:
|
if playlist_item_limit is None:
|
||||||
playlist_item_limit = config.DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT
|
playlist_item_limit = config.DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT
|
||||||
|
if split_by_chapters is None:
|
||||||
|
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()
|
||||||
|
|
||||||
|
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)}')
|
||||||
|
|
||||||
|
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)
|
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
|
||||||
|
|
||||||
status = await dqueue.add(url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start)
|
status = await dqueue.add(
|
||||||
|
url,
|
||||||
|
download_type,
|
||||||
|
codec,
|
||||||
|
format,
|
||||||
|
quality,
|
||||||
|
folder,
|
||||||
|
custom_name_prefix,
|
||||||
|
playlist_item_limit,
|
||||||
|
auto_start,
|
||||||
|
split_by_chapters,
|
||||||
|
chapter_template,
|
||||||
|
subtitle_language,
|
||||||
|
subtitle_mode,
|
||||||
|
)
|
||||||
return web.Response(text=serializer.encode(status))
|
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 + 'delete')
|
@routes.post(config.URL_PREFIX + 'delete')
|
||||||
async def delete(request):
|
async def delete(request):
|
||||||
post = await request.json()
|
post = await _read_json_request(request)
|
||||||
ids = post.get('ids')
|
ids = post.get('ids')
|
||||||
where = post.get('where')
|
where = post.get('where')
|
||||||
if not ids or where not in ['queue', 'done']:
|
if not ids or where not in ['queue', 'done']:
|
||||||
@@ -252,12 +465,77 @@ async def delete(request):
|
|||||||
|
|
||||||
@routes.post(config.URL_PREFIX + 'start')
|
@routes.post(config.URL_PREFIX + 'start')
|
||||||
async def start(request):
|
async def start(request):
|
||||||
post = await request.json()
|
post = await _read_json_request(request)
|
||||||
ids = post.get('ids')
|
ids = post.get('ids')
|
||||||
log.info(f"Received request to start pending downloads for ids: {ids}")
|
log.info(f"Received request to start pending downloads for ids: {ids}")
|
||||||
status = await dqueue.start_pending(ids)
|
status = await dqueue.start_pending(ids)
|
||||||
return web.Response(text=serializer.encode(status))
|
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')
|
@routes.get(config.URL_PREFIX + 'history')
|
||||||
async def history(request):
|
async def history(request):
|
||||||
history = { 'done': [], 'queue': [], 'pending': []}
|
history = { 'done': [], 'queue': [], 'pending': []}
|
||||||
@@ -276,13 +554,29 @@ async def history(request):
|
|||||||
async def connect(sid, environ):
|
async def connect(sid, environ):
|
||||||
log.info(f"Client connected: {sid}")
|
log.info(f"Client connected: {sid}")
|
||||||
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
|
await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
|
||||||
await sio.emit('configuration', serializer.encode(config), to=sid)
|
await sio.emit('configuration', serializer.encode(config.frontend_safe()), to=sid)
|
||||||
if config.CUSTOM_DIRS:
|
if config.CUSTOM_DIRS:
|
||||||
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
|
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
|
||||||
if config.YTDL_OPTIONS_FILE:
|
if config.YTDL_OPTIONS_FILE:
|
||||||
await sio.emit('ytdl_options_changed', serializer.encode(get_options_update_time()), to=sid)
|
await sio.emit('ytdl_options_changed', serializer.encode(get_options_update_time()), to=sid)
|
||||||
|
|
||||||
def get_custom_dirs():
|
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):
|
def recursive_dirs(base):
|
||||||
path = pathlib.Path(base)
|
path = pathlib.Path(base)
|
||||||
|
|
||||||
@@ -304,8 +598,12 @@ def get_custom_dirs():
|
|||||||
else:
|
else:
|
||||||
return re.search(config.CUSTOM_DIRS_EXCLUDE_REGEX, d) is None
|
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('**/'))))
|
dirs = list(filter(include_dir, map(convert, path.glob('**/'))))
|
||||||
|
if '' not in dirs:
|
||||||
|
dirs.insert(0, '')
|
||||||
|
|
||||||
return dirs
|
return dirs
|
||||||
|
|
||||||
@@ -315,20 +613,24 @@ def get_custom_dirs():
|
|||||||
if config.DOWNLOAD_DIR != config.AUDIO_DOWNLOAD_DIR:
|
if config.DOWNLOAD_DIR != config.AUDIO_DOWNLOAD_DIR:
|
||||||
audio_download_dir = recursive_dirs(config.AUDIO_DOWNLOAD_DIR)
|
audio_download_dir = recursive_dirs(config.AUDIO_DOWNLOAD_DIR)
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
"download_dir": download_dir,
|
"download_dir": download_dir,
|
||||||
"audio_download_dir": audio_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)
|
@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'))
|
response = web.FileResponse(os.path.join(config.BASE_DIR, 'ui/dist/metube/browser/index.html'))
|
||||||
if 'metube_theme' not in request.cookies:
|
if 'metube_theme' not in request.cookies:
|
||||||
response.set_cookie('metube_theme', config.DEFAULT_THEME)
|
response.set_cookie('metube_theme', config.DEFAULT_THEME)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@routes.get(config.URL_PREFIX + 'robots.txt')
|
@routes.get(config.URL_PREFIX + 'robots.txt')
|
||||||
def robots(request):
|
async def robots(request):
|
||||||
if config.ROBOTS_TXT:
|
if config.ROBOTS_TXT:
|
||||||
response = web.FileResponse(os.path.join(config.BASE_DIR, config.ROBOTS_TXT))
|
response = web.FileResponse(os.path.join(config.BASE_DIR, config.ROBOTS_TXT))
|
||||||
else:
|
else:
|
||||||
@@ -338,7 +640,7 @@ def robots(request):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
@routes.get(config.URL_PREFIX + 'version')
|
@routes.get(config.URL_PREFIX + 'version')
|
||||||
def version(request):
|
async def version(request):
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"yt-dlp": yt_dlp_version,
|
"yt-dlp": yt_dlp_version,
|
||||||
"version": os.getenv("METUBE_VERSION", "dev")
|
"version": os.getenv("METUBE_VERSION", "dev")
|
||||||
@@ -346,11 +648,11 @@ def version(request):
|
|||||||
|
|
||||||
if config.URL_PREFIX != '/':
|
if config.URL_PREFIX != '/':
|
||||||
@routes.get('/')
|
@routes.get('/')
|
||||||
def index_redirect_root(request):
|
async def index_redirect_root(request):
|
||||||
return web.HTTPFound(config.URL_PREFIX)
|
return web.HTTPFound(config.URL_PREFIX)
|
||||||
|
|
||||||
@routes.get(config.URL_PREFIX[:-1])
|
@routes.get(config.URL_PREFIX[:-1])
|
||||||
def index_redirect_dir(request):
|
async def index_redirect_dir(request):
|
||||||
return web.HTTPFound(config.URL_PREFIX)
|
return web.HTTPFound(config.URL_PREFIX)
|
||||||
|
|
||||||
routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
|
routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
|
||||||
@@ -369,6 +671,9 @@ async def add_cors(request):
|
|||||||
return web.Response(text=serializer.encode({"status": "ok"}))
|
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 + 'add', add_cors)
|
||||||
|
app.router.add_route('OPTIONS', config.URL_PREFIX + 'cancel-add', 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):
|
async def on_prepare(request, response):
|
||||||
if 'Origin' in request.headers:
|
if 'Origin' in request.headers:
|
||||||
@@ -386,21 +691,6 @@ def supports_reuse_port():
|
|||||||
except (AttributeError, OSError):
|
except (AttributeError, OSError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
def isAccessLogEnabled():
|
def isAccessLogEnabled():
|
||||||
if config.ENABLE_ACCESSLOG:
|
if config.ENABLE_ACCESSLOG:
|
||||||
return access_logger
|
return access_logger
|
||||||
@@ -408,9 +698,15 @@ def isAccessLogEnabled():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
logging.basicConfig(level=parseLogLevel(config.LOGLEVEL))
|
logging.getLogger().setLevel(parseLogLevel(config.LOGLEVEL) or logging.INFO)
|
||||||
log.info(f"Listening on {config.HOST}:{config.PORT}")
|
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:
|
if config.HTTPS:
|
||||||
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||||
ssl_context.load_cert_chain(certfile=config.CERTFILE, keyfile=config.KEYFILE)
|
ssl_context.load_cert_chain(certfile=config.CERTFILE, keyfile=config.KEYFILE)
|
||||||
|
|||||||
@@ -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,146 @@
|
|||||||
|
"""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)
|
||||||
@@ -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,119 @@
|
|||||||
|
"""Integration tests for ``PersistentQueue`` (shelve-backed storage)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
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="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(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_shelve(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_put_rollbacks_in_memory_queue_when_shelf_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_open = __import__("shelve").open
|
||||||
|
|
||||||
|
def bad_open(filename, flag="c", *args, **kwargs):
|
||||||
|
if flag == "w":
|
||||||
|
raise OSError("simulated shelf failure")
|
||||||
|
return orig_open(filename, flag, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch("ytdl.shelve.open", bad_open):
|
||||||
|
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_open = __import__("shelve").open
|
||||||
|
|
||||||
|
def bad_open(filename, flag="c", *args, **kwargs):
|
||||||
|
if flag == "w":
|
||||||
|
raise OSError("simulated shelf failure")
|
||||||
|
return orig_open(filename, flag, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch("ytdl.shelve.open", bad_open):
|
||||||
|
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,172 @@
|
|||||||
|
"""Tests for pure helpers and migration logic in ``ytdl``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pickle
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ytdl import (
|
||||||
|
DownloadInfo,
|
||||||
|
_convert_srt_to_txt_file,
|
||||||
|
_outtmpl_substitute_field,
|
||||||
|
_sanitize_entry_for_pickle,
|
||||||
|
_sanitize_path_component,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class OuttmplSubstituteFieldTests(unittest.TestCase):
|
||||||
|
def test_simple_substitution(self):
|
||||||
|
self.assertEqual(_outtmpl_substitute_field("%(title)s", "title", "Hello"), "Hello")
|
||||||
|
|
||||||
|
def test_format_spec_int(self):
|
||||||
|
self.assertEqual(_outtmpl_substitute_field("%(idx)02d", "idx", 3), "03")
|
||||||
|
|
||||||
|
def test_missing_field_unchanged(self):
|
||||||
|
self.assertEqual(_outtmpl_substitute_field("%(other)s", "title", "x"), "%(other)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, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+609
-89
@@ -1,5 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
|
import collections
|
||||||
|
import collections.abc
|
||||||
|
import pickle
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
import shelve
|
import shelve
|
||||||
import time
|
import time
|
||||||
@@ -8,23 +12,148 @@ import multiprocessing
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import types
|
import types
|
||||||
|
import dbm
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
import yt_dlp.networking.impersonate
|
import yt_dlp.networking.impersonate
|
||||||
|
from yt_dlp.utils import STR_FORMAT_RE_TMPL, STR_FORMAT_TYPES
|
||||||
from dl_formats import get_format, get_opts, AUDIO_FORMATS
|
from dl_formats import get_format, get_opts, AUDIO_FORMATS
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
log = logging.getLogger('ytdl')
|
log = logging.getLogger('ytdl')
|
||||||
|
|
||||||
def _convert_generators_to_lists(obj):
|
|
||||||
"""Recursively convert generators to lists in a dictionary to make it pickleable."""
|
@lru_cache(maxsize=None)
|
||||||
if isinstance(obj, types.GeneratorType):
|
def _compile_outtmpl_pattern(field: str) -> re.Pattern:
|
||||||
return list(obj)
|
"""Compile a regex pattern to match a specific field in an output template, including optional format specifiers."""
|
||||||
elif isinstance(obj, dict):
|
conversion_types = f"[{re.escape(STR_FORMAT_TYPES)}]"
|
||||||
return {k: _convert_generators_to_lists(v) for k, v in obj.items()}
|
return re.compile(STR_FORMAT_RE_TMPL.format(re.escape(field), conversion_types))
|
||||||
elif isinstance(obj, (list, tuple)):
|
|
||||||
return type(obj)(_convert_generators_to_lists(item) for item in obj)
|
|
||||||
|
# Characters that are invalid in Windows/NTFS path components. These are pre-
|
||||||
|
# sanitised when substituting playlist/channel titles into output templates so
|
||||||
|
# that downloads do not fail on NTFS-mounted volumes or Windows Docker hosts.
|
||||||
|
_WINDOWS_INVALID_PATH_CHARS = re.compile(r'[\\:*?"<>|]')
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_path_component(value: Any) -> Any:
|
||||||
|
"""Replace characters that are invalid in Windows path components with '_'.
|
||||||
|
|
||||||
|
Non-string values (int, float, None, …) are passed through unchanged so
|
||||||
|
that ``_outtmpl_substitute_field`` can still coerce them with format specs
|
||||||
|
(e.g. ``%(playlist_index)02d``). Only string values are sanitised because
|
||||||
|
Windows-invalid characters are only a concern for human-readable strings
|
||||||
|
(titles, channel names, etc.) that may end up as directory names.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return _WINDOWS_INVALID_PATH_CHARS.sub('_', value)
|
||||||
|
|
||||||
|
|
||||||
|
def _outtmpl_substitute_field(template: str, field: str, value: Any) -> str:
|
||||||
|
"""Substitute a single field in an output template, applying any format specifiers to the value."""
|
||||||
|
pattern = _compile_outtmpl_pattern(field)
|
||||||
|
|
||||||
|
def replacement(match: re.Match) -> str:
|
||||||
|
if match.group("has_key") is None:
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
prefix = match.group("prefix") or ""
|
||||||
|
format_spec = match.group("format")
|
||||||
|
|
||||||
|
if not format_spec:
|
||||||
|
return f"{prefix}{value}"
|
||||||
|
|
||||||
|
conversion_type = format_spec[-1]
|
||||||
|
try:
|
||||||
|
if conversion_type in "diouxX":
|
||||||
|
coerced_value = int(value)
|
||||||
|
elif conversion_type in "eEfFgG":
|
||||||
|
coerced_value = float(value)
|
||||||
else:
|
else:
|
||||||
|
coerced_value = value
|
||||||
|
|
||||||
|
return f"{prefix}{('%' + format_spec) % coerced_value}"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return f"{prefix}{value}"
|
||||||
|
|
||||||
|
return pattern.sub(replacement, template)
|
||||||
|
|
||||||
|
_MAX_ENTRY_SANITIZE_DEPTH = 64
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_entry_for_pickle(obj, _depth=0):
|
||||||
|
"""Recursively normalize yt-dlp ``info_dict`` data so it can be stored in shelve/pickle.
|
||||||
|
|
||||||
|
Live streams and newer yt-dlp versions may nest generators, iterators, sets, or
|
||||||
|
non-serializable objects (e.g. locks) inside the extracted metadata. The previous
|
||||||
|
helper only walked plain dict/list/tuple and only expanded ``types.GeneratorType``.
|
||||||
|
"""
|
||||||
|
if _depth > _MAX_ENTRY_SANITIZE_DEPTH:
|
||||||
|
return None
|
||||||
|
if obj is None or isinstance(obj, (bool, int, float, str, bytes)):
|
||||||
return obj
|
return obj
|
||||||
|
if isinstance(obj, types.GeneratorType):
|
||||||
|
return _sanitize_entry_for_pickle(list(obj), _depth + 1)
|
||||||
|
if isinstance(obj, collections.abc.Mapping):
|
||||||
|
return {k: _sanitize_entry_for_pickle(v, _depth + 1) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, (list, tuple)):
|
||||||
|
return type(obj)(_sanitize_entry_for_pickle(x, _depth + 1) for x in obj)
|
||||||
|
if isinstance(obj, (set, frozenset)):
|
||||||
|
return [_sanitize_entry_for_pickle(x, _depth + 1) for x in obj]
|
||||||
|
if isinstance(obj, collections.deque):
|
||||||
|
return [_sanitize_entry_for_pickle(x, _depth + 1) for x in obj]
|
||||||
|
if isinstance(obj, collections.abc.Iterator):
|
||||||
|
try:
|
||||||
|
return _sanitize_entry_for_pickle(list(obj), _depth + 1)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
|
||||||
|
return obj
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_srt_to_txt_file(subtitle_path: str):
|
||||||
|
"""Convert an SRT subtitle file into plain text by stripping cue numbers/timestamps."""
|
||||||
|
txt_path = os.path.splitext(subtitle_path)[0] + ".txt"
|
||||||
|
try:
|
||||||
|
with open(subtitle_path, "r", encoding="utf-8", errors="replace") as infile:
|
||||||
|
content = infile.read()
|
||||||
|
|
||||||
|
# Normalize newlines so cue splitting is consistent across platforms.
|
||||||
|
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
cues = []
|
||||||
|
for block in re.split(r"\n{2,}", content):
|
||||||
|
lines = [line.strip() for line in block.split("\n") if line.strip()]
|
||||||
|
if not lines:
|
||||||
|
continue
|
||||||
|
if re.fullmatch(r"\d+", lines[0]):
|
||||||
|
lines = lines[1:]
|
||||||
|
if lines and "-->" in lines[0]:
|
||||||
|
lines = lines[1:]
|
||||||
|
|
||||||
|
text_lines = []
|
||||||
|
for line in lines:
|
||||||
|
if "-->" in line:
|
||||||
|
continue
|
||||||
|
clean_line = re.sub(r"<[^>]+>", "", line).strip()
|
||||||
|
if clean_line:
|
||||||
|
text_lines.append(clean_line)
|
||||||
|
if text_lines:
|
||||||
|
cues.append(" ".join(text_lines))
|
||||||
|
|
||||||
|
with open(txt_path, "w", encoding="utf-8") as outfile:
|
||||||
|
if cues:
|
||||||
|
outfile.write("\n".join(cues))
|
||||||
|
outfile.write("\n")
|
||||||
|
return txt_path
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning(f"Failed to convert subtitle file {subtitle_path} to txt: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
class DownloadQueueNotifier:
|
class DownloadQueueNotifier:
|
||||||
async def added(self, dl):
|
async def added(self, dl):
|
||||||
@@ -43,11 +172,31 @@ class DownloadQueueNotifier:
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
class DownloadInfo:
|
class DownloadInfo:
|
||||||
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit):
|
def __init__(
|
||||||
|
self,
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
quality,
|
||||||
|
download_type,
|
||||||
|
codec,
|
||||||
|
format,
|
||||||
|
folder,
|
||||||
|
custom_name_prefix,
|
||||||
|
error,
|
||||||
|
entry,
|
||||||
|
playlist_item_limit,
|
||||||
|
split_by_chapters,
|
||||||
|
chapter_template,
|
||||||
|
subtitle_language="en",
|
||||||
|
subtitle_mode="prefer_manual",
|
||||||
|
):
|
||||||
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}'
|
||||||
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
|
||||||
self.url = url
|
self.url = url
|
||||||
self.quality = quality
|
self.quality = quality
|
||||||
|
self.download_type = download_type
|
||||||
|
self.codec = codec
|
||||||
self.format = format
|
self.format = format
|
||||||
self.folder = folder
|
self.folder = folder
|
||||||
self.custom_name_prefix = custom_name_prefix
|
self.custom_name_prefix = custom_name_prefix
|
||||||
@@ -56,23 +205,86 @@ class DownloadInfo:
|
|||||||
self.size = None
|
self.size = None
|
||||||
self.timestamp = time.time_ns()
|
self.timestamp = time.time_ns()
|
||||||
self.error = error
|
self.error = error
|
||||||
# Convert generators to lists to make entry pickleable
|
# Strip non-pickleable values (generators, iterators, locks, etc.) for shelve
|
||||||
self.entry = _convert_generators_to_lists(entry) if entry is not None else None
|
self.entry = _sanitize_entry_for_pickle(entry) if entry is not None else None
|
||||||
self.playlist_item_limit = playlist_item_limit
|
self.playlist_item_limit = playlist_item_limit
|
||||||
|
self.split_by_chapters = split_by_chapters
|
||||||
|
self.chapter_template = chapter_template
|
||||||
|
self.subtitle_language = subtitle_language
|
||||||
|
self.subtitle_mode = subtitle_mode
|
||||||
|
self.subtitle_files = []
|
||||||
|
|
||||||
|
def __setstate__(self, state):
|
||||||
|
"""BACKWARD COMPATIBILITY: migrate old DownloadInfo from persistent queue files."""
|
||||||
|
self.__dict__.update(state)
|
||||||
|
if 'download_type' not in state:
|
||||||
|
old_format = state.get('format', 'any')
|
||||||
|
old_video_codec = state.get('video_codec', 'auto')
|
||||||
|
old_quality = state.get('quality', 'best')
|
||||||
|
old_subtitle_format = state.get('subtitle_format', 'srt')
|
||||||
|
|
||||||
|
if old_format in AUDIO_FORMATS:
|
||||||
|
self.download_type = 'audio'
|
||||||
|
self.codec = 'auto'
|
||||||
|
elif old_format == 'thumbnail':
|
||||||
|
self.download_type = 'thumbnail'
|
||||||
|
self.codec = 'auto'
|
||||||
|
self.format = 'jpg'
|
||||||
|
elif old_format == 'captions':
|
||||||
|
self.download_type = 'captions'
|
||||||
|
self.codec = 'auto'
|
||||||
|
self.format = old_subtitle_format
|
||||||
|
else:
|
||||||
|
self.download_type = 'video'
|
||||||
|
self.codec = old_video_codec
|
||||||
|
if old_quality == 'best_ios':
|
||||||
|
self.format = 'ios'
|
||||||
|
self.quality = 'best'
|
||||||
|
elif old_quality == 'audio':
|
||||||
|
self.download_type = 'audio'
|
||||||
|
self.codec = 'auto'
|
||||||
|
self.format = 'm4a'
|
||||||
|
self.quality = 'best'
|
||||||
|
self.__dict__.pop('video_codec', None)
|
||||||
|
self.__dict__.pop('subtitle_format', None)
|
||||||
|
|
||||||
|
if not getattr(self, "codec", None):
|
||||||
|
self.codec = "auto"
|
||||||
|
if not hasattr(self, "subtitle_files"):
|
||||||
|
self.subtitle_files = []
|
||||||
|
|
||||||
class Download:
|
class Download:
|
||||||
manager = None
|
manager = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def shutdown_manager(cls):
|
||||||
|
if cls.manager is not None:
|
||||||
|
cls.manager.shutdown()
|
||||||
|
cls.manager = None
|
||||||
|
|
||||||
def __init__(self, download_dir, temp_dir, output_template, output_template_chapter, quality, format, ytdl_opts, info):
|
def __init__(self, download_dir, temp_dir, output_template, output_template_chapter, quality, format, ytdl_opts, info):
|
||||||
self.download_dir = download_dir
|
self.download_dir = download_dir
|
||||||
self.temp_dir = temp_dir
|
self.temp_dir = temp_dir
|
||||||
self.output_template = output_template
|
self.output_template = output_template
|
||||||
self.output_template_chapter = output_template_chapter
|
self.output_template_chapter = output_template_chapter
|
||||||
self.format = get_format(format, quality)
|
self.info = info
|
||||||
self.ytdl_opts = get_opts(format, quality, ytdl_opts)
|
self.format = get_format(
|
||||||
|
getattr(info, 'download_type', 'video'),
|
||||||
|
getattr(info, 'codec', 'auto'),
|
||||||
|
format,
|
||||||
|
quality,
|
||||||
|
)
|
||||||
|
self.ytdl_opts = get_opts(
|
||||||
|
getattr(info, 'download_type', 'video'),
|
||||||
|
getattr(info, 'codec', 'auto'),
|
||||||
|
format,
|
||||||
|
quality,
|
||||||
|
ytdl_opts,
|
||||||
|
subtitle_language=getattr(info, 'subtitle_language', 'en'),
|
||||||
|
subtitle_mode=getattr(info, 'subtitle_mode', 'prefer_manual'),
|
||||||
|
)
|
||||||
if "impersonate" in self.ytdl_opts:
|
if "impersonate" in self.ytdl_opts:
|
||||||
self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"])
|
self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"])
|
||||||
self.info = info
|
|
||||||
self.canceled = False
|
self.canceled = False
|
||||||
self.tmpfilename = None
|
self.tmpfilename = None
|
||||||
self.status_queue = None
|
self.status_queue = None
|
||||||
@@ -83,6 +295,7 @@ class Download:
|
|||||||
def _download(self):
|
def _download(self):
|
||||||
log.info(f"Starting download for: {self.info.title} ({self.info.url})")
|
log.info(f"Starting download for: {self.info.title} ({self.info.url})")
|
||||||
try:
|
try:
|
||||||
|
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
||||||
def put_status(st):
|
def put_status(st):
|
||||||
self.status_queue.put({k: v for k, v in st.items() if k in (
|
self.status_queue.put({k: v for k, v in st.items() if k in (
|
||||||
'tmpfilename',
|
'tmpfilename',
|
||||||
@@ -98,14 +311,36 @@ class Download:
|
|||||||
|
|
||||||
def put_status_postprocessor(d):
|
def put_status_postprocessor(d):
|
||||||
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
|
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
|
||||||
|
filepath = d['info_dict']['filepath']
|
||||||
if '__finaldir' in d['info_dict']:
|
if '__finaldir' in d['info_dict']:
|
||||||
filename = os.path.join(d['info_dict']['__finaldir'], os.path.basename(d['info_dict']['filepath']))
|
finaldir = d['info_dict']['__finaldir']
|
||||||
|
filename = os.path.join(finaldir, os.path.basename(filepath))
|
||||||
else:
|
else:
|
||||||
filename = d['info_dict']['filepath']
|
filename = filepath
|
||||||
self.status_queue.put({'status': 'finished', 'filename': filename})
|
self.status_queue.put({'status': 'finished', 'filename': filename})
|
||||||
|
# For captions-only downloads, yt-dlp may still report a media-like
|
||||||
|
# filepath in MoveFiles. Capture subtitle outputs explicitly so the
|
||||||
|
# UI can link to real caption files.
|
||||||
|
if getattr(self.info, 'download_type', '') == 'captions':
|
||||||
|
requested_subtitles = d.get('info_dict', {}).get('requested_subtitles', {}) or {}
|
||||||
|
for subtitle in requested_subtitles.values():
|
||||||
|
if isinstance(subtitle, dict) and subtitle.get('filepath'):
|
||||||
|
self.status_queue.put({'subtitle_file': subtitle['filepath']})
|
||||||
|
|
||||||
ret = yt_dlp.YoutubeDL(params={
|
# Capture all chapter files when SplitChapters finishes
|
||||||
'quiet': True,
|
elif d.get('postprocessor') == 'SplitChapters' and d.get('status') == 'finished':
|
||||||
|
chapters = d.get('info_dict', {}).get('chapters', [])
|
||||||
|
if chapters:
|
||||||
|
for chapter in chapters:
|
||||||
|
if isinstance(chapter, dict) and 'filepath' in chapter:
|
||||||
|
log.info(f"Captured chapter file: {chapter['filepath']}")
|
||||||
|
self.status_queue.put({'chapter_file': chapter['filepath']})
|
||||||
|
else:
|
||||||
|
log.warning("SplitChapters finished but no chapter files found in info_dict")
|
||||||
|
|
||||||
|
ytdl_params = {
|
||||||
|
'quiet': not debug_logging,
|
||||||
|
'verbose': debug_logging,
|
||||||
'no_color': True,
|
'no_color': True,
|
||||||
'paths': {"home": self.download_dir, "temp": self.temp_dir},
|
'paths': {"home": self.download_dir, "temp": self.temp_dir},
|
||||||
'outtmpl': { "default": self.output_template, "chapter": self.output_template_chapter },
|
'outtmpl': { "default": self.output_template, "chapter": self.output_template_chapter },
|
||||||
@@ -115,7 +350,19 @@ class Download:
|
|||||||
'progress_hooks': [put_status],
|
'progress_hooks': [put_status],
|
||||||
'postprocessor_hooks': [put_status_postprocessor],
|
'postprocessor_hooks': [put_status_postprocessor],
|
||||||
**self.ytdl_opts,
|
**self.ytdl_opts,
|
||||||
}).download([self.info.url])
|
}
|
||||||
|
|
||||||
|
# Add chapter splitting options if enabled
|
||||||
|
if self.info.split_by_chapters:
|
||||||
|
ytdl_params['outtmpl']['chapter'] = self.info.chapter_template
|
||||||
|
if 'postprocessors' not in ytdl_params:
|
||||||
|
ytdl_params['postprocessors'] = []
|
||||||
|
ytdl_params['postprocessors'].append({
|
||||||
|
'key': 'FFmpegSplitChapters',
|
||||||
|
'force_keyframes': False
|
||||||
|
})
|
||||||
|
|
||||||
|
ret = yt_dlp.YoutubeDL(params=ytdl_params).download([self.info.url])
|
||||||
self.status_queue.put({'status': 'finished' if ret == 0 else 'error'})
|
self.status_queue.put({'status': 'finished' if ret == 0 else 'error'})
|
||||||
log.info(f"Finished download for: {self.info.title}")
|
log.info(f"Finished download for: {self.info.title}")
|
||||||
except yt_dlp.utils.YoutubeDLError as exc:
|
except yt_dlp.utils.YoutubeDLError as exc:
|
||||||
@@ -133,8 +380,14 @@ class Download:
|
|||||||
self.notifier = notifier
|
self.notifier = notifier
|
||||||
self.info.status = 'preparing'
|
self.info.status = 'preparing'
|
||||||
await self.notifier.updated(self.info)
|
await self.notifier.updated(self.info)
|
||||||
asyncio.create_task(self.update_status())
|
self.status_task = asyncio.create_task(self.update_status())
|
||||||
return await self.loop.run_in_executor(None, self.proc.join)
|
await self.loop.run_in_executor(None, self.proc.join)
|
||||||
|
# Signal update_status to stop and wait for it to finish
|
||||||
|
# so that all status updates (including MoveFiles with correct
|
||||||
|
# file size) are processed before _post_download_cleanup runs.
|
||||||
|
if self.status_queue is not None:
|
||||||
|
self.status_queue.put(None)
|
||||||
|
await self.status_task
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
log.info(f"Cancelling download: {self.info.title}")
|
log.info(f"Cancelling download: {self.info.title}")
|
||||||
@@ -151,8 +404,6 @@ class Download:
|
|||||||
log.info(f"Closing download process for: {self.info.title}")
|
log.info(f"Closing download process for: {self.info.title}")
|
||||||
if self.started():
|
if self.started():
|
||||||
self.proc.close()
|
self.proc.close()
|
||||||
if self.status_queue is not None:
|
|
||||||
self.status_queue.put(None)
|
|
||||||
|
|
||||||
def running(self):
|
def running(self):
|
||||||
try:
|
try:
|
||||||
@@ -175,10 +426,65 @@ class Download:
|
|||||||
self.tmpfilename = status.get('tmpfilename')
|
self.tmpfilename = status.get('tmpfilename')
|
||||||
if 'filename' in status:
|
if 'filename' in status:
|
||||||
fileName = status.get('filename')
|
fileName = status.get('filename')
|
||||||
self.info.filename = os.path.relpath(fileName, self.download_dir)
|
rel_name = os.path.relpath(fileName, self.download_dir)
|
||||||
|
# For captions mode, ignore media-like placeholders and let subtitle_file
|
||||||
|
# statuses define the final file shown in the UI.
|
||||||
|
if getattr(self.info, 'download_type', '') == 'captions':
|
||||||
|
requested_subtitle_format = str(getattr(self.info, 'format', '')).lower()
|
||||||
|
allowed_caption_exts = ('.txt',) if requested_subtitle_format == 'txt' else ('.vtt', '.srt', '.sbv', '.scc', '.ttml', '.dfxp')
|
||||||
|
if not rel_name.lower().endswith(allowed_caption_exts):
|
||||||
|
continue
|
||||||
|
self.info.filename = rel_name
|
||||||
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
|
self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None
|
||||||
if self.info.format == 'thumbnail':
|
if getattr(self.info, 'download_type', '') == 'thumbnail':
|
||||||
self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
|
self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename)
|
||||||
|
|
||||||
|
# Handle chapter files
|
||||||
|
log.debug(f"Update status for {self.info.title}: {status}")
|
||||||
|
if 'chapter_file' in status:
|
||||||
|
chapter_file = status.get('chapter_file')
|
||||||
|
if not hasattr(self.info, 'chapter_files'):
|
||||||
|
self.info.chapter_files = []
|
||||||
|
rel_path = os.path.relpath(chapter_file, self.download_dir)
|
||||||
|
file_size = os.path.getsize(chapter_file) if os.path.exists(chapter_file) else None
|
||||||
|
#Postprocessor hook called multiple times with chapters. Only insert if not already present.
|
||||||
|
existing = next((cf for cf in self.info.chapter_files if cf['filename'] == rel_path), None)
|
||||||
|
if not existing:
|
||||||
|
self.info.chapter_files.append({'filename': rel_path, 'size': file_size})
|
||||||
|
# Skip the rest of status processing for chapter files
|
||||||
|
continue
|
||||||
|
|
||||||
|
if 'subtitle_file' in status:
|
||||||
|
subtitle_file = status.get('subtitle_file')
|
||||||
|
if not subtitle_file:
|
||||||
|
continue
|
||||||
|
subtitle_output_file = subtitle_file
|
||||||
|
|
||||||
|
# txt mode is derived from SRT by stripping cue metadata.
|
||||||
|
if getattr(self.info, 'download_type', '') == 'captions' and str(getattr(self.info, 'format', '')).lower() == 'txt':
|
||||||
|
converted_txt = _convert_srt_to_txt_file(subtitle_file)
|
||||||
|
if converted_txt:
|
||||||
|
subtitle_output_file = converted_txt
|
||||||
|
if converted_txt != subtitle_file:
|
||||||
|
try:
|
||||||
|
os.remove(subtitle_file)
|
||||||
|
except OSError as exc:
|
||||||
|
log.debug(f"Could not remove temporary SRT file {subtitle_file}: {exc}")
|
||||||
|
|
||||||
|
rel_path = os.path.relpath(subtitle_output_file, self.download_dir)
|
||||||
|
file_size = os.path.getsize(subtitle_output_file) if os.path.exists(subtitle_output_file) else None
|
||||||
|
existing = next((sf for sf in self.info.subtitle_files if sf['filename'] == rel_path), None)
|
||||||
|
if not existing:
|
||||||
|
self.info.subtitle_files.append({'filename': rel_path, 'size': file_size})
|
||||||
|
# Prefer first subtitle file as the primary result link in captions mode.
|
||||||
|
if getattr(self.info, 'download_type', '') == 'captions' and (
|
||||||
|
not getattr(self.info, 'filename', None) or
|
||||||
|
str(getattr(self.info, 'format', '')).lower() == 'txt'
|
||||||
|
):
|
||||||
|
self.info.filename = rel_path
|
||||||
|
self.info.size = file_size
|
||||||
|
continue
|
||||||
|
|
||||||
self.info.status = status['status']
|
self.info.status = status['status']
|
||||||
self.info.msg = status.get('msg')
|
self.info.msg = status.get('msg')
|
||||||
if 'downloaded_bytes' in status:
|
if 'downloaded_bytes' in status:
|
||||||
@@ -187,22 +493,25 @@ class Download:
|
|||||||
self.info.percent = status['downloaded_bytes'] / total * 100
|
self.info.percent = status['downloaded_bytes'] / total * 100
|
||||||
self.info.speed = status.get('speed')
|
self.info.speed = status.get('speed')
|
||||||
self.info.eta = status.get('eta')
|
self.info.eta = status.get('eta')
|
||||||
log.info(f"Updating status for {self.info.title}: {status}")
|
log.debug(f"Updating status for {self.info.title}: {status}")
|
||||||
await self.notifier.updated(self.info)
|
await self.notifier.updated(self.info)
|
||||||
|
|
||||||
class PersistentQueue:
|
class PersistentQueue:
|
||||||
def __init__(self, path):
|
def __init__(self, name, path):
|
||||||
|
self.identifier = name
|
||||||
pdir = os.path.dirname(path)
|
pdir = os.path.dirname(path)
|
||||||
if not os.path.isdir(pdir):
|
if not os.path.isdir(pdir):
|
||||||
os.mkdir(pdir)
|
os.mkdir(pdir)
|
||||||
with shelve.open(path, 'c'):
|
with shelve.open(path, 'c'):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self.path = path
|
self.path = path
|
||||||
|
self.repair()
|
||||||
self.dict = OrderedDict()
|
self.dict = OrderedDict()
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
for k, v in self.saved_items():
|
for k, v in self.saved_items():
|
||||||
self.dict[k] = Download(None, None, None, None, None, None, {}, v)
|
self.dict[k] = Download(None, None, None, None, getattr(v, 'quality', 'best'), getattr(v, 'format', 'any'), {}, v)
|
||||||
|
|
||||||
def exists(self, key):
|
def exists(self, key):
|
||||||
return key in self.dict
|
return key in self.dict
|
||||||
@@ -219,9 +528,17 @@ class PersistentQueue:
|
|||||||
|
|
||||||
def put(self, value):
|
def put(self, value):
|
||||||
key = value.info.url
|
key = value.info.url
|
||||||
|
old = self.dict.get(key)
|
||||||
self.dict[key] = value
|
self.dict[key] = value
|
||||||
|
try:
|
||||||
with shelve.open(self.path, 'w') as shelf:
|
with shelve.open(self.path, 'w') as shelf:
|
||||||
shelf[key] = value.info
|
shelf[key] = value.info
|
||||||
|
except Exception:
|
||||||
|
if old is None:
|
||||||
|
del self.dict[key]
|
||||||
|
else:
|
||||||
|
self.dict[key] = old
|
||||||
|
raise
|
||||||
|
|
||||||
def delete(self, key):
|
def delete(self, key):
|
||||||
if key in self.dict:
|
if key in self.dict:
|
||||||
@@ -236,22 +553,106 @@ class PersistentQueue:
|
|||||||
def empty(self):
|
def empty(self):
|
||||||
return not bool(self.dict)
|
return not bool(self.dict)
|
||||||
|
|
||||||
|
def repair(self):
|
||||||
|
# check DB format
|
||||||
|
type_check = subprocess.run(
|
||||||
|
["file", self.path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
db_type = type_check.stdout.lower()
|
||||||
|
|
||||||
|
# create backup (<queue>.old)
|
||||||
|
try:
|
||||||
|
shutil.copy2(self.path, f"{self.path}.old")
|
||||||
|
except Exception as e:
|
||||||
|
# if we cannot backup then its not safe to attempt a repair
|
||||||
|
# since it could be due to a filesystem error
|
||||||
|
log.debug(f"PersistentQueue:{self.identifier} backup failed, skipping repair")
|
||||||
|
return
|
||||||
|
|
||||||
|
if "gnu dbm" in db_type:
|
||||||
|
# perform gdbm repair
|
||||||
|
log_prefix = f"PersistentQueue:{self.identifier} repair (dbm/file)"
|
||||||
|
log.debug(f"{log_prefix} started")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["gdbmtool", self.path],
|
||||||
|
input="recover verbose summary\n",
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
log.debug(f"{log_prefix} {result.stdout}")
|
||||||
|
if result.stderr:
|
||||||
|
log.debug(f"{log_prefix} failed: {result.stderr}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
log.debug(f"{log_prefix} failed: 'gdbmtool' was not found")
|
||||||
|
|
||||||
|
# perform null key cleanup
|
||||||
|
log_prefix = f"PersistentQueue:{self.identifier} repair (null keys)"
|
||||||
|
log.debug(f"{log_prefix} started")
|
||||||
|
deleted = 0
|
||||||
|
try:
|
||||||
|
with dbm.open(self.path, "w") as db:
|
||||||
|
for key in list(db.keys()):
|
||||||
|
if len(key) > 0 and all(b == 0x00 for b in key):
|
||||||
|
log.debug(f"{log_prefix} deleting key of length {len(key)} (all NUL bytes)")
|
||||||
|
del db[key]
|
||||||
|
deleted += 1
|
||||||
|
log.debug(f"{log_prefix} done - deleted {deleted} key(s)")
|
||||||
|
except dbm.error:
|
||||||
|
log.debug(f"{log_prefix} failed: db type is dbm.gnu, but the module is not available (dbm.error; module support may be missing or the file may be corrupted)")
|
||||||
|
|
||||||
|
elif "sqlite" in db_type:
|
||||||
|
# perform sqlite3 recovery
|
||||||
|
log_prefix = f"PersistentQueue:{self.identifier} repair (sqlite3/file)"
|
||||||
|
log.debug(f"{log_prefix} started")
|
||||||
|
try:
|
||||||
|
recover_proc = subprocess.Popen(
|
||||||
|
["sqlite3", self.path, ".recover"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
run_result = subprocess.run(
|
||||||
|
["sqlite3", f"{self.path}.tmp"],
|
||||||
|
stdin=recover_proc.stdout,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if recover_proc.stdout is not None:
|
||||||
|
recover_proc.stdout.close()
|
||||||
|
recover_stderr = recover_proc.stderr.read() if recover_proc.stderr is not None else ""
|
||||||
|
recover_proc.wait(timeout=60)
|
||||||
|
if run_result.stderr or recover_stderr:
|
||||||
|
error_text = " ".join(part for part in [recover_stderr.strip(), run_result.stderr.strip()] if part)
|
||||||
|
log.debug(f"{log_prefix} failed: {error_text}")
|
||||||
|
else:
|
||||||
|
shutil.move(f"{self.path}.tmp", self.path)
|
||||||
|
log.debug(f"{log_prefix}{run_result.stdout or ' was successful, no output'}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
log.debug(f"{log_prefix} failed: 'sqlite3' was not found")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
log.debug(f"{log_prefix} failed: sqlite recovery timed out")
|
||||||
|
|
||||||
class DownloadQueue:
|
class DownloadQueue:
|
||||||
def __init__(self, config, notifier):
|
def __init__(self, config, notifier):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.notifier = notifier
|
self.notifier = notifier
|
||||||
self.queue = PersistentQueue(self.config.STATE_DIR + '/queue')
|
self.queue = PersistentQueue("queue", self.config.STATE_DIR + '/queue')
|
||||||
self.done = PersistentQueue(self.config.STATE_DIR + '/completed')
|
self.done = PersistentQueue("completed", self.config.STATE_DIR + '/completed')
|
||||||
self.pending = PersistentQueue(self.config.STATE_DIR + '/pending')
|
self.pending = PersistentQueue("pending", self.config.STATE_DIR + '/pending')
|
||||||
self.active_downloads = set()
|
self.active_downloads = set()
|
||||||
self.semaphore = None
|
|
||||||
# For sequential mode, use an asyncio lock to ensure one-at-a-time execution.
|
|
||||||
if self.config.DOWNLOAD_MODE == 'sequential':
|
|
||||||
self.seq_lock = asyncio.Lock()
|
|
||||||
elif self.config.DOWNLOAD_MODE == 'limited':
|
|
||||||
self.semaphore = asyncio.Semaphore(int(self.config.MAX_CONCURRENT_DOWNLOADS))
|
self.semaphore = asyncio.Semaphore(int(self.config.MAX_CONCURRENT_DOWNLOADS))
|
||||||
|
|
||||||
self.done.load()
|
self.done.load()
|
||||||
|
self._add_generation = 0
|
||||||
|
self._canceled_urls = set() # URLs canceled during current playlist add
|
||||||
|
|
||||||
|
def cancel_add(self):
|
||||||
|
self._add_generation += 1
|
||||||
|
log.info('Playlist add operation canceled by user')
|
||||||
|
|
||||||
async def __import_queue(self):
|
async def __import_queue(self):
|
||||||
for k, v in self.queue.saved_items():
|
for k, v in self.queue.saved_items():
|
||||||
@@ -270,28 +671,9 @@ class DownloadQueue:
|
|||||||
if download.canceled:
|
if download.canceled:
|
||||||
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
||||||
return
|
return
|
||||||
if self.config.DOWNLOAD_MODE == 'sequential':
|
|
||||||
async with self.seq_lock:
|
|
||||||
log.info("Starting sequential download.")
|
|
||||||
await download.start(self.notifier)
|
|
||||||
self._post_download_cleanup(download)
|
|
||||||
elif self.config.DOWNLOAD_MODE == 'limited' and self.semaphore is not None:
|
|
||||||
await self.__limited_concurrent_download(download)
|
|
||||||
else:
|
|
||||||
await self.__concurrent_download(download)
|
|
||||||
|
|
||||||
async def __concurrent_download(self, download):
|
|
||||||
log.info("Starting concurrent download without limits.")
|
|
||||||
asyncio.create_task(self._run_download(download))
|
|
||||||
|
|
||||||
async def __limited_concurrent_download(self, download):
|
|
||||||
log.info("Starting limited concurrent download.")
|
|
||||||
async with self.semaphore:
|
async with self.semaphore:
|
||||||
await self._run_download(download)
|
|
||||||
|
|
||||||
async def _run_download(self, download):
|
|
||||||
if download.canceled:
|
if download.canceled:
|
||||||
log.info(f"Download {download.info.title} is canceled; skipping start.")
|
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
||||||
return
|
return
|
||||||
await download.start(self.notifier)
|
await download.start(self.notifier)
|
||||||
self._post_download_cleanup(download)
|
self._post_download_cleanup(download)
|
||||||
@@ -301,7 +683,7 @@ class DownloadQueue:
|
|||||||
if download.tmpfilename and os.path.isfile(download.tmpfilename):
|
if download.tmpfilename and os.path.isfile(download.tmpfilename):
|
||||||
try:
|
try:
|
||||||
os.remove(download.tmpfilename)
|
os.remove(download.tmpfilename)
|
||||||
except:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
download.info.status = 'error'
|
download.info.status = 'error'
|
||||||
download.close()
|
download.close()
|
||||||
@@ -312,24 +694,40 @@ class DownloadQueue:
|
|||||||
else:
|
else:
|
||||||
self.done.put(download)
|
self.done.put(download)
|
||||||
asyncio.create_task(self.notifier.completed(download.info))
|
asyncio.create_task(self.notifier.completed(download.info))
|
||||||
|
try:
|
||||||
|
clear_after = int(self.config.CLEAR_COMPLETED_AFTER)
|
||||||
|
except ValueError:
|
||||||
|
log.error(f'CLEAR_COMPLETED_AFTER is set to an invalid value "{self.config.CLEAR_COMPLETED_AFTER}", expected an integer number of seconds')
|
||||||
|
clear_after = 0
|
||||||
|
if clear_after > 0:
|
||||||
|
task = asyncio.create_task(self.__auto_clear_after_delay(download.info.url, clear_after))
|
||||||
|
task.add_done_callback(lambda t: log.error(f'Auto-clear task failed: {t.exception()}') if not t.cancelled() and t.exception() else None)
|
||||||
|
|
||||||
def __extract_info(self, url, playlist_strict_mode):
|
async def __auto_clear_after_delay(self, url, delay_seconds):
|
||||||
|
await asyncio.sleep(delay_seconds)
|
||||||
|
if self.done.exists(url):
|
||||||
|
log.debug(f'Auto-clearing completed download: {url}')
|
||||||
|
await self.clear([url])
|
||||||
|
|
||||||
|
def __extract_info(self, url):
|
||||||
|
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
||||||
return yt_dlp.YoutubeDL(params={
|
return yt_dlp.YoutubeDL(params={
|
||||||
'quiet': True,
|
'quiet': not debug_logging,
|
||||||
|
'verbose': debug_logging,
|
||||||
'no_color': True,
|
'no_color': True,
|
||||||
'extract_flat': True,
|
'extract_flat': True,
|
||||||
'ignore_no_formats_error': True,
|
'ignore_no_formats_error': True,
|
||||||
'noplaylist': playlist_strict_mode,
|
'noplaylist': True,
|
||||||
'paths': {"home": self.config.DOWNLOAD_DIR, "temp": self.config.TEMP_DIR},
|
'paths': {"home": self.config.DOWNLOAD_DIR, "temp": self.config.TEMP_DIR},
|
||||||
**self.config.YTDL_OPTIONS,
|
**self.config.YTDL_OPTIONS,
|
||||||
**({'impersonate': yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.config.YTDL_OPTIONS['impersonate'])} if 'impersonate' in self.config.YTDL_OPTIONS else {}),
|
**({'impersonate': yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.config.YTDL_OPTIONS['impersonate'])} if 'impersonate' in self.config.YTDL_OPTIONS else {}),
|
||||||
}).extract_info(url, download=False)
|
}).extract_info(url, download=False)
|
||||||
|
|
||||||
def __calc_download_path(self, quality, format, folder):
|
def __calc_download_path(self, download_type, folder):
|
||||||
base_directory = self.config.DOWNLOAD_DIR if (quality != 'audio' and format not in AUDIO_FORMATS) else self.config.AUDIO_DOWNLOAD_DIR
|
base_directory = self.config.AUDIO_DOWNLOAD_DIR if download_type == 'audio' else self.config.DOWNLOAD_DIR
|
||||||
if folder:
|
if folder:
|
||||||
if not self.config.CUSTOM_DIRS:
|
if not self.config.CUSTOM_DIRS:
|
||||||
return None, {'status': 'error', 'msg': f'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'}
|
return None, {'status': 'error', 'msg': 'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'}
|
||||||
dldirectory = os.path.realpath(os.path.join(base_directory, folder))
|
dldirectory = os.path.realpath(os.path.join(base_directory, folder))
|
||||||
real_base_directory = os.path.realpath(base_directory)
|
real_base_directory = os.path.realpath(base_directory)
|
||||||
if not dldirectory.startswith(real_base_directory):
|
if not dldirectory.startswith(real_base_directory):
|
||||||
@@ -343,18 +741,24 @@ class DownloadQueue:
|
|||||||
return dldirectory, None
|
return dldirectory, None
|
||||||
|
|
||||||
async def __add_download(self, dl, auto_start):
|
async def __add_download(self, dl, auto_start):
|
||||||
dldirectory, error_message = self.__calc_download_path(dl.quality, dl.format, dl.folder)
|
dldirectory, error_message = self.__calc_download_path(dl.download_type, dl.folder)
|
||||||
if error_message is not None:
|
if error_message is not None:
|
||||||
return error_message
|
return error_message
|
||||||
output = self.config.OUTPUT_TEMPLATE if len(dl.custom_name_prefix) == 0 else f'{dl.custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}'
|
output = self.config.OUTPUT_TEMPLATE if len(dl.custom_name_prefix) == 0 else f'{dl.custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}'
|
||||||
output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER
|
output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER
|
||||||
entry = getattr(dl, 'entry', None)
|
entry = getattr(dl, 'entry', None)
|
||||||
if entry is not None and 'playlist' in entry and entry['playlist'] is not None:
|
if entry is not None and entry.get('playlist_index') is not None:
|
||||||
if len(self.config.OUTPUT_TEMPLATE_PLAYLIST):
|
if len(self.config.OUTPUT_TEMPLATE_PLAYLIST):
|
||||||
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
|
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
|
||||||
for property, value in entry.items():
|
for property, value in entry.items():
|
||||||
if property.startswith("playlist"):
|
if property.startswith("playlist"):
|
||||||
output = output.replace(f"%({property})s", str(value))
|
output = _outtmpl_substitute_field(output, property, _sanitize_path_component(value))
|
||||||
|
if entry is not None and entry.get('channel_index') is not None:
|
||||||
|
if len(self.config.OUTPUT_TEMPLATE_CHANNEL):
|
||||||
|
output = self.config.OUTPUT_TEMPLATE_CHANNEL
|
||||||
|
for property, value in entry.items():
|
||||||
|
if property.startswith("channel"):
|
||||||
|
output = _outtmpl_substitute_field(output, property, _sanitize_path_component(value))
|
||||||
ytdl_options = dict(self.config.YTDL_OPTIONS)
|
ytdl_options = dict(self.config.YTDL_OPTIONS)
|
||||||
playlist_item_limit = getattr(dl, 'playlist_item_limit', 0)
|
playlist_item_limit = getattr(dl, 'playlist_item_limit', 0)
|
||||||
if playlist_item_limit > 0:
|
if playlist_item_limit > 0:
|
||||||
@@ -368,7 +772,24 @@ class DownloadQueue:
|
|||||||
self.pending.put(download)
|
self.pending.put(download)
|
||||||
await self.notifier.added(dl)
|
await self.notifier.added(dl)
|
||||||
|
|
||||||
async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already):
|
async def __add_entry(
|
||||||
|
self,
|
||||||
|
entry,
|
||||||
|
download_type,
|
||||||
|
codec,
|
||||||
|
format,
|
||||||
|
quality,
|
||||||
|
folder,
|
||||||
|
custom_name_prefix,
|
||||||
|
playlist_item_limit,
|
||||||
|
auto_start,
|
||||||
|
split_by_chapters,
|
||||||
|
chapter_template,
|
||||||
|
subtitle_language,
|
||||||
|
subtitle_mode,
|
||||||
|
already,
|
||||||
|
_add_gen=None,
|
||||||
|
):
|
||||||
if not entry:
|
if not entry:
|
||||||
return {'status': 'error', 'msg': "Invalid/empty data was given."}
|
return {'status': 'error', 'msg': "Invalid/empty data was given."}
|
||||||
|
|
||||||
@@ -383,42 +804,123 @@ class DownloadQueue:
|
|||||||
etype = entry.get('_type') or 'video'
|
etype = entry.get('_type') or 'video'
|
||||||
|
|
||||||
if etype.startswith('url'):
|
if etype.startswith('url'):
|
||||||
log.debug('Processing as an url')
|
log.debug('Processing as a url')
|
||||||
return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already)
|
return await self.add(
|
||||||
elif etype == 'playlist':
|
entry['url'],
|
||||||
log.debug('Processing as a playlist')
|
download_type,
|
||||||
|
codec,
|
||||||
|
format,
|
||||||
|
quality,
|
||||||
|
folder,
|
||||||
|
custom_name_prefix,
|
||||||
|
playlist_item_limit,
|
||||||
|
auto_start,
|
||||||
|
split_by_chapters,
|
||||||
|
chapter_template,
|
||||||
|
subtitle_language,
|
||||||
|
subtitle_mode,
|
||||||
|
already,
|
||||||
|
_add_gen,
|
||||||
|
)
|
||||||
|
elif etype == 'playlist' or etype == 'channel':
|
||||||
|
log.debug(f'Processing as a {etype}')
|
||||||
entries = entry['entries']
|
entries = entry['entries']
|
||||||
# Convert generator to list if needed (for len() and slicing operations)
|
# Convert generator to list if needed (for len() and slicing operations)
|
||||||
if isinstance(entries, types.GeneratorType):
|
if isinstance(entries, types.GeneratorType):
|
||||||
entries = list(entries)
|
entries = list(entries)
|
||||||
log.info(f'playlist detected with {len(entries)} entries')
|
log.info(f'{etype} detected with {len(entries)} entries')
|
||||||
playlist_index_digits = len(str(len(entries)))
|
index_digits = len(str(len(entries)))
|
||||||
results = []
|
results = []
|
||||||
if playlist_item_limit > 0:
|
if playlist_item_limit > 0:
|
||||||
log.info(f'Playlist item limit is set. Processing only first {playlist_item_limit} entries')
|
log.info(f'Item limit is set. Processing only first {playlist_item_limit} entries')
|
||||||
entries = entries[:playlist_item_limit]
|
entries = entries[:playlist_item_limit]
|
||||||
for index, etr in enumerate(entries, start=1):
|
for index, etr in enumerate(entries, start=1):
|
||||||
|
if _add_gen is not None and self._add_generation != _add_gen:
|
||||||
|
log.info(f'Playlist add canceled after processing {len(already)} entries')
|
||||||
|
return {'status': 'ok', 'msg': f'Canceled - added {len(already)} items before cancel'}
|
||||||
etr["_type"] = "video"
|
etr["_type"] = "video"
|
||||||
etr["playlist"] = entry["id"]
|
etr[etype] = entry.get("id") or entry.get("channel_id") or entry.get("channel")
|
||||||
etr["playlist_index"] = '{{0:0{0:d}d}}'.format(playlist_index_digits).format(index)
|
etr[f"{etype}_index"] = '{{0:0{0:d}d}}'.format(index_digits).format(index)
|
||||||
for property in ("id", "title", "uploader", "uploader_id"):
|
for property in ("id", "title", "uploader", "uploader_id"):
|
||||||
if property in entry:
|
if property in entry:
|
||||||
etr[f"playlist_{property}"] = entry[property]
|
etr[f"{etype}_{property}"] = entry[property]
|
||||||
results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already))
|
results.append(
|
||||||
|
await self.__add_entry(
|
||||||
|
etr,
|
||||||
|
download_type,
|
||||||
|
codec,
|
||||||
|
format,
|
||||||
|
quality,
|
||||||
|
folder,
|
||||||
|
custom_name_prefix,
|
||||||
|
playlist_item_limit,
|
||||||
|
auto_start,
|
||||||
|
split_by_chapters,
|
||||||
|
chapter_template,
|
||||||
|
subtitle_language,
|
||||||
|
subtitle_mode,
|
||||||
|
already,
|
||||||
|
_add_gen,
|
||||||
|
)
|
||||||
|
)
|
||||||
if any(res['status'] == 'error' for res in results):
|
if any(res['status'] == 'error' for res in results):
|
||||||
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
|
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
elif etype == 'video' or (etype.startswith('url') and 'id' in entry and 'title' in entry):
|
elif etype == 'video' or (etype.startswith('url') and 'id' in entry and 'title' in entry):
|
||||||
log.debug('Processing as a video')
|
log.debug('Processing as a video')
|
||||||
key = entry.get('webpage_url') or entry['url']
|
key = entry.get('webpage_url') or entry['url']
|
||||||
|
if key in self._canceled_urls:
|
||||||
|
log.info(f'Skipping canceled URL: {entry.get("title") or key}')
|
||||||
|
return {'status': 'ok'}
|
||||||
if not self.queue.exists(key):
|
if not self.queue.exists(key):
|
||||||
dl = DownloadInfo(entry['id'], entry.get('title') or entry['id'], key, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit)
|
dl = DownloadInfo(
|
||||||
|
id=entry['id'],
|
||||||
|
title=entry.get('title') or entry['id'],
|
||||||
|
url=key,
|
||||||
|
quality=quality,
|
||||||
|
download_type=download_type,
|
||||||
|
codec=codec,
|
||||||
|
format=format,
|
||||||
|
folder=folder,
|
||||||
|
custom_name_prefix=custom_name_prefix,
|
||||||
|
error=error,
|
||||||
|
entry=entry,
|
||||||
|
playlist_item_limit=playlist_item_limit,
|
||||||
|
split_by_chapters=split_by_chapters,
|
||||||
|
chapter_template=chapter_template,
|
||||||
|
subtitle_language=subtitle_language,
|
||||||
|
subtitle_mode=subtitle_mode,
|
||||||
|
)
|
||||||
await self.__add_download(dl, auto_start)
|
await self.__add_download(dl, auto_start)
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
||||||
|
|
||||||
async def add(self, url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start=True, already=None):
|
async def add(
|
||||||
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_strict_mode=} {playlist_item_limit=} {auto_start=}')
|
self,
|
||||||
|
url,
|
||||||
|
download_type,
|
||||||
|
codec,
|
||||||
|
format,
|
||||||
|
quality,
|
||||||
|
folder,
|
||||||
|
custom_name_prefix,
|
||||||
|
playlist_item_limit,
|
||||||
|
auto_start=True,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template=None,
|
||||||
|
subtitle_language="en",
|
||||||
|
subtitle_mode="prefer_manual",
|
||||||
|
already=None,
|
||||||
|
_add_gen=None,
|
||||||
|
):
|
||||||
|
log.info(
|
||||||
|
f'adding {url}: {download_type=} {codec=} {format=} {quality=} {already=} {folder=} {custom_name_prefix=} '
|
||||||
|
f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} '
|
||||||
|
f'{subtitle_language=} {subtitle_mode=}'
|
||||||
|
)
|
||||||
|
if already is None:
|
||||||
|
_add_gen = self._add_generation
|
||||||
|
self._canceled_urls.clear()
|
||||||
already = set() if already is None else already
|
already = set() if already is None else already
|
||||||
if url in already:
|
if url in already:
|
||||||
log.info('recursion detected, skipping')
|
log.info('recursion detected, skipping')
|
||||||
@@ -426,15 +928,31 @@ class DownloadQueue:
|
|||||||
else:
|
else:
|
||||||
already.add(url)
|
already.add(url)
|
||||||
try:
|
try:
|
||||||
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url, playlist_strict_mode)
|
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
|
||||||
except yt_dlp.utils.YoutubeDLError as exc:
|
except yt_dlp.utils.YoutubeDLError as exc:
|
||||||
return {'status': 'error', 'msg': str(exc)}
|
return {'status': 'error', 'msg': str(exc)}
|
||||||
return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start, already)
|
return await self.__add_entry(
|
||||||
|
entry,
|
||||||
|
download_type,
|
||||||
|
codec,
|
||||||
|
format,
|
||||||
|
quality,
|
||||||
|
folder,
|
||||||
|
custom_name_prefix,
|
||||||
|
playlist_item_limit,
|
||||||
|
auto_start,
|
||||||
|
split_by_chapters,
|
||||||
|
chapter_template,
|
||||||
|
subtitle_language,
|
||||||
|
subtitle_mode,
|
||||||
|
already,
|
||||||
|
_add_gen,
|
||||||
|
)
|
||||||
|
|
||||||
async def start_pending(self, ids):
|
async def start_pending(self, ids):
|
||||||
for id in ids:
|
for id in ids:
|
||||||
if not self.pending.exists(id):
|
if not self.pending.exists(id):
|
||||||
log.warn(f'requested start for non-existent download {id}')
|
log.warning(f'requested start for non-existent download {id}')
|
||||||
continue
|
continue
|
||||||
dl = self.pending.get(id)
|
dl = self.pending.get(id)
|
||||||
self.queue.put(dl)
|
self.queue.put(dl)
|
||||||
@@ -444,12 +962,14 @@ class DownloadQueue:
|
|||||||
|
|
||||||
async def cancel(self, ids):
|
async def cancel(self, ids):
|
||||||
for id in ids:
|
for id in ids:
|
||||||
|
# Track URL so playlist add loop won't re-queue it
|
||||||
|
self._canceled_urls.add(id)
|
||||||
if self.pending.exists(id):
|
if self.pending.exists(id):
|
||||||
self.pending.delete(id)
|
self.pending.delete(id)
|
||||||
await self.notifier.canceled(id)
|
await self.notifier.canceled(id)
|
||||||
continue
|
continue
|
||||||
if not self.queue.exists(id):
|
if not self.queue.exists(id):
|
||||||
log.warn(f'requested cancel for non-existent download {id}')
|
log.warning(f'requested cancel for non-existent download {id}')
|
||||||
continue
|
continue
|
||||||
if self.queue.get(id).started():
|
if self.queue.get(id).started():
|
||||||
self.queue.get(id).cancel()
|
self.queue.get(id).cancel()
|
||||||
@@ -461,15 +981,15 @@ class DownloadQueue:
|
|||||||
async def clear(self, ids):
|
async def clear(self, ids):
|
||||||
for id in ids:
|
for id in ids:
|
||||||
if not self.done.exists(id):
|
if not self.done.exists(id):
|
||||||
log.warn(f'requested delete for non-existent download {id}')
|
log.warning(f'requested delete for non-existent download {id}')
|
||||||
continue
|
continue
|
||||||
if self.config.DELETE_FILE_ON_TRASHCAN:
|
if self.config.DELETE_FILE_ON_TRASHCAN:
|
||||||
dl = self.done.get(id)
|
dl = self.done.get(id)
|
||||||
try:
|
try:
|
||||||
dldirectory, _ = self.__calc_download_path(dl.info.quality, dl.info.format, dl.info.folder)
|
dldirectory, _ = self.__calc_download_path(dl.info.download_type, dl.info.folder)
|
||||||
os.remove(os.path.join(dldirectory, dl.info.filename))
|
os.remove(os.path.join(dldirectory, dl.info.filename))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warn(f'deleting file for download {id} failed with error message {e!r}')
|
log.warning(f'deleting file for download {id} failed with error message {e!r}')
|
||||||
self.done.delete(id)
|
self.done.delete(id)
|
||||||
await self.notifier.cleared(id)
|
await self.notifier.cleared(id)
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
|
|||||||
+15
-6
@@ -1,19 +1,28 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
|
PUID="${UID:-$PUID}"
|
||||||
|
PGID="${GID:-$PGID}"
|
||||||
|
|
||||||
echo "Setting umask to ${UMASK}"
|
echo "Setting umask to ${UMASK}"
|
||||||
umask ${UMASK}
|
umask ${UMASK}
|
||||||
echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})"
|
echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})"
|
||||||
mkdir -p "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
mkdir -p "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
||||||
|
|
||||||
if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
|
if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then
|
||||||
if [ "${UID}" -eq 0 ]; then
|
if [ "${PUID}" -eq 0 ]; then
|
||||||
echo "Warning: it is not recommended to run as root user, please check your setting of the UID environment variable"
|
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
|
fi
|
||||||
echo "Changing ownership of download and state directories to ${UID}:${GID}"
|
if [ "${CHOWN_DIRS:-true}" != "false" ]; then
|
||||||
chown -R "${UID}":"${GID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
echo "Changing ownership of download and state directories to ${PUID}:${PGID}"
|
||||||
echo "Running MeTube as user ${UID}:${GID}"
|
chown -R "${PUID}":"${PGID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
||||||
exec su-exec "${UID}":"${GID}" python3 app/main.py
|
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
|
else
|
||||||
echo "User set by docker; running MeTube as `id -u`:`id -g`"
|
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
|
exec python3 app/main.py
|
||||||
fi
|
fi
|
||||||
|
|||||||
+10
-1
@@ -6,7 +6,7 @@ requires-python = ">=3.13"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
"python-socketio>=5.0,<6.0",
|
"python-socketio>=5.0,<6.0",
|
||||||
"yt-dlp[default,curl-cffi]",
|
"yt-dlp[default,curl-cffi,deno]",
|
||||||
"mutagen",
|
"mutagen",
|
||||||
"curl-cffi",
|
"curl-cffi",
|
||||||
"watchfiles",
|
"watchfiles",
|
||||||
@@ -15,4 +15,13 @@ dependencies = [
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pylint",
|
"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"
|
||||||
|
|||||||
+27
-65
@@ -15,17 +15,13 @@
|
|||||||
"prefix": "app",
|
"prefix": "app",
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:application",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": {
|
"outputPath": {
|
||||||
"base": "dist/metube"
|
"base": "dist/metube"
|
||||||
},
|
},
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"polyfills": [
|
|
||||||
"src/polyfills.ts"
|
|
||||||
],
|
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"aot": true,
|
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/favicon.ico",
|
"src/favicon.ico",
|
||||||
"src/assets",
|
"src/assets",
|
||||||
@@ -37,21 +33,16 @@
|
|||||||
"node_modules/@ng-select/ng-select/themes/default.theme.css",
|
"node_modules/@ng-select/ng-select/themes/default.theme.css",
|
||||||
"src/styles.sass"
|
"src/styles.sass"
|
||||||
],
|
],
|
||||||
"scripts": [
|
"scripts": [],
|
||||||
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
|
|
||||||
],
|
|
||||||
"serviceWorker": "ngsw-config.json",
|
"serviceWorker": "ngsw-config.json",
|
||||||
"browser": "src/main.ts"
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js",
|
||||||
|
"@angular/localize/init"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"fileReplacements": [
|
|
||||||
{
|
|
||||||
"replace": "src/environments/environment.ts",
|
|
||||||
"with": "src/environments/environment.prod.ts"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"optimization": true,
|
|
||||||
"outputHashing": "all",
|
"outputHashing": "all",
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"namedChunks": false,
|
"namedChunks": false,
|
||||||
@@ -68,75 +59,46 @@
|
|||||||
"maximumError": "10kb"
|
"maximumError": "10kb"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
"options": {
|
|
||||||
"buildTarget": "metube:build"
|
|
||||||
},
|
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "metube:build:production"
|
"buildTarget": "metube:build:production"
|
||||||
}
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "metube:build:development",
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"defaultConfiguration": "development"
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
|
||||||
"options": {
|
|
||||||
"buildTarget": "metube:build"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular-devkit/build-angular:karma",
|
"builder": "@angular/build:unit-test"
|
||||||
"options": {
|
|
||||||
"main": "src/test.ts",
|
|
||||||
"polyfills": "src/polyfills.ts",
|
|
||||||
"tsConfig": "tsconfig.spec.json",
|
|
||||||
"karmaConfig": "karma.conf.js",
|
|
||||||
"assets": [
|
|
||||||
"src/favicon.ico",
|
|
||||||
"src/assets",
|
|
||||||
"src/manifest.webmanifest",
|
|
||||||
"src/custom-service-worker.js"
|
|
||||||
],
|
|
||||||
"styles": [
|
|
||||||
"src/styles.sass"
|
|
||||||
],
|
|
||||||
"scripts": []
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"builder": "@angular-devkit/build-angular:tslint",
|
"builder": "@angular-eslint/builder:lint",
|
||||||
"options": {
|
"options": {
|
||||||
"tsConfig": [
|
"lintFilePatterns": [
|
||||||
"tsconfig.app.json",
|
"src/**/*.ts",
|
||||||
"tsconfig.spec.json",
|
"src/**/*.html"
|
||||||
"e2e/tsconfig.json"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"**/node_modules/**"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"e2e": {
|
|
||||||
"builder": "@angular-devkit/build-angular:protractor",
|
|
||||||
"options": {
|
|
||||||
"protractorConfig": "e2e/protractor.conf.js",
|
|
||||||
"devServerTarget": "metube:serve"
|
|
||||||
},
|
|
||||||
"configurations": {
|
|
||||||
"production": {
|
|
||||||
"devServerTarget": "metube:serve:production"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cli": {
|
"cli": {
|
||||||
"analytics": false
|
"analytics": false,
|
||||||
|
"packageManager": "pnpm"
|
||||||
},
|
},
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@schematics/angular:component": {
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
// Protractor configuration file, see link for more information
|
|
||||||
// https://github.com/angular/protractor/blob/master/lib/config.ts
|
|
||||||
|
|
||||||
const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type { import("protractor").Config }
|
|
||||||
*/
|
|
||||||
exports.config = {
|
|
||||||
allScriptsTimeout: 11000,
|
|
||||||
specs: [
|
|
||||||
'./src/**/*.e2e-spec.ts'
|
|
||||||
],
|
|
||||||
capabilities: {
|
|
||||||
browserName: 'chrome'
|
|
||||||
},
|
|
||||||
directConnect: true,
|
|
||||||
baseUrl: 'http://localhost:4200/',
|
|
||||||
framework: 'jasmine',
|
|
||||||
jasmineNodeOpts: {
|
|
||||||
showColors: true,
|
|
||||||
defaultTimeoutInterval: 30000,
|
|
||||||
print: function() {}
|
|
||||||
},
|
|
||||||
onPrepare() {
|
|
||||||
require('ts-node').register({
|
|
||||||
project: require('path').join(__dirname, './tsconfig.json')
|
|
||||||
});
|
|
||||||
jasmine.getEnv().addReporter(new SpecReporter({
|
|
||||||
spec: {
|
|
||||||
displayStacktrace: StacktraceOption.PRETTY
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { AppPage } from './app.po';
|
|
||||||
import { browser, logging } from 'protractor';
|
|
||||||
|
|
||||||
describe('workspace-project App', () => {
|
|
||||||
let page: AppPage;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
page = new AppPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display welcome message', () => {
|
|
||||||
page.navigateTo();
|
|
||||||
expect(page.getTitleText()).toEqual('metube app is running!');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
// Assert that there are no errors emitted from the browser
|
|
||||||
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
|
|
||||||
expect(logs).not.toContain(jasmine.objectContaining({
|
|
||||||
level: logging.Level.SEVERE,
|
|
||||||
} as logging.Entry));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { browser, by, element } from 'protractor';
|
|
||||||
|
|
||||||
export class AppPage {
|
|
||||||
navigateTo(): Promise<unknown> {
|
|
||||||
return browser.get(browser.baseUrl) as Promise<unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTitleText(): Promise<string> {
|
|
||||||
return element(by.css('app-root .content span')).getText() as Promise<string>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
|
||||||
{
|
|
||||||
"extends": "../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "../out-tsc/e2e",
|
|
||||||
"module": "commonjs",
|
|
||||||
"target": "es2018",
|
|
||||||
"types": [
|
|
||||||
"jasmine",
|
|
||||||
"jasminewd2",
|
|
||||||
"node"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// @ts-check
|
||||||
|
const eslint = require("@eslint/js");
|
||||||
|
const { defineConfig } = require("eslint/config");
|
||||||
|
const tseslint = require("typescript-eslint");
|
||||||
|
const angular = require("angular-eslint");
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
extends: [
|
||||||
|
eslint.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
tseslint.configs.stylistic,
|
||||||
|
angular.configs.tsRecommended,
|
||||||
|
],
|
||||||
|
processor: angular.processInlineTemplates,
|
||||||
|
rules: {
|
||||||
|
"@angular-eslint/directive-selector": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
type: "attribute",
|
||||||
|
prefix: "app",
|
||||||
|
style: "camelCase",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"@angular-eslint/component-selector": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
type: "element",
|
||||||
|
prefix: "app",
|
||||||
|
style: "kebab-case",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.html"],
|
||||||
|
extends: [
|
||||||
|
angular.configs.templateRecommended,
|
||||||
|
angular.configs.templateAccessibility,
|
||||||
|
],
|
||||||
|
rules: {},
|
||||||
|
}
|
||||||
|
]);
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Karma configuration file, see link for more information
|
|
||||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
|
||||||
|
|
||||||
module.exports = function (config) {
|
|
||||||
config.set({
|
|
||||||
basePath: '',
|
|
||||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
|
||||||
plugins: [
|
|
||||||
require('karma-jasmine'),
|
|
||||||
require('karma-chrome-launcher'),
|
|
||||||
require('karma-jasmine-html-reporter'),
|
|
||||||
require('karma-coverage-istanbul-reporter'),
|
|
||||||
require('@angular-devkit/build-angular/plugins/karma')
|
|
||||||
],
|
|
||||||
client: {
|
|
||||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
|
||||||
},
|
|
||||||
coverageIstanbulReporter: {
|
|
||||||
dir: require('path').join(__dirname, './coverage/metube'),
|
|
||||||
reports: ['html', 'lcovonly', 'text-summary'],
|
|
||||||
fixWebpackSourcePaths: true
|
|
||||||
},
|
|
||||||
reporters: ['progress', 'kjhtml'],
|
|
||||||
port: 9876,
|
|
||||||
colors: true,
|
|
||||||
logLevel: config.LOG_INFO,
|
|
||||||
autoWatch: true,
|
|
||||||
browsers: ['Chrome'],
|
|
||||||
singleRun: false,
|
|
||||||
restartOnFileChange: true
|
|
||||||
});
|
|
||||||
};
|
|
||||||
Generated
-14890
File diff suppressed because it is too large
Load Diff
+47
-32
@@ -5,44 +5,59 @@
|
|||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
|
"build:watch": "ng build --watch",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"lint": "ng lint",
|
"lint": "ng lint"
|
||||||
"e2e": "ng e2e"
|
},
|
||||||
|
"prettier": {
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^20.3.15",
|
"@angular/animations": "^21.2.5",
|
||||||
"@angular/common": "^20.3.15",
|
"@angular/common": "^21.2.5",
|
||||||
"@angular/compiler": "^20.3.15",
|
"@angular/compiler": "^21.2.5",
|
||||||
"@angular/core": "^20.3.15",
|
"@angular/core": "^21.2.5",
|
||||||
"@angular/forms": "^20.3.15",
|
"@angular/forms": "^21.2.5",
|
||||||
"@angular/localize": "^20.3.15",
|
"@angular/platform-browser": "^21.2.5",
|
||||||
"@angular/platform-browser": "^20.3.15",
|
"@angular/platform-browser-dynamic": "^21.2.5",
|
||||||
"@angular/platform-browser-dynamic": "^20.3.15",
|
"@angular/service-worker": "^21.2.5",
|
||||||
"@angular/router": "^20.3.15",
|
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||||
"@angular/service-worker": "^20.3.15",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/angular-fontawesome": "~3.0.0",
|
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@ng-select/ng-select": "^21.5.2",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^19.0.0",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@ng-select/ng-select": "^20.0.0",
|
"bootstrap": "^5.3.8",
|
||||||
"bootstrap": "^5.3.6",
|
"ngx-cookie-service": "^21.3.1",
|
||||||
"ngx-cookie-service": "^20.0.0",
|
"ngx-socket-io": "~4.10.0",
|
||||||
"ngx-socket-io": "~4.9.0",
|
"rxjs": "~7.8.2",
|
||||||
"rxjs": "~7.8.0",
|
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"zone.js": "~0.15.1"
|
"zone.js": "0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^20.3.13",
|
"@angular-eslint/builder": "21.1.0",
|
||||||
"@angular/cli": "^20.3.13",
|
"@angular/build": "^21.2.3",
|
||||||
"@angular/compiler-cli": "^20.3.15",
|
"@angular/cli": "^21.2.3",
|
||||||
"@types/node": "^22.15.29",
|
"@angular/compiler-cli": "^21.2.5",
|
||||||
"codelyzer": "^6.0.2",
|
"@angular/localize": "^21.2.5",
|
||||||
"ts-node": "~10.9.1",
|
"@eslint/js": "^9.39.4",
|
||||||
"tslint": "~6.1.3",
|
"angular-eslint": "21.1.0",
|
||||||
"typescript": "~5.8.3"
|
"eslint": "^9.39.4",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "8.47.0",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+6836
File diff suppressed because it is too large
Load Diff
@@ -1,402 +0,0 @@
|
|||||||
<nav class="navbar navbar-expand-md navbar-dark">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand d-flex align-items-center" href="#">
|
|
||||||
<img src="assets/icons/android-chrome-192x192.png" alt="MeTube Logo" height="32" class="me-2">
|
|
||||||
MeTube
|
|
||||||
</a>
|
|
||||||
<div class="download-metrics">
|
|
||||||
<div class="metric" *ngIf="activeDownloads > 0">
|
|
||||||
<fa-icon [icon]="faDownload" class="text-primary"></fa-icon>
|
|
||||||
<span>{{activeDownloads}} downloading</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric" *ngIf="queuedDownloads > 0">
|
|
||||||
<fa-icon [icon]="faClock" class="text-warning"></fa-icon>
|
|
||||||
<span>{{queuedDownloads}} queued</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric" *ngIf="completedDownloads > 0">
|
|
||||||
<fa-icon [icon]="faCheck" class="text-success"></fa-icon>
|
|
||||||
<span>{{completedDownloads}} completed</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric" *ngIf="failedDownloads > 0">
|
|
||||||
<fa-icon [icon]="faTimesCircle" class="text-danger"></fa-icon>
|
|
||||||
<span>{{failedDownloads}} failed</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric" *ngIf="(totalSpeed | speed) !== ''">
|
|
||||||
<fa-icon [icon]="faTachometerAlt" class="text-info"></fa-icon>
|
|
||||||
<span>{{totalSpeed | speed }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!--
|
|
||||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsDefault" aria-controls="navbarsDefault" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
<div class="collapse navbar-collapse" id="navbarsDefault">
|
|
||||||
<ul class="navbar-nav mr-auto">
|
|
||||||
<li class="nav-item active">
|
|
||||||
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
-->
|
|
||||||
<div class="navbar-nav ms-auto">
|
|
||||||
<div class="nav-item dropdown">
|
|
||||||
<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">
|
|
||||||
<fa-icon [icon]="activeTheme.icon"></fa-icon>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select">
|
|
||||||
<li *ngFor="let theme of themes">
|
|
||||||
<button type="button" class="dropdown-item d-flex align-items-center" [ngClass]="{'active' : activeTheme == theme}" (click)="themeChanged(theme)">
|
|
||||||
<span class="me-2 opacity-50">
|
|
||||||
<fa-icon [icon]="theme.icon"></fa-icon>
|
|
||||||
</span>
|
|
||||||
{{ theme.displayName }}
|
|
||||||
<span class="ms-auto" [ngClass]="{'d-none' : activeTheme != theme}">
|
|
||||||
<fa-icon [icon]="faCheck"></fa-icon>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main role="main" class="container container-xl">
|
|
||||||
<form #f="ngForm">
|
|
||||||
<div class="container add-url-box">
|
|
||||||
<!-- Main URL Input with Download Button -->
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col">
|
|
||||||
<div class="input-group input-group-lg shadow-sm">
|
|
||||||
<input type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
class="form-control form-control-lg"
|
|
||||||
placeholder="Enter video or playlist URL"
|
|
||||||
name="addUrl"
|
|
||||||
[(ngModel)]="addUrl"
|
|
||||||
[disabled]="addInProgress || downloads.loading">
|
|
||||||
<button class="btn btn-primary btn-lg px-4"
|
|
||||||
type="submit"
|
|
||||||
(click)="addDownload()"
|
|
||||||
[disabled]="addInProgress || downloads.loading">
|
|
||||||
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner" *ngIf="addInProgress"></span>
|
|
||||||
{{ addInProgress ? "Adding..." : "Download" }}
|
|
||||||
</button>
|
|
||||||
</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">
|
|
||||||
<option *ngFor="let q of qualities" [ngValue]="q.id">{{ q.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 || downloads.loading">
|
|
||||||
<option *ngFor="let f of formats" [ngValue]="f.id">{{ f.text }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-outline-secondary w-100 h-100"
|
|
||||||
(click)="toggleAdvanced()">
|
|
||||||
Advanced Options
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Advanced Options Panel -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="collapse show" id="advancedOptions" [ngbCollapse]="!isAdvancedOpen">
|
|
||||||
<div class="card card-body">
|
|
||||||
<!-- Advanced Settings -->
|
|
||||||
<div class="row g-3 mb-2">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text">Auto Start</span>
|
|
||||||
<select class="form-select"
|
|
||||||
name="autoStart"
|
|
||||||
[(ngModel)]="autoStart"
|
|
||||||
(change)="autoStartChanged()"
|
|
||||||
[disabled]="addInProgress || downloads.loading"
|
|
||||||
ngbTooltip="Automatically start downloads when added">
|
|
||||||
<option [ngValue]="true">Yes</option>
|
|
||||||
<option [ngValue]="false">No</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text">Download Folder</span>
|
|
||||||
<ng-select [items]="customDirs$ | async"
|
|
||||||
placeholder="Default"
|
|
||||||
[addTag]="allowCustomDir.bind(this)"
|
|
||||||
addTagText="Create directory"
|
|
||||||
bindLabel="folder"
|
|
||||||
[(ngModel)]="folder"
|
|
||||||
[disabled]="addInProgress || downloads.loading"
|
|
||||||
[virtualScroll]="true"
|
|
||||||
[clearable]="true"
|
|
||||||
[loading]="downloads.loading"
|
|
||||||
[searchable]="true"
|
|
||||||
[closeOnSelect]="true"
|
|
||||||
ngbTooltip="Choose where to save downloads. Type to create a new folder.">
|
|
||||||
</ng-select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text">Custom Name Prefix</span>
|
|
||||||
<input type="text"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="Default"
|
|
||||||
name="customNamePrefix"
|
|
||||||
[(ngModel)]="customNamePrefix"
|
|
||||||
[disabled]="addInProgress || downloads.loading"
|
|
||||||
ngbTooltip="Add a prefix to downloaded filenames">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text">Items Limit</span>
|
|
||||||
<input type="number"
|
|
||||||
min="0"
|
|
||||||
class="form-control"
|
|
||||||
placeholder="Default"
|
|
||||||
name="playlistItemLimit"
|
|
||||||
(keydown)="isNumber($event)"
|
|
||||||
[(ngModel)]="playlistItemLimit"
|
|
||||||
[disabled]="addInProgress || downloads.loading"
|
|
||||||
ngbTooltip="Maximum number of items to download from a playlist (0 = no limit)">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
role="switch"
|
|
||||||
name="playlistStrictMode"
|
|
||||||
[(ngModel)]="playlistStrictMode"
|
|
||||||
[disabled]="addInProgress || downloads.loading"
|
|
||||||
ngbTooltip="Only download playlists when URL explicitly points to a playlist">
|
|
||||||
<label class="form-check-label">Strict Playlist Mode</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Advanced Actions -->
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<hr class="my-3">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-secondary w-100"
|
|
||||||
(click)="openBatchImportModal()">
|
|
||||||
<fa-icon [icon]="faFileImport" class="me-2"></fa-icon>
|
|
||||||
Import URLs
|
|
||||||
</button>
|
|
||||||
</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"></fa-icon>
|
|
||||||
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"></fa-icon>
|
|
||||||
Copy URLs
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Batch Import Modal -->
|
|
||||||
<div class="modal fade" tabindex="-1" role="dialog" [ngClass]="{'show': batchImportModalOpen}" [ngStyle]="{'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>
|
|
||||||
<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"
|
|
||||||
placeholder="Paste one video URL per line"></textarea>
|
|
||||||
<div class="mt-2">
|
|
||||||
<small *ngIf="batchImportStatus">{{ batchImportStatus }}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-danger me-auto" *ngIf="importInProgress" (click)="cancelBatchImport()">
|
|
||||||
Cancel Import
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button>
|
|
||||||
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">
|
|
||||||
Import URLs
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div *ngIf="downloads.loading" class="alert alert-info" role="alert">
|
|
||||||
Connecting to server...
|
|
||||||
</div>
|
|
||||||
<div class="metube-section-header">Downloading</div>
|
|
||||||
<div class="px-2 py-3 border-bottom">
|
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt"></fa-icon> Cancel selected</button>
|
|
||||||
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload"></fa-icon> Download selected</button>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-auto">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col" style="width: 1rem;">
|
|
||||||
<app-master-checkbox #queueMasterCheckbox [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)"></app-master-checkbox>
|
|
||||||
</th>
|
|
||||||
<th scope="col">Video</th>
|
|
||||||
<th scope="col" style="width: 8rem;">Speed</th>
|
|
||||||
<th scope="col" style="width: 7rem;">ETA</th>
|
|
||||||
<th scope="col" style="width: 6rem;"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let download of downloads.queue | keyvalue: asIsOrder; trackBy: identifyDownloadRow" [class.disabled]='download.value.deleting'>
|
|
||||||
<td>
|
|
||||||
<app-slave-checkbox [id]="download.key" [master]="queueMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
|
|
||||||
</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" [value]="download.value.status == 'preparing' ? 100 : download.value.percent | number:'1.0-0'" class="download-progressbar"></ngb-progressbar>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{{ download.value.speed | speed }}</td>
|
|
||||||
<td>{{ download.value.eta | eta }}</td>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex">
|
|
||||||
<button *ngIf="download.value.status === 'pending'" type="button" class="btn btn-link" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload"></fa-icon></button>
|
|
||||||
<button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
|
|
||||||
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt"></fa-icon> 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"></fa-icon> 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"></fa-icon> 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"></fa-icon> 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"></fa-icon> Download Selected</button>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-auto">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col" style="width: 1rem;">
|
|
||||||
<app-master-checkbox #doneMasterCheckbox [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)"></app-master-checkbox>
|
|
||||||
</th>
|
|
||||||
<th scope="col">Video</th>
|
|
||||||
<th scope="col">File Size</th>
|
|
||||||
<th scope="col" style="width: 8rem;"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let download of downloads.done | keyvalue: asIsOrder; trackBy: identifyDownloadRow" [class.disabled]='download.value.deleting'>
|
|
||||||
<td>
|
|
||||||
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div style="display: inline-block; width: 1.5rem;">
|
|
||||||
<fa-icon *ngIf="download.value.status == 'finished'" [icon]="faCheckCircle" class="text-success"></fa-icon>
|
|
||||||
<fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" class="text-danger"></fa-icon>
|
|
||||||
</div>
|
|
||||||
<span ngbTooltip="{{download.value.msg}} | {{download.value.error}}"><a *ngIf="!!download.value.filename; else noDownloadLink" href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a></span>
|
|
||||||
<ng-template #noDownloadLink>
|
|
||||||
{{download.value.title}}
|
|
||||||
<span *ngIf="download.value.msg"><br>{{download.value.msg}}</span>
|
|
||||||
<span *ngIf="download.value.error"><br>Error: {{download.value.error}}</span>
|
|
||||||
</ng-template>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span *ngIf="download.value.size">{{ download.value.size | fileSize }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex">
|
|
||||||
<button *ngIf="download.value.status == 'error'" type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt"></fa-icon></button>
|
|
||||||
<a *ngIf="download.value.filename" href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload"></fa-icon></a>
|
|
||||||
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt"></fa-icon></a>
|
|
||||||
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</main><!-- /.container -->
|
|
||||||
|
|
||||||
<footer class="footer navbar-dark bg-dark py-3 mt-5">
|
|
||||||
<div class="container text-center">
|
|
||||||
<div class="footer-content" *ngIf="ytDlpVersion && metubeVersion">
|
|
||||||
<div class="version-item">
|
|
||||||
<span class="version-label">yt-dlp</span>
|
|
||||||
<span class="version-value">{{ytDlpVersion}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="version-separator"></div>
|
|
||||||
<div class="version-item">
|
|
||||||
<span class="version-label">MeTube</span>
|
|
||||||
<span class="version-value">{{metubeVersion}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="version-separator"></div>
|
|
||||||
<div class="version-item" *ngIf="ytDlpOptionsUpdateTime">
|
|
||||||
<span class="version-label">yt-dlp-options</span>
|
|
||||||
<span class="version-value">{{ytDlpOptionsUpdateTime}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="version-separator" *ngIf="ytDlpOptionsUpdateTime"></div>
|
|
||||||
<a href="https://github.com/alexta69/metube" target="_blank" class="github-link">
|
|
||||||
<fa-icon [icon]="faGithub"></fa-icon>
|
|
||||||
<span>GitHub</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { AppComponent } from './app.component';
|
|
||||||
|
|
||||||
describe('AppComponent', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
declarations: [
|
|
||||||
AppComponent
|
|
||||||
],
|
|
||||||
}).compileComponents();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create the app', () => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
const app = fixture.componentInstance;
|
|
||||||
expect(app).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`should have as title 'metube'`, () => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
const app = fixture.componentInstance;
|
|
||||||
expect(app.title).toEqual('metube');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render title', () => {
|
|
||||||
const fixture = TestBed.createComponent(AppComponent);
|
|
||||||
fixture.detectChanges();
|
|
||||||
const compiled = fixture.nativeElement;
|
|
||||||
expect(compiled.querySelector('.content span').textContent).toContain('metube app is running!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,519 +0,0 @@
|
|||||||
import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { faTrashAlt, faCheckCircle, faTimesCircle, IconDefinition } from '@fortawesome/free-regular-svg-icons';
|
|
||||||
import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
|
||||||
import { CookieService } from 'ngx-cookie-service';
|
|
||||||
import { map, Observable, of, distinctUntilChanged } from 'rxjs';
|
|
||||||
|
|
||||||
import { Download, DownloadsService, Status } from './downloads.service';
|
|
||||||
import { MasterCheckboxComponent } from './master-checkbox.component';
|
|
||||||
import { Formats, Format, Quality } from './formats';
|
|
||||||
import { Theme, Themes } from './theme';
|
|
||||||
import {KeyValue} from "@angular/common";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-root',
|
|
||||||
templateUrl: './app.component.html',
|
|
||||||
styleUrls: ['./app.component.sass'],
|
|
||||||
standalone: false
|
|
||||||
})
|
|
||||||
export class AppComponent implements AfterViewInit {
|
|
||||||
addUrl: string;
|
|
||||||
formats: Format[] = Formats;
|
|
||||||
qualities: Quality[];
|
|
||||||
quality: string;
|
|
||||||
format: string;
|
|
||||||
folder: string;
|
|
||||||
customNamePrefix: string;
|
|
||||||
autoStart: boolean;
|
|
||||||
playlistStrictMode: boolean;
|
|
||||||
playlistItemLimit: number;
|
|
||||||
addInProgress = false;
|
|
||||||
themes: Theme[] = Themes;
|
|
||||||
activeTheme: Theme;
|
|
||||||
customDirs$: Observable<string[]>;
|
|
||||||
showBatchPanel: boolean = false;
|
|
||||||
batchImportModalOpen = false;
|
|
||||||
batchImportText = '';
|
|
||||||
batchImportStatus = '';
|
|
||||||
importInProgress = false;
|
|
||||||
cancelImportFlag = false;
|
|
||||||
ytDlpOptionsUpdateTime: string | null = null;
|
|
||||||
ytDlpVersion: string | null = null;
|
|
||||||
metubeVersion: string | null = null;
|
|
||||||
isAdvancedOpen = false;
|
|
||||||
|
|
||||||
// Download metrics
|
|
||||||
activeDownloads = 0;
|
|
||||||
queuedDownloads = 0;
|
|
||||||
completedDownloads = 0;
|
|
||||||
failedDownloads = 0;
|
|
||||||
totalSpeed = 0;
|
|
||||||
|
|
||||||
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
|
|
||||||
@ViewChild('queueDelSelected') queueDelSelected: ElementRef;
|
|
||||||
@ViewChild('queueDownloadSelected') queueDownloadSelected: ElementRef;
|
|
||||||
@ViewChild('doneMasterCheckbox') doneMasterCheckbox: MasterCheckboxComponent;
|
|
||||||
@ViewChild('doneDelSelected') doneDelSelected: ElementRef;
|
|
||||||
@ViewChild('doneClearCompleted') doneClearCompleted: ElementRef;
|
|
||||||
@ViewChild('doneClearFailed') doneClearFailed: ElementRef;
|
|
||||||
@ViewChild('doneRetryFailed') doneRetryFailed: ElementRef;
|
|
||||||
@ViewChild('doneDownloadSelected') doneDownloadSelected: ElementRef;
|
|
||||||
|
|
||||||
faTrashAlt = faTrashAlt;
|
|
||||||
faCheckCircle = faCheckCircle;
|
|
||||||
faTimesCircle = faTimesCircle;
|
|
||||||
faRedoAlt = faRedoAlt;
|
|
||||||
faSun = faSun;
|
|
||||||
faMoon = faMoon;
|
|
||||||
faCheck = faCheck;
|
|
||||||
faCircleHalfStroke = faCircleHalfStroke;
|
|
||||||
faDownload = faDownload;
|
|
||||||
faExternalLinkAlt = faExternalLinkAlt;
|
|
||||||
faFileImport = faFileImport;
|
|
||||||
faFileExport = faFileExport;
|
|
||||||
faCopy = faCopy;
|
|
||||||
faGithub = faGithub;
|
|
||||||
faClock = faClock;
|
|
||||||
faTachometerAlt = faTachometerAlt;
|
|
||||||
|
|
||||||
constructor(public downloads: DownloadsService, private cookieService: CookieService, private http: HttpClient) {
|
|
||||||
this.format = cookieService.get('metube_format') || 'any';
|
|
||||||
// Needs to be set or qualities won't automatically be set
|
|
||||||
this.setQualities()
|
|
||||||
this.quality = cookieService.get('metube_quality') || 'best';
|
|
||||||
this.autoStart = cookieService.get('metube_auto_start') !== 'false';
|
|
||||||
|
|
||||||
this.activeTheme = this.getPreferredTheme(cookieService);
|
|
||||||
|
|
||||||
// Subscribe to download updates
|
|
||||||
this.downloads.queueChanged.subscribe(() => {
|
|
||||||
this.updateMetrics();
|
|
||||||
});
|
|
||||||
this.downloads.doneChanged.subscribe(() => {
|
|
||||||
this.updateMetrics();
|
|
||||||
});
|
|
||||||
// Subscribe to real-time updates
|
|
||||||
this.downloads.updated.subscribe(() => {
|
|
||||||
this.updateMetrics();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
this.getConfiguration();
|
|
||||||
this.getYtdlOptionsUpdateTime();
|
|
||||||
this.customDirs$ = this.getMatchingCustomDir();
|
|
||||||
this.setTheme(this.activeTheme);
|
|
||||||
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
||||||
if (this.activeTheme.id === 'auto') {
|
|
||||||
this.setTheme(this.activeTheme);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewInit() {
|
|
||||||
this.downloads.queueChanged.subscribe(() => {
|
|
||||||
this.queueMasterCheckbox.selectionChanged();
|
|
||||||
});
|
|
||||||
this.downloads.doneChanged.subscribe(() => {
|
|
||||||
this.doneMasterCheckbox.selectionChanged();
|
|
||||||
let completed: number = 0, failed: number = 0;
|
|
||||||
this.downloads.done.forEach(dl => {
|
|
||||||
if (dl.status === 'finished')
|
|
||||||
completed++;
|
|
||||||
else if (dl.status === 'error')
|
|
||||||
failed++;
|
|
||||||
});
|
|
||||||
this.doneClearCompleted.nativeElement.disabled = completed === 0;
|
|
||||||
this.doneClearFailed.nativeElement.disabled = failed === 0;
|
|
||||||
this.doneRetryFailed.nativeElement.disabled = failed === 0;
|
|
||||||
});
|
|
||||||
this.fetchVersionInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
// workaround to allow fetching of Map values in the order they were inserted
|
|
||||||
// https://github.com/angular/angular/issues/31420
|
|
||||||
asIsOrder(a, b) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
qualityChanged() {
|
|
||||||
this.cookieService.set('metube_quality', this.quality, { expires: 3650 });
|
|
||||||
// Re-trigger custom directory change
|
|
||||||
this.downloads.customDirsChanged.next(this.downloads.customDirs);
|
|
||||||
}
|
|
||||||
|
|
||||||
showAdvanced() {
|
|
||||||
return this.downloads.configuration['CUSTOM_DIRS'];
|
|
||||||
}
|
|
||||||
|
|
||||||
allowCustomDir(tag: string) {
|
|
||||||
if (this.downloads.configuration['CREATE_CUSTOM_DIRS']) {
|
|
||||||
return tag;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
isAudioType() {
|
|
||||||
return this.quality == 'audio' || this.format == 'mp3' || this.format == 'm4a' || this.format == 'opus' || this.format == 'wav' || this.format == 'flac';
|
|
||||||
}
|
|
||||||
|
|
||||||
getMatchingCustomDir() : Observable<string[]> {
|
|
||||||
return this.downloads.customDirsChanged.asObservable().pipe(
|
|
||||||
map((output) => {
|
|
||||||
// Keep logic consistent with app/ytdl.py
|
|
||||||
if (this.isAudioType()) {
|
|
||||||
console.debug("Showing audio-specific download directories");
|
|
||||||
return output["audio_download_dir"];
|
|
||||||
} else {
|
|
||||||
console.debug("Showing default download directories");
|
|
||||||
return output["download_dir"];
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
distinctUntilChanged((prev, curr) => JSON.stringify(prev) === JSON.stringify(curr))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getYtdlOptionsUpdateTime() {
|
|
||||||
this.downloads.ytdlOptionsChanged.subscribe({
|
|
||||||
next: (data) => {
|
|
||||||
if (data['success']){
|
|
||||||
const date = new Date(data['update_time'] * 1000);
|
|
||||||
this.ytDlpOptionsUpdateTime=date.toLocaleString();
|
|
||||||
}else{
|
|
||||||
alert("Error reload yt-dlp options: "+data['msg']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
getConfiguration() {
|
|
||||||
this.downloads.configurationChanged.subscribe({
|
|
||||||
next: (config) => {
|
|
||||||
this.playlistStrictMode = config['DEFAULT_OPTION_PLAYLIST_STRICT_MODE'];
|
|
||||||
const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
|
|
||||||
if (playlistItemLimit !== '0') {
|
|
||||||
this.playlistItemLimit = playlistItemLimit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getPreferredTheme(cookieService: CookieService) {
|
|
||||||
let theme = 'auto';
|
|
||||||
if (cookieService.check('metube_theme')) {
|
|
||||||
theme = cookieService.get('metube_theme');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.themes.find(x => x.id === theme) ?? this.themes.find(x => x.id === 'auto');
|
|
||||||
}
|
|
||||||
|
|
||||||
themeChanged(theme: Theme) {
|
|
||||||
this.cookieService.set('metube_theme', theme.id, { expires: 3650 });
|
|
||||||
this.setTheme(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTheme(theme: Theme) {
|
|
||||||
this.activeTheme = theme;
|
|
||||||
if (theme.id === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
||||||
document.documentElement.setAttribute('data-bs-theme', 'dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.setAttribute('data-bs-theme', theme.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formatChanged() {
|
|
||||||
this.cookieService.set('metube_format', this.format, { expires: 3650 });
|
|
||||||
// Updates to use qualities available
|
|
||||||
this.setQualities()
|
|
||||||
// Re-trigger custom directory change
|
|
||||||
this.downloads.customDirsChanged.next(this.downloads.customDirs);
|
|
||||||
}
|
|
||||||
|
|
||||||
autoStartChanged() {
|
|
||||||
this.cookieService.set('metube_auto_start', this.autoStart ? 'true' : 'false', { expires: 3650 });
|
|
||||||
}
|
|
||||||
|
|
||||||
queueSelectionChanged(checked: number) {
|
|
||||||
this.queueDelSelected.nativeElement.disabled = checked == 0;
|
|
||||||
this.queueDownloadSelected.nativeElement.disabled = checked == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
doneSelectionChanged(checked: number) {
|
|
||||||
this.doneDelSelected.nativeElement.disabled = checked == 0;
|
|
||||||
this.doneDownloadSelected.nativeElement.disabled = checked == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
setQualities() {
|
|
||||||
// qualities for specific format
|
|
||||||
this.qualities = this.formats.find(el => el.id == this.format).qualities
|
|
||||||
const exists = this.qualities.find(el => el.id === this.quality)
|
|
||||||
this.quality = exists ? this.quality : 'best'
|
|
||||||
}
|
|
||||||
|
|
||||||
addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, playlistStrictMode?: boolean, playlistItemLimit?: number, autoStart?: boolean) {
|
|
||||||
url = url ?? this.addUrl
|
|
||||||
quality = quality ?? this.quality
|
|
||||||
format = format ?? this.format
|
|
||||||
folder = folder ?? this.folder
|
|
||||||
customNamePrefix = customNamePrefix ?? this.customNamePrefix
|
|
||||||
playlistStrictMode = playlistStrictMode ?? this.playlistStrictMode
|
|
||||||
playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit
|
|
||||||
autoStart = autoStart ?? this.autoStart
|
|
||||||
|
|
||||||
console.debug('Downloading: url='+url+' quality='+quality+' format='+format+' folder='+folder+' customNamePrefix='+customNamePrefix+' playlistStrictMode='+playlistStrictMode+' playlistItemLimit='+playlistItemLimit+' autoStart='+autoStart);
|
|
||||||
this.addInProgress = true;
|
|
||||||
this.downloads.add(url, quality, format, folder, customNamePrefix, playlistStrictMode, playlistItemLimit, autoStart).subscribe((status: Status) => {
|
|
||||||
if (status.status === 'error') {
|
|
||||||
alert(`Error adding URL: ${status.msg}`);
|
|
||||||
} else {
|
|
||||||
this.addUrl = '';
|
|
||||||
}
|
|
||||||
this.addInProgress = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadItemByKey(id: string) {
|
|
||||||
this.downloads.startById([id]).subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
retryDownload(key: string, download: Download) {
|
|
||||||
this.addDownload(download.url, download.quality, download.format, download.folder, download.custom_name_prefix, download.playlist_strict_mode, download.playlist_item_limit, true);
|
|
||||||
this.downloads.delById('done', [key]).subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
delDownload(where: string, id: string) {
|
|
||||||
this.downloads.delById(where, [id]).subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
startSelectedDownloads(where: string){
|
|
||||||
this.downloads.startByFilter(where, dl => dl.checked).subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
delSelectedDownloads(where: string) {
|
|
||||||
this.downloads.delByFilter(where, dl => dl.checked).subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
clearCompletedDownloads() {
|
|
||||||
this.downloads.delByFilter('done', dl => dl.status === 'finished').subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
clearFailedDownloads() {
|
|
||||||
this.downloads.delByFilter('done', dl => dl.status === 'error').subscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
retryFailedDownloads() {
|
|
||||||
this.downloads.done.forEach((dl, key) => {
|
|
||||||
if (dl.status === 'error') {
|
|
||||||
this.retryDownload(key, dl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadSelectedFiles() {
|
|
||||||
this.downloads.done.forEach((dl, key) => {
|
|
||||||
if (dl.status === 'finished' && dl.checked) {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = this.buildDownloadLink(dl);
|
|
||||||
link.setAttribute('download', dl.filename);
|
|
||||||
link.setAttribute('target', '_self');
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
buildDownloadLink(download: Download) {
|
|
||||||
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
|
|
||||||
if (download.quality == 'audio' || download.filename.endsWith('.mp3')) {
|
|
||||||
baseDir = this.downloads.configuration["PUBLIC_HOST_AUDIO_URL"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (download.folder) {
|
|
||||||
baseDir += download.folder + '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseDir + encodeURIComponent(download.filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
identifyDownloadRow(index: number, row: KeyValue<string, Download>) {
|
|
||||||
return row.key;
|
|
||||||
}
|
|
||||||
|
|
||||||
isNumber(event) {
|
|
||||||
const charCode = (event.which) ? event.which : event.keyCode;
|
|
||||||
if (charCode > 31 && (charCode < 48 || charCode > 57)) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle inline batch panel (if you want to use an inline panel for export; not used for import modal)
|
|
||||||
toggleBatchPanel(): void {
|
|
||||||
this.showBatchPanel = !this.showBatchPanel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the Batch Import modal
|
|
||||||
openBatchImportModal(): void {
|
|
||||||
this.batchImportModalOpen = true;
|
|
||||||
this.batchImportText = '';
|
|
||||||
this.batchImportStatus = '';
|
|
||||||
this.importInProgress = false;
|
|
||||||
this.cancelImportFlag = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the Batch Import modal
|
|
||||||
closeBatchImportModal(): void {
|
|
||||||
this.batchImportModalOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start importing URLs from the batch modal textarea
|
|
||||||
startBatchImport(): void {
|
|
||||||
const urls = this.batchImportText
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map(url => url.trim())
|
|
||||||
.filter(url => url.length > 0);
|
|
||||||
if (urls.length === 0) {
|
|
||||||
alert('No valid URLs found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.importInProgress = true;
|
|
||||||
this.cancelImportFlag = false;
|
|
||||||
this.batchImportStatus = `Starting to import ${urls.length} URLs...`;
|
|
||||||
let index = 0;
|
|
||||||
const delayBetween = 1000;
|
|
||||||
const processNext = () => {
|
|
||||||
if (this.cancelImportFlag) {
|
|
||||||
this.batchImportStatus = `Import cancelled after ${index} of ${urls.length} URLs.`;
|
|
||||||
this.importInProgress = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (index >= urls.length) {
|
|
||||||
this.batchImportStatus = `Finished importing ${urls.length} URLs.`;
|
|
||||||
this.importInProgress = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = urls[index];
|
|
||||||
this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
|
|
||||||
// Now pass the selected quality, format, folder, etc. to the add() method
|
|
||||||
this.downloads.add(url, this.quality, this.format, this.folder, this.customNamePrefix,
|
|
||||||
this.playlistStrictMode, this.playlistItemLimit, this.autoStart)
|
|
||||||
.subscribe({
|
|
||||||
next: (status: Status) => {
|
|
||||||
if (status.status === 'error') {
|
|
||||||
alert(`Error adding URL ${url}: ${status.msg}`);
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
setTimeout(processNext, delayBetween);
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
console.error(`Error importing URL ${url}:`, err);
|
|
||||||
index++;
|
|
||||||
setTimeout(processNext, delayBetween);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
processNext();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel the batch import process
|
|
||||||
cancelBatchImport(): void {
|
|
||||||
if (this.importInProgress) {
|
|
||||||
this.cancelImportFlag = true;
|
|
||||||
this.batchImportStatus += ' Cancelling...';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export URLs based on filter: 'pending', 'completed', 'failed', or 'all'
|
|
||||||
exportBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
|
|
||||||
let urls: string[];
|
|
||||||
if (filter === 'pending') {
|
|
||||||
urls = Array.from(this.downloads.queue.values()).map(dl => dl.url);
|
|
||||||
} else if (filter === 'completed') {
|
|
||||||
// Only finished downloads in the "done" Map
|
|
||||||
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'finished').map(dl => dl.url);
|
|
||||||
} else if (filter === 'failed') {
|
|
||||||
// Only error downloads from the "done" Map
|
|
||||||
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'error').map(dl => dl.url);
|
|
||||||
} else {
|
|
||||||
// All: pending + both finished and error in done
|
|
||||||
urls = [
|
|
||||||
...Array.from(this.downloads.queue.values()).map(dl => dl.url),
|
|
||||||
...Array.from(this.downloads.done.values()).map(dl => dl.url)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (!urls.length) {
|
|
||||||
alert('No URLs found for the selected filter.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const content = urls.join('\n');
|
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
|
||||||
const downloadUrl = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = downloadUrl;
|
|
||||||
a.download = 'metube_urls.txt';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
window.URL.revokeObjectURL(downloadUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy URLs to clipboard based on filter: 'pending', 'completed', 'failed', or 'all'
|
|
||||||
copyBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
|
|
||||||
let urls: string[];
|
|
||||||
if (filter === 'pending') {
|
|
||||||
urls = Array.from(this.downloads.queue.values()).map(dl => dl.url);
|
|
||||||
} else if (filter === 'completed') {
|
|
||||||
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'finished').map(dl => dl.url);
|
|
||||||
} else if (filter === 'failed') {
|
|
||||||
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'error').map(dl => dl.url);
|
|
||||||
} else {
|
|
||||||
urls = [
|
|
||||||
...Array.from(this.downloads.queue.values()).map(dl => dl.url),
|
|
||||||
...Array.from(this.downloads.done.values()).map(dl => dl.url)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (!urls.length) {
|
|
||||||
alert('No URLs found for the selected filter.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const content = urls.join('\n');
|
|
||||||
navigator.clipboard.writeText(content)
|
|
||||||
.then(() => alert('URLs copied to clipboard.'))
|
|
||||||
.catch(() => alert('Failed to copy URLs.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchVersionInfo(): void {
|
|
||||||
const baseUrl = `${window.location.origin}${window.location.pathname.replace(/\/[^\/]*$/, '/')}`;
|
|
||||||
const versionUrl = `${baseUrl}version`;
|
|
||||||
this.http.get<{ 'yt-dlp': string, version: string }>(versionUrl)
|
|
||||||
.subscribe({
|
|
||||||
next: (data) => {
|
|
||||||
this.ytDlpVersion = data['yt-dlp'];
|
|
||||||
this.metubeVersion = data.version;
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.ytDlpVersion = null;
|
|
||||||
this.metubeVersion = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleAdvanced() {
|
|
||||||
this.isAdvancedOpen = !this.isAdvancedOpen;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateMetrics() {
|
|
||||||
this.activeDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading' || d.status === 'preparing').length;
|
|
||||||
this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length;
|
|
||||||
this.completedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'finished').length;
|
|
||||||
this.failedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'error').length;
|
|
||||||
|
|
||||||
// Calculate total speed from downloading items
|
|
||||||
const downloadingItems = Array.from(this.downloads.queue.values())
|
|
||||||
.filter(d => d.status === 'downloading');
|
|
||||||
|
|
||||||
this.totalSpeed = downloadingItems.reduce((total, item) => total + (item.speed || 0), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideServiceWorker } from '@angular/service-worker';
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
|
provideServiceWorker('custom-service-worker.js', {
|
||||||
|
enabled: !isDevMode(),
|
||||||
|
// Register the ServiceWorker as soon as the application is stable
|
||||||
|
// or after 30 seconds (whichever comes first).
|
||||||
|
registrationStrategy: 'registerWhenStable:30000'
|
||||||
|
}),
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1,780 @@
|
|||||||
|
<nav class="navbar navbar-expand-md navbar-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand d-flex align-items-center" href="#">
|
||||||
|
<img src="assets/icons/android-chrome-192x192.png" alt="MeTube Logo" height="32" class="me-2">
|
||||||
|
MeTube
|
||||||
|
</a>
|
||||||
|
<div class="download-metrics">
|
||||||
|
@if (activeDownloads > 0) {
|
||||||
|
<div class="metric">
|
||||||
|
<fa-icon [icon]="faDownload" class="text-primary" />
|
||||||
|
<span>{{activeDownloads}} downloading</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (queuedDownloads > 0) {
|
||||||
|
<div class="metric">
|
||||||
|
<fa-icon [icon]="faClock" class="text-warning" />
|
||||||
|
<span>{{queuedDownloads}} queued</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (completedDownloads > 0) {
|
||||||
|
<div class="metric">
|
||||||
|
<fa-icon [icon]="faCheck" class="text-success" />
|
||||||
|
<span>{{completedDownloads}} completed</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (failedDownloads > 0) {
|
||||||
|
<div class="metric">
|
||||||
|
<fa-icon [icon]="faTimesCircle" class="text-danger" />
|
||||||
|
<span>{{failedDownloads}} failed</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if ((totalSpeed | speed) !== '') {
|
||||||
|
<div class="metric">
|
||||||
|
<fa-icon [icon]="faTachometerAlt" class="text-info" />
|
||||||
|
<span>{{totalSpeed | speed }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<!--
|
||||||
|
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsDefault" aria-controls="navbarsDefault" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarsDefault">
|
||||||
|
<ul class="navbar-nav mr-auto">
|
||||||
|
<li class="nav-item active">
|
||||||
|
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
<div class="navbar-nav ms-auto">
|
||||||
|
<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"
|
||||||
|
ngbDropdownToggle>
|
||||||
|
@if(activeTheme){
|
||||||
|
<fa-icon [icon]="activeTheme.icon" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<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"
|
||||||
|
[class.active]="activeTheme === theme"
|
||||||
|
ngbDropdownItem
|
||||||
|
(click)="themeChanged(theme)">
|
||||||
|
<span class="me-2 opacity-50">
|
||||||
|
<fa-icon [icon]="theme.icon" />
|
||||||
|
</span>
|
||||||
|
{{ theme.displayName }}
|
||||||
|
<span class="ms-auto"
|
||||||
|
[class.d-none]="activeTheme !== theme">
|
||||||
|
<fa-icon [icon]="faCheck" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main role="main" class="container container-xl">
|
||||||
|
<form #f="ngForm">
|
||||||
|
<div class="container add-url-box">
|
||||||
|
<!-- Main URL Input with Download Button -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<div class="input-group input-group-lg shadow-sm">
|
||||||
|
<input type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
placeholder="Enter video, channel, or playlist URL"
|
||||||
|
name="addUrl"
|
||||||
|
[(ngModel)]="addUrl"
|
||||||
|
[disabled]="addInProgress || downloads.loading">
|
||||||
|
@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 {
|
||||||
|
<button class="btn btn-primary btn-lg px-4" type="submit"
|
||||||
|
(click)="addDownload()"
|
||||||
|
[disabled]="downloads.loading">
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Options Row -->
|
||||||
|
<div class="row mb-3 g-3">
|
||||||
|
@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 || downloads.loading">
|
||||||
|
@for (type of downloadTypes; track type.id) {
|
||||||
|
<option [ngValue]="type.id">{{ type.text }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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 || downloads.loading">
|
||||||
|
@for (vc of videoCodecs; track vc.id) {
|
||||||
|
<option [ngValue]="vc.id">{{ vc.text }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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 || 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 || 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 || 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 || 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 || downloads.loading">
|
||||||
|
@for (q of qualities; track q.id) {
|
||||||
|
<option [ngValue]="q.id">{{ q.text }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else if (downloadType === 'captions') {
|
||||||
|
<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 || downloads.loading">
|
||||||
|
@for (type of downloadTypes; track type.id) {
|
||||||
|
<option [ngValue]="type.id">{{ type.text }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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 || 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-md-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 || 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-md-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 || 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 || 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-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>
|
||||||
|
|
||||||
|
<!-- Advanced Options Panel -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="collapse show" id="advancedOptions" [ngbCollapse]="!isAdvancedOpen">
|
||||||
|
<div class="py-2">
|
||||||
|
<!-- Advanced Settings -->
|
||||||
|
<div class="row g-3 mb-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">Auto Start</span>
|
||||||
|
<select class="form-select"
|
||||||
|
name="autoStart"
|
||||||
|
[(ngModel)]="autoStart"
|
||||||
|
(change)="autoStartChanged()"
|
||||||
|
[disabled]="addInProgress || downloads.loading"
|
||||||
|
ngbTooltip="Automatically start downloads when added">
|
||||||
|
<option [ngValue]="true">Yes</option>
|
||||||
|
<option [ngValue]="false">No</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">Download Folder</span>
|
||||||
|
@if (customDirs$ | async; as customDirs) {
|
||||||
|
<ng-select [items]="customDirs"
|
||||||
|
placeholder="Default"
|
||||||
|
[addTag]="allowCustomDir.bind(this)"
|
||||||
|
addTagText="Create directory"
|
||||||
|
bindLabel="folder"
|
||||||
|
[(ngModel)]="folder"
|
||||||
|
[disabled]="addInProgress || downloads.loading"
|
||||||
|
[virtualScroll]="true"
|
||||||
|
[clearable]="true"
|
||||||
|
[loading]="downloads.loading"
|
||||||
|
[searchable]="true"
|
||||||
|
[closeOnSelect]="true"
|
||||||
|
ngbTooltip="Choose where to save downloads. Type to create a new folder." />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">Custom Name Prefix</span>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Default"
|
||||||
|
name="customNamePrefix"
|
||||||
|
[(ngModel)]="customNamePrefix"
|
||||||
|
[disabled]="addInProgress || downloads.loading"
|
||||||
|
ngbTooltip="Add a prefix to downloaded filenames">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">Items Limit</span>
|
||||||
|
<input type="number"
|
||||||
|
min="0"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Default"
|
||||||
|
name="playlistItemLimit"
|
||||||
|
(keydown)="isNumber($event)"
|
||||||
|
[(ngModel)]="playlistItemLimit"
|
||||||
|
[disabled]="addInProgress || downloads.loading"
|
||||||
|
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="row g-2 align-items-center">
|
||||||
|
<div class="col-auto">
|
||||||
|
<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"
|
||||||
|
ngbTooltip="Split video into separate files by chapters">
|
||||||
|
<label class="form-check-label" for="checkbox-split-chapters">Split by chapters</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (splitByChapters) {
|
||||||
|
<div class="col">
|
||||||
|
<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"
|
||||||
|
ngbTooltip="Output template for chapter files">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Actions -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<hr class="my-3">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<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-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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Batch Import Modal -->
|
||||||
|
<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 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 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) {
|
||||||
|
<small>{{ batchImportStatus }}</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
@if (importInProgress) {
|
||||||
|
<button type="button" class="btn btn-danger me-auto" (click)="cancelBatchImport()">
|
||||||
|
Cancel Import
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button>
|
||||||
|
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">
|
||||||
|
Import URLs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
@if (downloads.loading) {
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
Connecting to server...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="metube-section-header">Downloading</div>
|
||||||
|
<div class="px-2 py-3 border-bottom">
|
||||||
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" /> Cancel selected</button>
|
||||||
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload" /> Download selected</button>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="width: 1rem;">
|
||||||
|
<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>
|
||||||
|
<th scope="col" style="width: 7rem;">ETA</th>
|
||||||
|
<th scope="col" style="width: 6rem;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) {
|
||||||
|
<tr [class.disabled]='download.value.deleting'>
|
||||||
|
<td>
|
||||||
|
<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"
|
||||||
|
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ download.value.speed | speed }}</td>
|
||||||
|
<td>{{ download.value.eta | eta }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex">
|
||||||
|
@if (download.value.status === 'pending') {
|
||||||
|
<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" [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>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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]="!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">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" style="width: 1rem;">
|
||||||
|
<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 (entry of cachedSortedDone; track entry[1].id) {
|
||||||
|
<tr [class.disabled]='entry[1].deleting'>
|
||||||
|
<td>
|
||||||
|
<app-item-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: inline-block; width: 1.5rem;">
|
||||||
|
@if (entry[1].status === 'finished') {
|
||||||
|
<fa-icon [icon]="faCheckCircle" class="text-success" />
|
||||||
|
}
|
||||||
|
@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(entry[1])}}">@if (!!entry[1].filename) {
|
||||||
|
<a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
|
||||||
|
} @else {
|
||||||
|
@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 (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 (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 (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="{{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 (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(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(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>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main><!-- /.container -->
|
||||||
|
|
||||||
|
<footer class="footer navbar-dark bg-dark py-3 mt-5">
|
||||||
|
<div class="container text-center">
|
||||||
|
@if (ytDlpVersion && metubeVersion) {
|
||||||
|
<div class="footer-content">
|
||||||
|
<div class="version-item">
|
||||||
|
<span class="version-label">yt-dlp</span>
|
||||||
|
<span class="version-value">{{ytDlpVersion}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="version-separator"></div>
|
||||||
|
<div class="version-item">
|
||||||
|
<span class="version-label">MeTube</span>
|
||||||
|
<span class="version-value">{{metubeVersion}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="version-separator"></div>
|
||||||
|
@if (ytDlpOptionsUpdateTime) {
|
||||||
|
<div class="version-item">
|
||||||
|
<span class="version-label">yt-dlp-options</span>
|
||||||
|
<span class="version-value">{{ytDlpOptionsUpdateTime}}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (ytDlpOptionsUpdateTime) {
|
||||||
|
<div class="version-separator"></div>
|
||||||
|
}
|
||||||
|
<a href="https://github.com/alexta69/metube" target="_blank" class="github-link">
|
||||||
|
<fa-icon [icon]="faGithub" />
|
||||||
|
<span>GitHub</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { BrowserModule } from '@angular/platform-browser';
|
|
||||||
import { NgModule, isDevMode } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
|
||||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
|
||||||
import { CookieService } from 'ngx-cookie-service';
|
|
||||||
|
|
||||||
import { AppComponent } from './app.component';
|
|
||||||
import { EtaPipe, SpeedPipe, EncodeURIComponent, FileSizePipe } from './downloads.pipe';
|
|
||||||
import { MasterCheckboxComponent, SlaveCheckboxComponent } from './master-checkbox.component';
|
|
||||||
import { MeTubeSocket } from './metube-socket';
|
|
||||||
import { NgSelectModule } from '@ng-select/ng-select';
|
|
||||||
import { ServiceWorkerModule } from '@angular/service-worker';
|
|
||||||
|
|
||||||
@NgModule({ declarations: [
|
|
||||||
AppComponent,
|
|
||||||
EtaPipe,
|
|
||||||
SpeedPipe,
|
|
||||||
FileSizePipe,
|
|
||||||
EncodeURIComponent,
|
|
||||||
MasterCheckboxComponent,
|
|
||||||
SlaveCheckboxComponent
|
|
||||||
],
|
|
||||||
bootstrap: [AppComponent], imports: [BrowserModule,
|
|
||||||
FormsModule,
|
|
||||||
NgbModule,
|
|
||||||
FontAwesomeModule,
|
|
||||||
NgSelectModule,
|
|
||||||
ServiceWorkerModule.register('custom-service-worker.js', {
|
|
||||||
enabled: !isDevMode(),
|
|
||||||
// Register the ServiceWorker as soon as the application is stable
|
|
||||||
// or after 30 seconds (whichever comes first).
|
|
||||||
registrationStrategy: 'registerWhenStable:30000'
|
|
||||||
})], providers: [CookieService, MeTubeSocket, provideHttpClient(withInterceptorsFromDi())] })
|
|
||||||
export class AppModule { }
|
|
||||||
@@ -1,29 +1,7 @@
|
|||||||
.button-toggle-theme:focus, .button-toggle-theme:active
|
|
||||||
box-shadow: none
|
|
||||||
outline: 0px
|
|
||||||
|
|
||||||
.add-url-box
|
.add-url-box
|
||||||
max-width: 960px
|
max-width: 960px
|
||||||
margin: 4rem auto
|
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
|
.metube-section-header
|
||||||
font-size: 1.8rem
|
font-size: 1.8rem
|
||||||
font-weight: 300
|
font-weight: 300
|
||||||
@@ -66,39 +44,11 @@ td
|
|||||||
width: 12rem
|
width: 12rem
|
||||||
margin-left: auto
|
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
|
.modal.fade.show
|
||||||
background-color: rgba(0, 0, 0, 0.5)
|
background-color: rgba(0, 0, 0, 0.5)
|
||||||
|
|
||||||
.modal-header
|
.modal-header
|
||||||
border-bottom: 1px solid #eee
|
border-bottom: 1px solid var(--bs-border-color)
|
||||||
|
|
||||||
.modal-body
|
.modal-body
|
||||||
textarea.form-control
|
textarea.form-control
|
||||||
@@ -112,20 +62,12 @@ td
|
|||||||
.spinner-border
|
.spinner-border
|
||||||
margin-right: 0.5rem
|
margin-right: 0.5rem
|
||||||
|
|
||||||
::ng-deep .ng-select
|
.add-progress-btn
|
||||||
flex: 1
|
min-width: 9.5rem
|
||||||
.ng-select-container
|
cursor: default
|
||||||
min-height: 38px
|
|
||||||
.ng-value
|
.add-cancel-btn
|
||||||
white-space: nowrap
|
min-width: 3.25rem
|
||||||
overflow: visible
|
|
||||||
.ng-dropdown-panel
|
|
||||||
.ng-dropdown-panel-items
|
|
||||||
max-height: 300px
|
|
||||||
.ng-option
|
|
||||||
white-space: nowrap
|
|
||||||
overflow: visible
|
|
||||||
text-overflow: ellipsis
|
|
||||||
|
|
||||||
:host
|
:host
|
||||||
display: flex
|
display: flex
|
||||||
@@ -209,3 +151,48 @@ main
|
|||||||
|
|
||||||
span
|
span
|
||||||
white-space: nowrap
|
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)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
+1133
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Component, ElementRef, viewChild, output, input } from "@angular/core";
|
||||||
|
import { Checkable } from "../interfaces";
|
||||||
|
import { FormsModule } from "@angular/forms";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
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()" [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 SelectAllCheckboxComponent {
|
||||||
|
readonly id = input.required<string>();
|
||||||
|
readonly list = input.required<Map<string, Checkable>>();
|
||||||
|
readonly changed = output<number>();
|
||||||
|
|
||||||
|
readonly masterCheckbox = viewChild.required<ElementRef>('masterCheckbox');
|
||||||
|
selected!: boolean;
|
||||||
|
|
||||||
|
clicked() {
|
||||||
|
this.list().forEach(item => item.checked = this.selected);
|
||||||
|
this.selectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionChanged() {
|
||||||
|
const masterCheckbox = this.masterCheckbox();
|
||||||
|
if (!masterCheckbox)
|
||||||
|
return;
|
||||||
|
let checked = 0;
|
||||||
|
this.list().forEach(item => { if(item.checked) checked++ });
|
||||||
|
this.selected = checked > 0 && checked == this.list().size;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
import { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||||
|
import { Checkable } from '../interfaces';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
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()" [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 ItemCheckboxComponent {
|
||||||
|
readonly id = input.required<string>();
|
||||||
|
readonly master = input.required<SelectAllCheckboxComponent>();
|
||||||
|
readonly checkable = input.required<Checkable>();
|
||||||
|
}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
|
||||||
import { SpeedService } from './speed.service';
|
|
||||||
import { BehaviorSubject } from 'rxjs';
|
|
||||||
import { throttleTime } from 'rxjs/operators';
|
|
||||||
|
|
||||||
@Pipe({
|
|
||||||
name: 'eta',
|
|
||||||
standalone: false
|
|
||||||
})
|
|
||||||
export class EtaPipe implements PipeTransform {
|
|
||||||
transform(value: number, ...args: any[]): any {
|
|
||||||
if (value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (value < 60) {
|
|
||||||
return `${Math.round(value)}s`;
|
|
||||||
}
|
|
||||||
if (value < 3600) {
|
|
||||||
return `${Math.floor(value/60)}m ${Math.round(value%60)}s`;
|
|
||||||
}
|
|
||||||
const hours = Math.floor(value/3600)
|
|
||||||
const minutes = value % 3600
|
|
||||||
return `${hours}h ${Math.floor(minutes/60)}m ${Math.round(minutes%60)}s`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Pipe({
|
|
||||||
name: 'speed',
|
|
||||||
standalone: false,
|
|
||||||
pure: false // Make the pipe impure so it can handle async updates
|
|
||||||
})
|
|
||||||
export class SpeedPipe implements PipeTransform {
|
|
||||||
private speedSubject = new BehaviorSubject<number>(0);
|
|
||||||
private formattedSpeed: string = '';
|
|
||||||
|
|
||||||
constructor(private speedService: SpeedService) {
|
|
||||||
// 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, ...args: any[]): any {
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Pipe({
|
|
||||||
name: 'encodeURIComponent',
|
|
||||||
standalone: false
|
|
||||||
})
|
|
||||||
export class EncodeURIComponent implements PipeTransform {
|
|
||||||
transform(value: string, ...args: any[]): any {
|
|
||||||
return encodeURIComponent(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Pipe({
|
|
||||||
name: 'fileSize',
|
|
||||||
standalone: false
|
|
||||||
})
|
|
||||||
export class FileSizePipe implements PipeTransform {
|
|
||||||
transform(value: number): string {
|
|
||||||
if (isNaN(value) || value === 0) return '0 Bytes';
|
|
||||||
|
|
||||||
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
|
||||||
const unitIndex = Math.floor(Math.log(value) / Math.log(1000)); // Use 1000 for common units
|
|
||||||
|
|
||||||
const unitValue = value / Math.pow(1000, unitIndex);
|
|
||||||
return `${unitValue.toFixed(2)} ${units[unitIndex]}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
|
||||||
import { Observable, of, Subject } from 'rxjs';
|
|
||||||
import { catchError } from 'rxjs/operators';
|
|
||||||
import { MeTubeSocket } from './metube-socket';
|
|
||||||
|
|
||||||
export interface Status {
|
|
||||||
status: string;
|
|
||||||
msg?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Download {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
quality: string;
|
|
||||||
format: string;
|
|
||||||
folder: string;
|
|
||||||
custom_name_prefix: string;
|
|
||||||
playlist_strict_mode: boolean;
|
|
||||||
playlist_item_limit: number;
|
|
||||||
status: string;
|
|
||||||
msg: string;
|
|
||||||
percent: number;
|
|
||||||
speed: number;
|
|
||||||
eta: number;
|
|
||||||
filename: string;
|
|
||||||
checked?: boolean;
|
|
||||||
deleting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({
|
|
||||||
providedIn: 'root'
|
|
||||||
})
|
|
||||||
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();
|
|
||||||
|
|
||||||
configuration = {};
|
|
||||||
customDirs = {};
|
|
||||||
|
|
||||||
constructor(private http: HttpClient, private socket: MeTubeSocket) {
|
|
||||||
socket.fromEvent('all').subscribe((strdata: string) => {
|
|
||||||
this.loading = false;
|
|
||||||
let data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata);
|
|
||||||
this.queue.clear();
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
socket.fromEvent('added').subscribe((strdata: string) => {
|
|
||||||
let data: Download = JSON.parse(strdata);
|
|
||||||
this.queue.set(data.url, data);
|
|
||||||
this.queueChanged.next(null);
|
|
||||||
});
|
|
||||||
socket.fromEvent('updated').subscribe((strdata: string) => {
|
|
||||||
let data: Download = JSON.parse(strdata);
|
|
||||||
let dl: Download = this.queue.get(data.url);
|
|
||||||
data.checked = dl.checked;
|
|
||||||
data.deleting = dl.deleting;
|
|
||||||
this.queue.set(data.url, data);
|
|
||||||
this.updated.next(null);
|
|
||||||
});
|
|
||||||
socket.fromEvent('completed').subscribe((strdata: string) => {
|
|
||||||
let data: Download = JSON.parse(strdata);
|
|
||||||
this.queue.delete(data.url);
|
|
||||||
this.done.set(data.url, data);
|
|
||||||
this.queueChanged.next(null);
|
|
||||||
this.doneChanged.next(null);
|
|
||||||
});
|
|
||||||
socket.fromEvent('canceled').subscribe((strdata: string) => {
|
|
||||||
let data: string = JSON.parse(strdata);
|
|
||||||
this.queue.delete(data);
|
|
||||||
this.queueChanged.next(null);
|
|
||||||
});
|
|
||||||
socket.fromEvent('cleared').subscribe((strdata: string) => {
|
|
||||||
let data: string = JSON.parse(strdata);
|
|
||||||
this.done.delete(data);
|
|
||||||
this.doneChanged.next(null);
|
|
||||||
});
|
|
||||||
socket.fromEvent('configuration').subscribe((strdata: string) => {
|
|
||||||
let data = JSON.parse(strdata);
|
|
||||||
console.debug("got configuration:", data);
|
|
||||||
this.configuration = data;
|
|
||||||
this.configurationChanged.next(data);
|
|
||||||
});
|
|
||||||
socket.fromEvent('custom_dirs').subscribe((strdata: string) => {
|
|
||||||
let data = JSON.parse(strdata);
|
|
||||||
console.debug("got custom_dirs:", data);
|
|
||||||
this.customDirs = data;
|
|
||||||
this.customDirsChanged.next(data);
|
|
||||||
});
|
|
||||||
socket.fromEvent('ytdl_options_changed').subscribe((strdata: string) => {
|
|
||||||
let data = JSON.parse(strdata);
|
|
||||||
this.ytdlOptionsChanged.next(data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleHTTPError(error: HttpErrorResponse) {
|
|
||||||
var msg = error.error instanceof ErrorEvent ? error.error.message : error.error;
|
|
||||||
return of({status: 'error', msg: msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
public add(url: string, quality: string, format: string, folder: string, customNamePrefix: string, playlistStrictMode: boolean, playlistItemLimit: number, autoStart: boolean) {
|
|
||||||
return this.http.post<Status>('add', {url: url, quality: quality, format: format, folder: folder, custom_name_prefix: customNamePrefix, playlist_strict_mode: playlistStrictMode, playlist_item_limit: playlistItemLimit, auto_start: autoStart}).pipe(
|
|
||||||
catchError(this.handleHTTPError)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public startById(ids: string[]) {
|
|
||||||
return this.http.post('start', {ids: ids});
|
|
||||||
}
|
|
||||||
|
|
||||||
public delById(where: string, ids: string[]) {
|
|
||||||
ids.forEach(id => this[where].get(id).deleting = true);
|
|
||||||
return this.http.post('delete', {where: where, ids: ids});
|
|
||||||
}
|
|
||||||
|
|
||||||
public startByFilter(where: string, filter: (dl: Download) => boolean) {
|
|
||||||
let ids: string[] = [];
|
|
||||||
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
|
||||||
return this.startById(ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
public delByFilter(where: string, filter: (dl: Download) => boolean) {
|
|
||||||
let ids: string[] = [];
|
|
||||||
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
|
||||||
return this.delById(where, ids);
|
|
||||||
}
|
|
||||||
public addDownloadByUrl(url: string): Promise<any> {
|
|
||||||
const defaultQuality = 'best';
|
|
||||||
const defaultFormat = 'mp4';
|
|
||||||
const defaultFolder = '';
|
|
||||||
const defaultCustomNamePrefix = '';
|
|
||||||
const defaultPlaylistStrictMode = false;
|
|
||||||
const defaultPlaylistItemLimit = 0;
|
|
||||||
const defaultAutoStart = true;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistStrictMode, defaultPlaylistItemLimit, defaultAutoStart)
|
|
||||||
.subscribe(
|
|
||||||
response => resolve(response),
|
|
||||||
error => reject(error)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
public exportQueueUrls(): string[] {
|
|
||||||
return Array.from(this.queue.values()).map(download => download.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
export interface Format {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
qualities: Quality[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Quality {
|
|
||||||
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' }],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface Checkable {
|
||||||
|
checked: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
|
||||||
|
export interface Download {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
download_type: string;
|
||||||
|
codec?: string;
|
||||||
|
quality: string;
|
||||||
|
format: string;
|
||||||
|
folder: string;
|
||||||
|
custom_name_prefix: string;
|
||||||
|
playlist_item_limit: number;
|
||||||
|
split_by_chapters?: boolean;
|
||||||
|
chapter_template?: string;
|
||||||
|
subtitle_language?: string;
|
||||||
|
subtitle_mode?: string;
|
||||||
|
status: string;
|
||||||
|
msg: string;
|
||||||
|
percent: number;
|
||||||
|
speed: number;
|
||||||
|
eta: number;
|
||||||
|
filename: string;
|
||||||
|
checked: boolean;
|
||||||
|
timestamp?: number;
|
||||||
|
size?: number;
|
||||||
|
error?: string;
|
||||||
|
deleting?: boolean;
|
||||||
|
chapter_files?: { filename: string, size: number }[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Quality } from "./quality";
|
||||||
|
|
||||||
|
export interface Format {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
qualities: Quality[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Quality } from "./quality";
|
||||||
|
|
||||||
|
export interface Option {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioFormatOption extends Option {
|
||||||
|
qualities: Quality[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOWNLOAD_TYPES: Option[] = [
|
||||||
|
{ id: "video", text: "Video" },
|
||||||
|
{ id: "audio", text: "Audio" },
|
||||||
|
{ id: "captions", text: "Captions" },
|
||||||
|
{ id: "thumbnail", text: "Thumbnail" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VIDEO_CODECS: Option[] = [
|
||||||
|
{ id: "auto", text: "Auto" },
|
||||||
|
{ id: "h264", text: "H.264" },
|
||||||
|
{ id: "h265", text: "H.265 (HEVC)" },
|
||||||
|
{ id: "av1", text: "AV1" },
|
||||||
|
{ id: "vp9", text: "VP9" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VIDEO_FORMATS: Option[] = [
|
||||||
|
{ id: "any", text: "Auto" },
|
||||||
|
{ id: "mp4", text: "MP4" },
|
||||||
|
{ id: "ios", text: "iOS Compatible" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VIDEO_QUALITIES: Quality[] = [
|
||||||
|
{ id: "best", text: "Best" },
|
||||||
|
{ id: "2160", text: "2160p" },
|
||||||
|
{ id: "1440", text: "1440p" },
|
||||||
|
{ id: "1080", text: "1080p" },
|
||||||
|
{ id: "720", text: "720p" },
|
||||||
|
{ id: "480", text: "480p" },
|
||||||
|
{ id: "360", text: "360p" },
|
||||||
|
{ id: "240", text: "240p" },
|
||||||
|
{ id: "worst", text: "Worst" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AUDIO_FORMATS: AudioFormatOption[] = [
|
||||||
|
{
|
||||||
|
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" }];
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export * from './theme';
|
||||||
|
export * from './status';
|
||||||
|
export * from './quality';
|
||||||
|
export * from './state';
|
||||||
|
export * from './download';
|
||||||
|
export * from './checkable';
|
||||||
|
export * from './format';
|
||||||
|
export * from './formats';
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
export interface Quality {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type State = 'queue' | 'done';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface Status {
|
||||||
|
status: string;
|
||||||
|
msg?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
icon: IconDefinition;
|
||||||
|
}
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { Component, Input, ViewChild, ElementRef, Output, EventEmitter } from '@angular/core';
|
|
||||||
|
|
||||||
interface Checkable {
|
|
||||||
checked: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-master-checkbox',
|
|
||||||
template: `
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="{{id}}-select-all" #masterCheckbox [(ngModel)]="selected" (change)="clicked()">
|
|
||||||
<label class="form-check-label" for="{{id}}-select-all"></label>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
standalone: false
|
|
||||||
})
|
|
||||||
export class MasterCheckboxComponent {
|
|
||||||
@Input() id: string;
|
|
||||||
@Input() list: Map<String, Checkable>;
|
|
||||||
@Output() changed = new EventEmitter<number>();
|
|
||||||
|
|
||||||
@ViewChild('masterCheckbox') masterCheckbox: ElementRef;
|
|
||||||
selected: boolean;
|
|
||||||
|
|
||||||
clicked() {
|
|
||||||
this.list.forEach(item => item.checked = this.selected);
|
|
||||||
this.selectionChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
selectionChanged() {
|
|
||||||
if (!this.masterCheckbox)
|
|
||||||
return;
|
|
||||||
let checked: number = 0;
|
|
||||||
this.list.forEach(item => { if(item.checked) checked++ });
|
|
||||||
this.selected = checked > 0 && checked == this.list.size;
|
|
||||||
this.masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list.size;
|
|
||||||
this.changed.emit(checked);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-slave-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>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
standalone: false
|
|
||||||
})
|
|
||||||
export class SlaveCheckboxComponent {
|
|
||||||
@Input() id: string;
|
|
||||||
@Input() master: MasterCheckboxComponent;
|
|
||||||
@Input() checkable: Checkable;
|
|
||||||
}
|
|
||||||
@@ -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,21 @@
|
|||||||
|
import { Pipe, PipeTransform } from "@angular/core";
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'eta',
|
||||||
|
})
|
||||||
|
export class EtaPipe implements PipeTransform {
|
||||||
|
transform(value: number): string | null {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value < 60) {
|
||||||
|
return `${Math.round(value)}s`;
|
||||||
|
}
|
||||||
|
if (value < 3600) {
|
||||||
|
return `${Math.floor(value/60)}m ${Math.round(value%60)}s`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(value/3600)
|
||||||
|
const minutes = value % 3600
|
||||||
|
return `${hours}h ${Math.floor(minutes/60)}m ${Math.round(minutes%60)}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,16 @@
|
|||||||
|
import { Pipe, PipeTransform } from "@angular/core";
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'fileSize',
|
||||||
|
})
|
||||||
|
export class FileSizePipe implements PipeTransform {
|
||||||
|
transform(value: number): string {
|
||||||
|
if (isNaN(value) || value === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
|
const unitIndex = Math.floor(Math.log(value) / Math.log(1000)); // Use 1000 for common units
|
||||||
|
|
||||||
|
const unitValue = value / Math.pow(1000, unitIndex);
|
||||||
|
return `${unitValue.toFixed(2)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { EtaPipe } from './eta.pipe';
|
||||||
|
export { SpeedPipe } from './speed.pipe';
|
||||||
|
export { FileSizePipe } from './file-size.pipe';
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Pipe, PipeTransform } from "@angular/core";
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'speed',
|
||||||
|
pure: true
|
||||||
|
})
|
||||||
|
export class SpeedPipe implements PipeTransform {
|
||||||
|
transform(value: number): string {
|
||||||
|
if (value === null || value === undefined || isNaN(value) || value <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const decimals = 2;
|
||||||
|
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
|
||||||
|
const i = Math.floor(Math.log(value) / Math.log(k));
|
||||||
|
return `${parseFloat((value / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { of, Subject } from 'rxjs';
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
export class DownloadsService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private socket = inject(MeTubeSocket);
|
||||||
|
loading = true;
|
||||||
|
queue = new Map<string, Download>();
|
||||||
|
done = new Map<string, Download>();
|
||||||
|
queueChanged = new Subject<void>();
|
||||||
|
doneChanged = new Subject<void>();
|
||||||
|
customDirsChanged = new Subject<Record<string, string[]>>();
|
||||||
|
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
|
||||||
|
configurationChanged = new Subject<Record<string, unknown>>();
|
||||||
|
updated = new Subject<void>();
|
||||||
|
|
||||||
|
configuration: Record<string, unknown> = {};
|
||||||
|
customDirs: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.socket.fromEvent('all')
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
this.loading = false;
|
||||||
|
const data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata);
|
||||||
|
this.queue.clear();
|
||||||
|
data[0].forEach(entry => this.queue.set(...entry));
|
||||||
|
this.done.clear();
|
||||||
|
data[1].forEach(entry => this.done.set(...entry));
|
||||||
|
this.queueChanged.next();
|
||||||
|
this.doneChanged.next();
|
||||||
|
});
|
||||||
|
this.socket.fromEvent('added')
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data: Download = JSON.parse(strdata);
|
||||||
|
this.queue.set(data.url, data);
|
||||||
|
this.queueChanged.next();
|
||||||
|
});
|
||||||
|
this.socket.fromEvent('updated')
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data: Download = JSON.parse(strdata);
|
||||||
|
const dl: Download | undefined = this.queue.get(data.url);
|
||||||
|
data.checked = !!dl?.checked;
|
||||||
|
data.deleting = !!dl?.deleting;
|
||||||
|
this.queue.set(data.url, data);
|
||||||
|
this.updated.next();
|
||||||
|
});
|
||||||
|
this.socket.fromEvent('completed')
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data: Download = JSON.parse(strdata);
|
||||||
|
this.queue.delete(data.url);
|
||||||
|
this.done.set(data.url, data);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
this.socket.fromEvent('cleared')
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data: string = JSON.parse(strdata);
|
||||||
|
this.done.delete(data);
|
||||||
|
this.doneChanged.next();
|
||||||
|
});
|
||||||
|
this.socket.fromEvent('configuration')
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data = JSON.parse(strdata);
|
||||||
|
console.debug("got configuration:", data);
|
||||||
|
this.configuration = data;
|
||||||
|
this.configurationChanged.next(data);
|
||||||
|
});
|
||||||
|
this.socket.fromEvent('custom_dirs')
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data = JSON.parse(strdata);
|
||||||
|
console.debug("got custom_dirs:", data);
|
||||||
|
this.customDirs = data;
|
||||||
|
this.customDirsChanged.next(data);
|
||||||
|
});
|
||||||
|
this.socket.fromEvent('ytdl_options_changed')
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe((strdata: string) => {
|
||||||
|
const data = JSON.parse(strdata);
|
||||||
|
this.ytdlOptionsChanged.next(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHTTPError(error: HttpErrorResponse) {
|
||||||
|
const msg = error.error instanceof ErrorEvent
|
||||||
|
? error.error.message
|
||||||
|
: (typeof error.error === 'string'
|
||||||
|
? error.error
|
||||||
|
: (error.error?.msg || error.message || 'Request failed'));
|
||||||
|
return of({ status: 'error', msg });
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public startById(ids: string[]) {
|
||||||
|
return this.http.post('start', {ids: ids});
|
||||||
|
}
|
||||||
|
|
||||||
|
public delById(where: State, ids: string[]) {
|
||||||
|
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});
|
||||||
|
}
|
||||||
|
|
||||||
|
public startByFilter(where: State, filter: (dl: Download) => boolean) {
|
||||||
|
const ids: string[] = [];
|
||||||
|
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
||||||
|
return this.startById(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public delByFilter(where: State, filter: (dl: Download) => boolean) {
|
||||||
|
const ids: string[] = [];
|
||||||
|
this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.url) });
|
||||||
|
return this.delById(where, ids);
|
||||||
|
}
|
||||||
|
public cancelAdd() {
|
||||||
|
return this.http.post<Status>('cancel-add', {}).pipe(
|
||||||
|
catchError(this.handleHTTPError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { DownloadsService } from './downloads.service';
|
||||||
|
export { MeTubeSocket } from './metube-socket.service';
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { ApplicationRef } from '@angular/core';
|
import { ApplicationRef } from '@angular/core';
|
||||||
import { Socket } from 'ngx-socket-io';
|
import { Socket } from 'ngx-socket-io';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable(
|
||||||
|
{ providedIn: 'root' }
|
||||||
|
)
|
||||||
export class MeTubeSocket extends Socket {
|
export class MeTubeSocket extends Socket {
|
||||||
constructor(appRef: ApplicationRef) {
|
|
||||||
|
constructor() {
|
||||||
|
const appRef = inject(ApplicationRef);
|
||||||
|
|
||||||
const path =
|
const path =
|
||||||
document.location.pathname.replace(/share-target/, '') + 'socket.io';
|
document.location.pathname.replace(/share-target/, '') + 'socket.io';
|
||||||
super({ url: '', options: { path } }, appRef);
|
super({ url: '', options: { path } }, appRef);
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-6
@@ -1,11 +1,6 @@
|
|||||||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
|
||||||
import { faCircleHalfStroke, faMoon, faSun } from "@fortawesome/free-solid-svg-icons";
|
import { faCircleHalfStroke, faMoon, faSun } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { Theme } from "./interfaces/theme";
|
||||||
|
|
||||||
export interface Theme {
|
|
||||||
id: string;
|
|
||||||
displayName: string;
|
|
||||||
icon: IconDefinition;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Themes: Theme[] = [
|
export const Themes: Theme[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export const environment = {
|
|
||||||
production: true
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// This file can be replaced during build by using the `fileReplacements` array.
|
|
||||||
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
|
|
||||||
// The list of file replacements can be found in `angular.json`.
|
|
||||||
|
|
||||||
export const environment = {
|
|
||||||
production: false
|
|
||||||
};
|
|
||||||
|
|
||||||
/*
|
|
||||||
* For easier debugging in development mode, you can import the following file
|
|
||||||
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
|
|
||||||
*
|
|
||||||
* This import should be commented out in production mode because it will have a negative impact
|
|
||||||
* on performance if an error is thrown.
|
|
||||||
*/
|
|
||||||
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.
|
|
||||||
+6
-9
@@ -1,12 +1,9 @@
|
|||||||
import { enableProdMode } from '@angular/core';
|
/// <reference types="@angular/localize" />
|
||||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { environment } from './environments/environment';
|
import { appConfig } from './app/app.config';
|
||||||
|
import { App } from './app/app';
|
||||||
|
|
||||||
if (environment.production) {
|
|
||||||
enableProdMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
bootstrapApplication(App, appConfig)
|
||||||
.catch(err => console.error(err));
|
.catch((err) => console.error(err));
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
/***************************************************************************************************
|
|
||||||
* Load `$localize` onto the global scope - used if i18n tags appear in Angular templates.
|
|
||||||
*/
|
|
||||||
import '@angular/localize/init';
|
|
||||||
/**
|
|
||||||
* This file includes polyfills needed by Angular and is loaded before the app.
|
|
||||||
* You can add your own extra polyfills to this file.
|
|
||||||
*
|
|
||||||
* This file is divided into 2 sections:
|
|
||||||
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
|
|
||||||
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
|
|
||||||
* file.
|
|
||||||
*
|
|
||||||
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
|
|
||||||
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
|
|
||||||
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
|
|
||||||
*
|
|
||||||
* Learn more in https://angular.io/guide/browser-support
|
|
||||||
*/
|
|
||||||
|
|
||||||
/***************************************************************************************************
|
|
||||||
* BROWSER POLYFILLS
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IE11 requires the following for NgClass support on SVG elements
|
|
||||||
*/
|
|
||||||
// import 'classlist.js'; // Run `npm install --save classlist.js`.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Web Animations `@angular/platform-browser/animations`
|
|
||||||
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
|
|
||||||
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
|
|
||||||
*/
|
|
||||||
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* By default, zone.js will patch all possible macroTask and DomEvents
|
|
||||||
* user can disable parts of macroTask/DomEvents patch by setting following flags
|
|
||||||
* because those flags need to be set before `zone.js` being loaded, and webpack
|
|
||||||
* will put import in the top of bundle, so user need to create a separate file
|
|
||||||
* in this directory (for example: zone-flags.ts), and put the following flags
|
|
||||||
* into that file, and then add the following code before importing zone.js.
|
|
||||||
* import './zone-flags';
|
|
||||||
*
|
|
||||||
* The flags allowed in zone-flags.ts are listed here.
|
|
||||||
*
|
|
||||||
* The following flags will work for all browsers.
|
|
||||||
*
|
|
||||||
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
|
|
||||||
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
|
|
||||||
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
|
|
||||||
*
|
|
||||||
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
|
|
||||||
* with the following flag, it will bypass `zone.js` patch for IE/Edge
|
|
||||||
*
|
|
||||||
* (window as any).__Zone_enable_cross_context_check = true;
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
/***************************************************************************************************
|
|
||||||
* Zone JS is required by default for Angular itself.
|
|
||||||
*/
|
|
||||||
import 'zone.js'; // Included with Angular CLI.
|
|
||||||
|
|
||||||
|
|
||||||
/***************************************************************************************************
|
|
||||||
* APPLICATION IMPORTS
|
|
||||||
*/
|
|
||||||
@@ -5,3 +5,22 @@
|
|||||||
|
|
||||||
[data-bs-theme="dark"] &
|
[data-bs-theme="dark"] &
|
||||||
background-color: var(--bs-dark-bg-subtle) !important
|
background-color: var(--bs-dark-bg-subtle) !important
|
||||||
|
|
||||||
|
.ng-select
|
||||||
|
flex: 1
|
||||||
|
|
||||||
|
.ng-select-container
|
||||||
|
min-height: 38px
|
||||||
|
|
||||||
|
.ng-value
|
||||||
|
white-space: nowrap
|
||||||
|
overflow: visible
|
||||||
|
|
||||||
|
.ng-dropdown-panel
|
||||||
|
.ng-dropdown-panel-items
|
||||||
|
max-height: 300px
|
||||||
|
|
||||||
|
.ng-option
|
||||||
|
white-space: nowrap
|
||||||
|
overflow: visible
|
||||||
|
text-overflow: ellipsis
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
|
|
||||||
|
|
||||||
import 'zone.js/dist/zone-testing';
|
|
||||||
import { getTestBed } from '@angular/core/testing';
|
|
||||||
import {
|
|
||||||
BrowserDynamicTestingModule,
|
|
||||||
platformBrowserDynamicTesting
|
|
||||||
} from '@angular/platform-browser-dynamic/testing';
|
|
||||||
|
|
||||||
// First, initialize the Angular testing environment.
|
|
||||||
getTestBed().initTestEnvironment(
|
|
||||||
BrowserDynamicTestingModule,
|
|
||||||
platformBrowserDynamicTesting()
|
|
||||||
);
|
|
||||||
@@ -3,13 +3,14 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/app",
|
"outDir": "./out-tsc/app",
|
||||||
"types": []
|
"types": [
|
||||||
|
"@angular/localize"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"files": [
|
|
||||||
"src/main.ts",
|
|
||||||
"src/polyfills.ts"
|
|
||||||
],
|
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.d.ts"
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-12
@@ -2,20 +2,30 @@
|
|||||||
{
|
{
|
||||||
"compileOnSave": false,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "./",
|
"strict": true,
|
||||||
"outDir": "./dist/out-tsc",
|
"noImplicitOverride": true,
|
||||||
"sourceMap": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"esModuleInterop": true,
|
"noImplicitReturns": true,
|
||||||
"declaration": false,
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "es2020",
|
"module": "preserve"
|
||||||
"lib": [
|
},
|
||||||
"es2018",
|
"angularCompilerOptions": {
|
||||||
"dom"
|
"strictInjectionParameters": true,
|
||||||
],
|
"strictInputAccessModifiers": true,
|
||||||
"useDefineForClassFields": false
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,9 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": [
|
"types": [
|
||||||
"jasmine"
|
"vitest/globals"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"files": [
|
|
||||||
"src/test.ts",
|
|
||||||
"src/polyfills.ts"
|
|
||||||
],
|
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.spec.ts",
|
"src/**/*.spec.ts",
|
||||||
"src/**/*.d.ts"
|
"src/**/*.d.ts"
|
||||||
|
|||||||
-152
@@ -1,152 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "tslint:recommended",
|
|
||||||
"rulesDirectory": [
|
|
||||||
"codelyzer"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"align": {
|
|
||||||
"options": [
|
|
||||||
"parameters",
|
|
||||||
"statements"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"array-type": false,
|
|
||||||
"arrow-return-shorthand": true,
|
|
||||||
"curly": true,
|
|
||||||
"deprecation": {
|
|
||||||
"severity": "warning"
|
|
||||||
},
|
|
||||||
"eofline": true,
|
|
||||||
"import-blacklist": [
|
|
||||||
true,
|
|
||||||
"rxjs/Rx"
|
|
||||||
],
|
|
||||||
"import-spacing": true,
|
|
||||||
"indent": {
|
|
||||||
"options": [
|
|
||||||
"spaces"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"max-classes-per-file": false,
|
|
||||||
"max-line-length": [
|
|
||||||
true,
|
|
||||||
140
|
|
||||||
],
|
|
||||||
"member-ordering": [
|
|
||||||
true,
|
|
||||||
{
|
|
||||||
"order": [
|
|
||||||
"static-field",
|
|
||||||
"instance-field",
|
|
||||||
"static-method",
|
|
||||||
"instance-method"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"no-console": [
|
|
||||||
true,
|
|
||||||
"debug",
|
|
||||||
"info",
|
|
||||||
"time",
|
|
||||||
"timeEnd",
|
|
||||||
"trace"
|
|
||||||
],
|
|
||||||
"no-empty": false,
|
|
||||||
"no-inferrable-types": [
|
|
||||||
true,
|
|
||||||
"ignore-params"
|
|
||||||
],
|
|
||||||
"no-non-null-assertion": true,
|
|
||||||
"no-redundant-jsdoc": true,
|
|
||||||
"no-switch-case-fall-through": true,
|
|
||||||
"no-var-requires": false,
|
|
||||||
"object-literal-key-quotes": [
|
|
||||||
true,
|
|
||||||
"as-needed"
|
|
||||||
],
|
|
||||||
"quotemark": [
|
|
||||||
true,
|
|
||||||
"single"
|
|
||||||
],
|
|
||||||
"semicolon": {
|
|
||||||
"options": [
|
|
||||||
"always"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"space-before-function-paren": {
|
|
||||||
"options": {
|
|
||||||
"anonymous": "never",
|
|
||||||
"asyncArrow": "always",
|
|
||||||
"constructor": "never",
|
|
||||||
"method": "never",
|
|
||||||
"named": "never"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"typedef": [
|
|
||||||
true,
|
|
||||||
"call-signature"
|
|
||||||
],
|
|
||||||
"typedef-whitespace": {
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"call-signature": "nospace",
|
|
||||||
"index-signature": "nospace",
|
|
||||||
"parameter": "nospace",
|
|
||||||
"property-declaration": "nospace",
|
|
||||||
"variable-declaration": "nospace"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"call-signature": "onespace",
|
|
||||||
"index-signature": "onespace",
|
|
||||||
"parameter": "onespace",
|
|
||||||
"property-declaration": "onespace",
|
|
||||||
"variable-declaration": "onespace"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"variable-name": {
|
|
||||||
"options": [
|
|
||||||
"ban-keywords",
|
|
||||||
"check-format",
|
|
||||||
"allow-pascal-case"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"whitespace": {
|
|
||||||
"options": [
|
|
||||||
"check-branch",
|
|
||||||
"check-decl",
|
|
||||||
"check-operator",
|
|
||||||
"check-separator",
|
|
||||||
"check-type",
|
|
||||||
"check-typecast"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"component-class-suffix": true,
|
|
||||||
"contextual-lifecycle": true,
|
|
||||||
"directive-class-suffix": true,
|
|
||||||
"no-conflicting-lifecycle": true,
|
|
||||||
"no-host-metadata-property": true,
|
|
||||||
"no-input-rename": true,
|
|
||||||
"no-inputs-metadata-property": true,
|
|
||||||
"no-output-native": true,
|
|
||||||
"no-output-on-prefix": true,
|
|
||||||
"no-output-rename": true,
|
|
||||||
"no-outputs-metadata-property": true,
|
|
||||||
"template-banana-in-box": true,
|
|
||||||
"template-no-negated-async": true,
|
|
||||||
"use-lifecycle-interface": true,
|
|
||||||
"use-pipe-transform-interface": true,
|
|
||||||
"directive-selector": [
|
|
||||||
true,
|
|
||||||
"attribute",
|
|
||||||
"app",
|
|
||||||
"camelCase"
|
|
||||||
],
|
|
||||||
"component-selector": [
|
|
||||||
true,
|
|
||||||
"element",
|
|
||||||
"app",
|
|
||||||
"kebab-case"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohttp"
|
name = "aiohttp"
|
||||||
version = "3.13.2"
|
version = "3.13.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohappyeyeballs" },
|
{ name = "aiohappyeyeballs" },
|
||||||
@@ -24,59 +24,59 @@ dependencies = [
|
|||||||
{ name = "propcache" },
|
{ name = "propcache" },
|
||||||
{ name = "yarl" },
|
{ name = "yarl" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139, upload-time = "2025-10-28T20:57:02.455Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082, upload-time = "2025-10-28T20:57:04.784Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035, upload-time = "2025-10-28T20:57:06.894Z" },
|
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387, upload-time = "2025-10-28T20:57:08.685Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314, upload-time = "2025-10-28T20:57:10.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317, upload-time = "2025-10-28T20:57:12.563Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539, upload-time = "2025-10-28T20:57:14.623Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597, upload-time = "2025-10-28T20:57:16.399Z" },
|
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006, upload-time = "2025-10-28T20:57:18.288Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220, upload-time = "2025-10-28T20:57:20.241Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570, upload-time = "2025-10-28T20:57:22.253Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407, upload-time = "2025-10-28T20:57:24.37Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093, upload-time = "2025-10-28T20:57:26.257Z" },
|
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084, upload-time = "2025-10-28T20:57:28.349Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987, upload-time = "2025-10-28T20:57:30.233Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859, upload-time = "2025-10-28T20:57:32.105Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192, upload-time = "2025-10-28T20:57:34.166Z" },
|
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/36/e2abae1bd815f01c957cbf7be817b3043304e1c87bad526292a0410fdcf9/aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", size = 735234, upload-time = "2025-10-28T20:57:36.415Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/e3/1ee62dde9b335e4ed41db6bba02613295a0d5b41f74a783c142745a12763/aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", size = 490733, upload-time = "2025-10-28T20:57:38.205Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/aa/7a451b1d6a04e8d15a362af3e9b897de71d86feac3babf8894545d08d537/aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", size = 491303, upload-time = "2025-10-28T20:57:40.122Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/1e/209958dbb9b01174870f6a7538cd1f3f28274fdbc88a750c238e2c456295/aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", size = 1717965, upload-time = "2025-10-28T20:57:42.28Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/aa/6a01848d6432f241416bc4866cae8dc03f05a5a884d2311280f6a09c73d6/aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", size = 1667221, upload-time = "2025-10-28T20:57:44.869Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/4f/36c1992432d31bbc789fa0b93c768d2e9047ec8c7177e5cd84ea85155f36/aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", size = 1757178, upload-time = "2025-10-28T20:57:47.216Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/b4/8e940dfb03b7e0f68a82b88fd182b9be0a65cb3f35612fe38c038c3112cf/aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", size = 1838001, upload-time = "2025-10-28T20:57:49.337Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/ef/39f3448795499c440ab66084a9db7d20ca7662e94305f175a80f5b7e0072/aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", size = 1716325, upload-time = "2025-10-28T20:57:51.327Z" },
|
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/51/b311500ffc860b181c05d91c59a1313bdd05c82960fdd4035a15740d431e/aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", size = 1547978, upload-time = "2025-10-28T20:57:53.554Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/64/b9d733296ef79815226dab8c586ff9e3df41c6aff2e16c06697b2d2e6775/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", size = 1682042, upload-time = "2025-10-28T20:57:55.617Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/30/43d3e0f9d6473a6db7d472104c4eff4417b1e9df01774cb930338806d36b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", size = 1680085, upload-time = "2025-10-28T20:57:57.59Z" },
|
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/51/c709f352c911b1864cfd1087577760ced64b3e5bee2aa88b8c0c8e2e4972/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", size = 1728238, upload-time = "2025-10-28T20:57:59.525Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/e2/19bd4c547092b773caeb48ff5ae4b1ae86756a0ee76c16727fcfd281404b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", size = 1544395, upload-time = "2025-10-28T20:58:01.914Z" },
|
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/87/860f2803b27dfc5ed7be532832a3498e4919da61299b4a1f8eb89b8ff44d/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", size = 1742965, upload-time = "2025-10-28T20:58:03.972Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/7f/db2fc7618925e8c7a601094d5cbe539f732df4fb570740be88ed9e40e99a/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", size = 1697585, upload-time = "2025-10-28T20:58:06.189Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/07/9127916cb09bb38284db5036036042b7b2c514c8ebaeee79da550c43a6d6/aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", size = 431621, upload-time = "2025-10-28T20:58:08.636Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/41/554a8a380df6d3a2bba8a7726429a23f4ac62aaf38de43bb6d6cde7b4d4d/aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", size = 457627, upload-time = "2025-10-28T20:58:11Z" },
|
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/8e/3824ef98c039d3951cb65b9205a96dd2b20f22241ee17d89c5701557c826/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", size = 767360, upload-time = "2025-10-28T20:58:13.358Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/0f/6a03e3fc7595421274fa34122c973bde2d89344f8a881b728fa8c774e4f1/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", size = 504616, upload-time = "2025-10-28T20:58:15.339Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/aa/ed341b670f1bc8a6f2c6a718353d13b9546e2cef3544f573c6a1ff0da711/aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", size = 509131, upload-time = "2025-10-28T20:58:17.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/f0/c68dac234189dae5c4bbccc0f96ce0cc16b76632cfc3a08fff180045cfa4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", size = 1864168, upload-time = "2025-10-28T20:58:20.113Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/65/75a9a76db8364b5d0e52a0c20eabc5d52297385d9af9c35335b924fafdee/aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", size = 1719200, upload-time = "2025-10-28T20:58:22.583Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/55/8df2ed78d7f41d232f6bd3ff866b6f617026551aa1d07e2f03458f964575/aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", size = 1843497, upload-time = "2025-10-28T20:58:24.672Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/e0/94d7215e405c5a02ccb6a35c7a3a6cfff242f457a00196496935f700cde5/aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", size = 1935703, upload-time = "2025-10-28T20:58:26.758Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/78/1eeb63c3f9b2d1015a4c02788fb543141aad0a03ae3f7a7b669b2483f8d4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", size = 1792738, upload-time = "2025-10-28T20:58:29.787Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/75/aaf1eea4c188e51538c04cc568040e3082db263a57086ea74a7d38c39e42/aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", size = 1624061, upload-time = "2025-10-28T20:58:32.529Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/c2/3b6034de81fbcc43de8aeb209073a2286dfb50b86e927b4efd81cf848197/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", size = 1789201, upload-time = "2025-10-28T20:58:34.618Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/38/c15dcf6d4d890217dae79d7213988f4e5fe6183d43893a9cf2fe9e84ca8d/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", size = 1776868, upload-time = "2025-10-28T20:58:38.835Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/75/f74fd178ac81adf4f283a74847807ade5150e48feda6aef024403716c30c/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", size = 1790660, upload-time = "2025-10-28T20:58:41.507Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/80/7368bd0d06b16b3aba358c16b919e9c46cf11587dc572091031b0e9e3ef0/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", size = 1617548, upload-time = "2025-10-28T20:58:43.674Z" },
|
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/4b/a6212790c50483cb3212e507378fbe26b5086d73941e1ec4b56a30439688/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", size = 1817240, upload-time = "2025-10-28T20:58:45.787Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/f7/ba5f0ba4ea8d8f3c32850912944532b933acbf0f3a75546b89269b9b7dde/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", size = 1762334, upload-time = "2025-10-28T20:58:47.936Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/83/1a5a1856574588b1cad63609ea9ad75b32a8353ac995d830bf5da9357364/aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", size = 464685, upload-time = "2025-10-28T20:58:50.642Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093, upload-time = "2025-10-28T20:58:52.782Z" },
|
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -93,33 +93,32 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.11.0"
|
version = "4.12.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "sniffio" },
|
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "astroid"
|
name = "astroid"
|
||||||
version = "4.0.2"
|
version = "4.0.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/22/97df040e15d964e592d3a180598ace67e91b7c559d8298bdb3c949dc6e42/astroid-4.0.2.tar.gz", hash = "sha256:ac8fb7ca1c08eb9afec91ccc23edbd8ac73bb22cbdd7da1d488d9fb8d6579070", size = 405714, upload-time = "2025-11-09T21:21:18.373Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/ac/a85b4bfb4cf53221513e27f33cc37ad158fce02ac291d18bee6b49ab477d/astroid-4.0.2-py3-none-any.whl", hash = "sha256:d7546c00a12efc32650b19a2bb66a153883185d3179ab0d4868086f807338b9b", size = 276354, upload-time = "2025-11-09T21:21:16.54Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "attrs"
|
name = "attrs"
|
||||||
version = "25.4.0"
|
version = "26.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -161,27 +160,32 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotlicffi"
|
name = "brotlicffi"
|
||||||
version = "1.2.0.0"
|
version = "1.2.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cffi" },
|
{ name = "cffi" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" },
|
{ url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/37/293a9a0a7caf17e6e657668bebb92dfe730305999fe8c0e2703b8888789c/brotlicffi-1.2.0.0-cp38-abi3-win32.whl", hash = "sha256:23e5c912fdc6fd37143203820230374d24babd078fc054e18070a647118158f6", size = 343085, upload-time = "2025-11-21T18:17:48.887Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/6b/6e92009df3b8b7272f85a0992b306b61c34b7ea1c4776643746e61c380ac/brotlicffi-1.2.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:f139a7cdfe4ae7859513067b736eb44d19fae1186f9e99370092f6915216451b", size = 378586, upload-time = "2025-11-21T18:17:50.531Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2025.11.12"
|
version = "2026.2.25"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -231,43 +235,59 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "charset-normalizer"
|
name = "charset-normalizer"
|
||||||
version = "3.4.4"
|
version = "3.4.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
{ url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
{ url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
{ url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
{ url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
{ url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
{ url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -281,32 +301,47 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "curl-cffi"
|
name = "curl-cffi"
|
||||||
version = "0.13.0"
|
version = "0.14.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
{ name = "cffi" },
|
{ name = "cffi" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303, upload-time = "2025-08-06T13:05:42.988Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633, upload-time = "2025-12-16T03:25:07.931Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337, upload-time = "2025-08-06T13:05:28.985Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277, upload-time = "2025-12-16T03:24:49.607Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613, upload-time = "2025-08-06T13:05:31.027Z" },
|
{ url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650, upload-time = "2025-12-16T03:24:51.518Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353, upload-time = "2025-08-06T13:05:32.273Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918, upload-time = "2025-12-16T03:24:52.862Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378, upload-time = "2025-08-06T13:05:33.672Z" },
|
{ url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624, upload-time = "2025-12-16T03:24:54.579Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585, upload-time = "2025-08-06T13:05:35.28Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654, upload-time = "2025-12-16T03:24:56.507Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831, upload-time = "2025-08-06T13:05:37.078Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969, upload-time = "2025-12-16T03:24:57.885Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908, upload-time = "2025-08-06T13:05:38.741Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133, upload-time = "2025-12-16T03:24:59.261Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510, upload-time = "2025-08-06T13:05:40.451Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167, upload-time = "2025-12-16T03:25:00.946Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464, upload-time = "2025-12-16T03:25:02.922Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416, upload-time = "2025-12-16T03:25:04.902Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deno"
|
||||||
|
version = "2.7.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/87/b4/e893908807648b8c499a085cf47c9ca6418a060b0f12e73f128478ada409/deno-2.7.7.tar.gz", hash = "sha256:5798bba73f89ddf50fa33044c8a44fe708fb19ab77b3ef98d02f4124e760fb65", size = 8166, upload-time = "2026-03-19T13:57:09.905Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/08/362f834c64798033ca56a02a1a4e8feca653b9b767aab4a854069ba8c801/deno-2.7.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:70be65294ee575b2e5ee66b587c459500984b1df17505fd6f5e7bffad402de0f", size = 46934365, upload-time = "2026-03-19T13:56:54.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/3f/cdbe9daa33e997f26610ee7f554e51ba2c8fd7a18abcbc9c6069e6386164/deno-2.7.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:65641b2dd299e3a4aae4f080d4e32d632bbcf44d77f72f97f61aa7b68ded4747", size = 43831345, upload-time = "2026-03-19T13:56:57.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/e7/5f63b2a64fc2f7a7ce6c73e9e847c41034283890e6edec0b2791518b7edd/deno-2.7.7-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:cc90d761472df285a8709483d3615fbd2faf4bbc162530196b5a112e4a561016", size = 47571993, upload-time = "2026-03-19T13:57:00.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/f2/68f4bb53de09970744f905628cff011bd6964f2f00f263140dcc9412a7b5/deno-2.7.7-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ced70363e30a7e3f27f614ffd46d69ccf1dd57633f0df6a3c6375ed2c803aa7", size = 49577613, upload-time = "2026-03-19T13:57:03.766Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/db/2fa6239c0d4df46ef6f3f43d55133aeda6cdd6668c6044d275548a95da24/deno-2.7.7-py3-none-win_amd64.whl", hash = "sha256:e614f666c169ade86a3a089a15a32b9a2002d1ad3294f1fbc8a1bd50c2bac4ab", size = 48802184, upload-time = "2026-03-19T13:57:07.328Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dill"
|
name = "dill"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -401,12 +436,21 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "isort"
|
name = "iniconfig"
|
||||||
version = "7.0.0"
|
version = "2.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "isort"
|
||||||
|
version = "8.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -428,12 +472,15 @@ dependencies = [
|
|||||||
{ name = "mutagen" },
|
{ name = "mutagen" },
|
||||||
{ name = "python-socketio" },
|
{ name = "python-socketio" },
|
||||||
{ name = "watchfiles" },
|
{ name = "watchfiles" },
|
||||||
{ name = "yt-dlp", extra = ["curl-cffi", "default"] },
|
{ name = "yt-dlp", extra = ["curl-cffi", "default", "deno"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pylint" },
|
{ name = "pylint" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-aiohttp" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -443,91 +490,96 @@ requires-dist = [
|
|||||||
{ name = "mutagen" },
|
{ name = "mutagen" },
|
||||||
{ name = "python-socketio", specifier = ">=5.0,<6.0" },
|
{ name = "python-socketio", specifier = ">=5.0,<6.0" },
|
||||||
{ name = "watchfiles" },
|
{ name = "watchfiles" },
|
||||||
{ name = "yt-dlp", extras = ["default", "curl-cffi"] },
|
{ name = "yt-dlp", extras = ["default", "curl-cffi", "deno"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [{ name = "pylint" }]
|
dev = [
|
||||||
|
{ name = "pylint" },
|
||||||
|
{ name = "pytest", specifier = ">=8.0" },
|
||||||
|
{ name = "pytest-aiohttp", specifier = ">=1.0" },
|
||||||
|
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multidict"
|
name = "multidict"
|
||||||
version = "6.7.0"
|
version = "6.7.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" },
|
{ url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" },
|
{ url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" },
|
{ url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" },
|
{ url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" },
|
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" },
|
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" },
|
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" },
|
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" },
|
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" },
|
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -540,12 +592,30 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "packaging"
|
||||||
version = "4.5.0"
|
version = "26.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.9.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -619,11 +689,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pycparser"
|
name = "pycparser"
|
||||||
version = "2.23"
|
version = "3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -656,9 +726,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pylint"
|
name = "pylint"
|
||||||
version = "4.0.3"
|
version = "4.0.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "astroid" },
|
{ name = "astroid" },
|
||||||
@@ -669,34 +748,76 @@ dependencies = [
|
|||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
{ name = "tomlkit" },
|
{ name = "tomlkit" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/9c/0500020a5446031220f487ca0c762713c6f3ddad7231b811aaf1d473f6aa/pylint-4.0.3.tar.gz", hash = "sha256:a427fe76e0e5355e9fb9b604fd106c419cafb395886ba7f3cebebb03f30e081d", size = 1570368, upload-time = "2025-11-13T15:54:41.394Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/01/b8acd4087102c774d432a6663bac4857405c64771445c0a3110828bc5c88/pylint-4.0.3-py3-none-any.whl", hash = "sha256:896d09afb0e78bbf2e030cd1f3d8dc92771a51f7e46828cbc3948a89cd03433a", size = 536199, upload-time = "2025-11-13T15:54:39.734Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-aiohttp"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-engineio"
|
name = "python-engineio"
|
||||||
version = "4.12.3"
|
version = "4.13.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "simple-websocket" },
|
{ name = "simple-websocket" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/d8/63e5535ab21dc4998ba1cfe13690ccf122883a38f025dca24d6e56c05eba/python_engineio-4.12.3.tar.gz", hash = "sha256:35633e55ec30915e7fc8f7e34ca8d73ee0c080cec8a8cd04faf2d7396f0a7a7a", size = 91910, upload-time = "2025-09-28T06:31:36.765Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/f0/c5aa0a69fd9326f013110653543f36ece4913c17921f3e1dbd78e1b423ee/python_engineio-4.12.3-py3-none-any.whl", hash = "sha256:7c099abb2a27ea7ab429c04da86ab2d82698cdd6c52406cb73766fe454feb7e1", size = 59637, upload-time = "2025-09-28T06:31:35.354Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-socketio"
|
name = "python-socketio"
|
||||||
version = "5.15.0"
|
version = "5.16.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "bidict" },
|
{ name = "bidict" },
|
||||||
{ name = "python-engineio" },
|
{ name = "python-engineio" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/a8/5f7c805dd6d0d6cba91d3ea215b4b88889d1b99b71a53c932629daba53f1/python_socketio-5.15.0.tar.gz", hash = "sha256:d0403ababb59aa12fd5adcfc933a821113f27bd77761bc1c54aad2e3191a9b69", size = 126439, upload-time = "2025-11-22T18:50:21.062Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/fa/1ef2f8537272a2f383d72b9301c3ef66a49710b3bb7dcb2bd138cf2920d1/python_socketio-5.15.0-py3-none-any.whl", hash = "sha256:e93363102f4da6d8e7a8872bf4908b866c40f070e716aa27132891e643e2687c", size = 79451, upload-time = "2025-11-22T18:50:19.416Z" },
|
{ url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -726,31 +847,22 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" },
|
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sniffio"
|
|
||||||
version = "1.3.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tomlkit"
|
name = "tomlkit"
|
||||||
version = "0.13.3"
|
version = "0.14.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.5.0"
|
version = "2.6.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -812,22 +924,38 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "websockets"
|
name = "websockets"
|
||||||
version = "15.0.1"
|
version = "16.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -844,89 +972,97 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yarl"
|
name = "yarl"
|
||||||
version = "1.22.0"
|
version = "1.23.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "multidict" },
|
{ name = "multidict" },
|
||||||
{ name = "propcache" },
|
{ name = "propcache" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" },
|
{ url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" },
|
{ url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" },
|
{ url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" },
|
{ url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
|
{ url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" },
|
{ url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" },
|
{ url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" },
|
{ url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
|
{ url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
|
{ url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
|
{ url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" },
|
{ url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" },
|
{ url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" },
|
{ url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" },
|
{ url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" },
|
{ url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" },
|
{ url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" },
|
{ url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yt-dlp"
|
name = "yt-dlp"
|
||||||
version = "2025.12.8"
|
version = "2026.3.17"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/14/77/db924ebbd99d0b2b571c184cb08ed232cf4906c6f9b76eed763cd2c84170/yt_dlp-2025.12.8.tar.gz", hash = "sha256:b773c81bb6b71cb2c111cfb859f453c7a71cf2ef44eff234ff155877184c3e4f", size = 3088947, upload-time = "2025-12-08T00:16:01.649Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/8b/34/7c6b4e3f89cb6416d2cd7ab6dab141a1df97ab0fb22d15816db2c92148c9/yt_dlp-2026.3.17.tar.gz", hash = "sha256:ba7aa31d533f1ffccfe70e421596d7ca8ff0bf1398dc6bb658b7d9dec057d2c9", size = 3119221, upload-time = "2026-03-17T23:43:00.244Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/2f/98c3596ad923f8efd32c90dca62e241e8ad9efcebf20831173c357042ba0/yt_dlp-2025.12.8-py3-none-any.whl", hash = "sha256:36e2584342e409cfbfa0b5e61448a1c5189e345cf4564294456ee509e7d3e065", size = 3291464, upload-time = "2025-12-08T00:15:58.556Z" },
|
{ url = "https://files.pythonhosted.org/packages/cd/13/5093bcb954878e50f7217fd2ab94282b53934022e4e4a03265582da83bf5/yt_dlp-2026.3.17-py3-none-any.whl", hash = "sha256:32992db94303a8a5d211a183f2174834fe7f8c29d83ed2e7a324eae97a8f26d8", size = 3315134, upload-time = "2026-03-17T23:42:57.863Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -944,12 +1080,15 @@ default = [
|
|||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
{ name = "yt-dlp-ejs" },
|
{ name = "yt-dlp-ejs" },
|
||||||
]
|
]
|
||||||
|
deno = [
|
||||||
|
{ name = "deno" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yt-dlp-ejs"
|
name = "yt-dlp-ejs"
|
||||||
version = "0.3.2"
|
version = "0.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/de/72/57d02cf78eb45126bd171298d6a58a5bd48ce1a398b6b7ff00fc904f1f0c/yt_dlp_ejs-0.3.2.tar.gz", hash = "sha256:31a41292799992bdc913e03c9fac2a8c90c82a5cbbc792b2e3373b01da841e3e", size = 34678, upload-time = "2025-12-07T23:44:48.258Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/e6/cceb9530e8f4e5940f6f7822d90e9d94f1b85343329a16baaf47bbbb3de1/yt_dlp_ejs-0.8.0.tar.gz", hash = "sha256:d5fa1639f63b5c4af8d932495f60689d5370f1a095782c944f7f62a303eb104e", size = 96571, upload-time = "2026-03-17T22:49:19.299Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/0d/1f0d7a735ca60b87953271b15d00eff5eef05f6118390ddf6f81982526ed/yt_dlp_ejs-0.3.2-py3-none-any.whl", hash = "sha256:f2dc6b3d1b909af1f13e021621b0af048056fca5fb07c4db6aa9bbb37a4f66a9", size = 53252, upload-time = "2025-12-07T23:44:46.605Z" },
|
{ url = "https://files.pythonhosted.org/packages/e3/bd/520769863744b669440a924271a6159ddd82ad5ae26b4ac4d4b69e9f8d44/yt_dlp_ejs-0.8.0-py3-none-any.whl", hash = "sha256:79300e5fca7f937a1eeede11f0456862c1b41107ce1d726871e0207424f4bdb4", size = 53443, upload-time = "2026-03-17T22:49:17.736Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user