Compare commits

...

85 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 5a38ba3de0 Initial plan 2026-03-07 14:15:45 +00:00
Alex a2740375be Merge pull request #922 from alexta69/copilot/scan-open-issues-for-fixes
Fix #898, #542, #561, #504: config leak, custom-dirs fallback, NTFS path sanitization
2026-03-07 16:06:12 +02:00
copilot-swe-agent[bot] 2736425e19 Revert #504 default change: restore original playlist/channel output templates
Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
2026-03-07 14:00:29 +00:00
copilot-swe-agent[bot] 0d905c0b61 Fix issues #898, #542, #561, #504
Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
2026-03-07 07:19:05 +00:00
copilot-swe-agent[bot] 6de4a56f28 Remove DEFAULT_DOWNLOAD_FOLDER feature
Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
2026-03-07 06:37:09 +00:00
copilot-swe-agent[bot] 1f4c4df847 Implement DEFAULT_DOWNLOAD_FOLDER and CLEAR_COMPLETED_AFTER features (#875, #869)
Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
2026-03-06 15:37:35 +00:00
copilot-swe-agent[bot] d211f24e00 Initial plan 2026-03-06 15:27:55 +00:00
Alex Shnitman 13acd5b309 upgrade dependencies 2026-03-06 15:44:20 +02:00
Alex Shnitman fc5f8cf8ca pin deno version to 2.7.2 2026-03-06 15:41:08 +02:00
Alex Shnitman 4565d5abb3 use precompiled binaries of bgutils POT provider 2026-03-06 14:20:51 +02:00
Alex Shnitman 54e25484c5 some fixes in cookie upload functionality 2026-03-06 14:20:16 +02:00
ddmoney420 7cfb0c3a1d Add cookie file upload for authenticated downloads 2026-03-04 13:29:43 -07:00
Alex Shnitman d2e6c079f9 upgrade dependencies; upgrade yt-dlp from 2026.2.21 to 2026.3.3 2026-03-03 20:28:35 +02:00
ddmoney420 3587098e80 Fix deleted playlist videos being re-queued during add
When adding a playlist, deleting a video from the queue causes it to be
re-added on the next loop iteration because queue.exists() returns False
for the deleted key. Track canceled URLs in a set so __add_entry skips
them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:17:50 -07:00
Alex Shnitman 1915bdfc46 refactor: simplify filename generation by removing unnecessary relative path computation (closes #916, closes #917) 2026-03-02 20:29:29 +02:00
Alex Shnitman 58c317f7cd enhance playlist addition cancellation and improve error handling UI 2026-03-02 20:21:04 +02:00
ddmoney420 880eda8435 feat: cancel playlist adding mid-operation (closes #840) 2026-03-01 19:11:29 -07:00
ddmoney420 fd3aaea9d9 feat: expandable error details with copy-to-clipboard (closes #143) 2026-03-01 19:06:58 -07:00
ddmoney420 da84753e20 feat: sort completed downloads by newest first (closes #610) 2026-03-01 19:02:04 -07:00
Alex 7427cbb0c0 Merge pull request #904 from vitaliibudnyi/feat/captions-format
Captions extraction feature with advanced options
2026-02-27 12:59:46 +02:00
Alex Shnitman 053e41cf52 code review fixes 2026-02-27 12:58:50 +02:00
vitaliibudnyi 77da359234 fix: for 'text only' subs now download .txt instead of an intermediate .srt 2026-02-27 12:46:23 +02:00
vitaliibudnyi 8dff6448b2 add "text only" as another advanced option for captions format 2026-02-27 12:46:23 +02:00
vitaliibudnyi dd4e05325a change delaut captions type to .srt 2026-02-27 12:46:23 +02:00
vitaliibudnyi ce9703cd04 add advanced options for captions download format 2026-02-27 12:46:23 +02:00
vitaliibudnyi 973a87ffc6 add "captions" as download format 2026-02-27 12:46:23 +02:00
AutoUpdater e24890fd9b upgrade yt-dlp from 2026.2.4 to 2026.2.21 2026-02-22 00:12:26 +00:00
Alex Shnitman 5170c708cd upgrade dependencies 2026-02-19 09:35:29 +02:00
Alex Shnitman 56258a4f1b disallow upward directory traversal in request-generated templates 2026-02-19 09:32:23 +02:00
Alex Shnitman 3bf7fb51f4 fix filepath regression 2026-02-14 08:58:01 +02:00
Alex Shnitman 8ae06c65d0 Refactor download status handling to ensure correct file path processing and task synchronization (closes #872) 2026-02-13 16:29:57 +02:00
Alex 97378d8704 Merge pull request #892 from ivanbarsukov/template_substitution
Refactor output template field substitution logic
2026-02-12 22:17:49 +02:00
Alex Shnitman de7e1418b5 add a missed substitution 2026-02-12 22:16:59 +02:00
Ivan Barsukov f47e5db284 Refactor output template field substitution logic 2026-02-09 14:49:50 +03:00
Alex Shnitman 76bdb376c3 cont. fix channel / playlist detection logic (closes #894) 2026-02-08 18:11:56 +02:00
Alex Shnitman 9896ce6820 fix channel / playlist detection logic (closes #894) 2026-02-08 12:14:35 +02:00
Alex Shnitman 79d0c3895e consolidate adding logic 2026-02-07 17:33:48 +02:00
Adam Fendley ffe1112dc6 Fix __add_entry parameters; remove duplicated code between channel and playlist processing 2026-02-07 17:30:40 +02:00
Adam Fendley 393add34b1 Add support for downloading an entire channel 2026-02-07 17:30:40 +02:00
Alex Shnitman 96e1863a68 change UID/GID to PUID/PGID; legacy name also supported (#889) 2026-02-06 15:31:01 +02:00
Alex Shnitman 46fbf92c00 delete uv after install 2026-02-04 21:09:42 +02:00
Alex Shnitman 297cac378c upgrade yt-dlp to 2026.2.4, and other dependencies 2026-02-04 20:56:02 +02:00
Alex Shnitman 9df7776c79 add BgUtils POT Provider (closes #886) 2026-02-03 23:02:46 +02:00
AutoUpdater c28cedacb7 upgrade yt-dlp from 2026.1.29 to 2026.1.31 2026-02-01 00:13:59 +00:00
AutoUpdater a77043bde9 upgrade yt-dlp from 2025.12.8 to 2026.1.29 2026-01-30 00:11:48 +00:00
Alex 3ce9021143 Merge pull request #877 from its-wizza/optional-chown-download-dir
Add environment variable to skip changing ownership of directories on startup
2026-01-29 23:10:41 +02:00
Lachlan Wisdom c7ce543704 Update ownership settings in README 2026-01-13 22:53:15 +11:00
Lachlan Wisdom 6b9461c8a8 Simplify directory ownership changes in entrypoint
Refactor ownership change logic for directories.
2026-01-13 22:47:56 +11:00
Lachlan Wisdom 38a77d19f5 Add CHOWN_DOWNLOAD_DIR option to README 2026-01-13 22:27:24 +11:00
Lachlan Wisdom 6a9098ab32 Update ownership handling in docker-entrypoint.sh
Refactor ownership change logic for directories
2026-01-13 22:21:27 +11:00
Alex Shnitman b179535711 upgrade dependencies 2026-01-11 20:38:06 +02:00
Alex 3f1b89e04a Merge pull request #876 from alexta69/copilot/fix-clear-completed-error
Fix undefined access error when bulk deleting downloads
2026-01-10 18:24:02 +02:00
copilot-swe-agent[bot] 846c4f0e52 Fix bulk delete error by making delById more defensive
- Extract map reference to local variable before iteration
- Change from forEach to for-of loop for better error handling
- Add null check on map before iterating
- Add @popperjs/core peer dependency for ng-bootstrap
- Update .gitignore to exclude package-lock.json

Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
2026-01-10 16:17:22 +00:00
copilot-swe-agent[bot] c13431c10d Initial plan 2026-01-10 16:08:45 +00:00
Alex Shnitman 9be0781c7f remove unnecessary DOWNLOAD_MODE config; always run concurrently (can limit to 1 for sequential mode) 2026-01-09 14:33:12 +02:00
Alex Shnitman e378179e05 remove playlist strict mode (make it always true) 2026-01-09 14:26:50 +02:00
Alex Shnitman 5a7dd8769b document HOST and PORT environment variables (closes #815) 2026-01-09 14:07:23 +02:00
Alex Shnitman e601ce99f5 add file command to the docker image (fixes #870) 2026-01-08 22:08:44 +02:00
Alex a74b201ed8 Merge pull request #862 from AlvinRamoutar/task/repair-persistentqueues
feature/repair-persistent-queues
2026-01-08 21:29:23 +02:00
AlvinRamoutar 191f17ee38 syntax changes + null logic update for dbm repair 2026-01-05 18:13:42 -05:00
Alex a002af9bf2 Merge pull request #864 from alexta69/dependabot/github_actions/github-actions-151e9c363d
Bump the github-actions group with 5 updates
2026-01-02 08:21:27 +02:00
dependabot[bot] 37aaa29efb Bump the github-actions group with 5 updates
Bumps the github-actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `6` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `5` | `6` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `1` | `2` |
| [actions/setup-python](https://github.com/actions/setup-python) | `4` | `6` |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `6` | `7` |


Updates `actions/checkout` from 4 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

Updates `docker/build-push-action` from 5 to 6
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

Updates `softprops/action-gh-release` from 1 to 2
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2)

Updates `actions/setup-python` from 4 to 6
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4...v6)

Updates `astral-sh/setup-uv` from 6 to 7
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: softprops/action-gh-release
  dependency-version: '2'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: astral-sh/setup-uv
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-02 06:15:39 +00:00
Alex d10f2a0358 Merge pull request #863 from cclauss/patch-1
Keep GitHub Actions up to date with GitHub's Dependabot
2026-01-02 08:14:46 +02:00
Alex c7008763d7 Merge pull request #861 from ikatkov/split-chapters-in-ui
Add video/audio chapter splitting with UI controls
2026-01-01 23:22:57 +02:00
Christian Clauss 351058e9f4 Keep GitHub Actions up to date with GitHub's Dependabot
* [Keeping your software supply chain secure with Dependabot](https://docs.github.com/en/code-security/dependabot)
* [Keeping your actions up to date with Dependabot](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot)
* [Configuration options for the `dependabot.yml` file - package-ecosystem](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem)

To see all GitHub Actions dependencies, run the command:
% `git grep 'uses: ' .github/workflows/`
2025-12-31 13:05:26 +01:00
AlvinRamoutar d799a4a8eb feature/repair-persistent-queues 2025-12-31 04:25:51 -05:00
ikatkov df87a1aa2b Merge branch 'alexta69:master' into split-chapters-in-ui 2025-12-31 00:16:52 -08:00
Igor Katkov 02480afddf feat: Use OUTPUT_TEMPLATE_CHAPTER default setting 2025-12-31 00:13:55 -08:00
Igor Katkov d51f2ce628 feat: Undo bogus formatting changes 2025-12-30 23:33:01 -08:00
Igor Katkov 962929d42d feat: Implement chapter splitting functionality with UI controls, yt-dlp integration, and chapter file tracking. 2025-12-30 22:07:49 -08:00
Alex 179452b4f4 Merge pull request #858 from ikatkov/master
Improves logging, helpful when debugging yt-dlp options
2025-12-30 22:40:16 +02:00
ikatkov 4fce74d1ed Merge pull request #1 from ikatkov/logging-fix
Logging fix
2025-12-30 10:22:31 -08:00
Igor Katkov 09a2e95515 fix: Root logger aligns with config.LOGLEVEL 2025-12-30 10:19:30 -08:00
Igor Katkov d947876a71 fix: pass DEBUG log level to ytdl 2025-12-30 10:01:43 -08:00
Igor Katkov 6ba681a3cd fix: Moved code to respect loggin level in main.py 2025-12-30 08:45:54 -08:00
Alex 1f8fa7744e Merge pull request #857 from mercury233/patch-1
fix completed result tooltip
2025-12-27 12:17:40 +02:00
mercury233 092765535f fix completed result tooltip 2025-12-27 10:48:57 +08:00
Alex 90299b227e Merge pull request #855 from alemonmk/suppress-dl-progress-logs
Suppress download progress update in logs
2025-12-26 17:43:36 +02:00
Alex 6445517751 Merge pull request #848 from alemonmk/fix-crlf
Convert files to LF line ending
2025-12-26 14:21:17 +02:00
Lemon Lam dae710a339 Suppress download progress update
...by sending them to debug
2025-12-26 19:42:09 +08:00
Lemon Lam 318f4f9f21 Convert to LF 2025-12-26 19:30:26 +08:00
Alex ca8e9e7907 Merge pull request #844 from pierrenedelec/clean-FE
feat(frontend): modernize Angular App
2025-12-25 21:27:52 +02:00
Pierre Nédélec 183c4ba898 feat(frontend): modernize Angular app 2025-12-15 01:56:47 +01:00
Alex c6d487e48a Merge pull request #846 from aloki/master
Switching to a maintained fork of watchtower
2025-12-14 20:17:53 +02:00
Aleksei 77c3c93157 Switching to a maintained fork of watchtower
The original repository has not been maintained for a long time
2025-12-14 07:42:25 +03:00
64 changed files with 11507 additions and 18437 deletions
+13
View File
@@ -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
+4 -4
View File
@@ -15,7 +15,7 @@ jobs:
run: echo "::set-output name=date::$(date +'%Y.%m.%d')" run: echo "::set-output name=date::$(date +'%Y.%m.%d')"
- -
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@v3
@@ -37,7 +37,7 @@ jobs:
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@v6
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
@@ -74,7 +74,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 +167,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 }}
+3 -3
View File
@@ -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: |
+1
View File
@@ -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
+43 -10
View File
@@ -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,18 +16,51 @@ 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 v2.7.2 && \
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
@@ -40,4 +73,4 @@ EXPOSE 8081
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"]
+19 -27
View File
@@ -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
+51 -1
View File
@@ -1,6 +1,17 @@
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")
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(format: str, quality: str) -> str: def get_format(format: str, quality: str) -> str:
@@ -26,6 +37,10 @@ def get_format(format: str, quality: str) -> str:
# Quality is irrelevant in this case since we skip the download # Quality is irrelevant in this case since we skip the download
return "bestaudio/best" return "bestaudio/best"
if format == "captions":
# Quality is irrelevant in this case since we skip the download
return "bestaudio/best"
if format in AUDIO_FORMATS: if format in AUDIO_FORMATS:
# Audio quality needs to be set post-download, set in opts # Audio quality needs to be set post-download, set in opts
return f"bestaudio[ext={format}]/bestaudio/best" return f"bestaudio[ext={format}]/bestaudio/best"
@@ -51,7 +66,14 @@ def get_format(format: str, quality: str) -> str:
raise Exception(f"Unkown format {format}") raise Exception(f"Unkown format {format}")
def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict: def get_opts(
format: str,
quality: str,
ytdl_opts: dict,
subtitle_format: str = "srt",
subtitle_language: str = "en",
subtitle_mode: str = "prefer_manual",
) -> dict:
""" """
Returns extra download options Returns extra download options
Mostly postprocessing options Mostly postprocessing options
@@ -98,6 +120,34 @@ def get_opts(format: str, quality: str, ytdl_opts: dict) -> dict:
{"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"} {"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"}
) )
if format == "captions":
mode = _normalize_caption_mode(subtitle_mode)
language = _normalize_subtitle_language(subtitle_language)
opts["skip_download"] = True
requested_subtitle_format = (subtitle_format or "srt").lower()
# txt is a derived, non-timed format produced from SRT after download.
if requested_subtitle_format == "txt":
requested_subtitle_format = "srt"
opts["subtitlesformat"] = 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 []
) )
+188 -27
View File
@@ -21,6 +21,26 @@ from yt_dlp.version import __version__ as yt_dlp_version
log = logging.getLogger('main') log = logging.getLogger('main')
def parseLogLevel(logLevel):
match logLevel:
case 'DEBUG':
return logging.DEBUG
case 'INFO':
return logging.INFO
case 'WARNING':
return logging.WARNING
case 'ERROR':
return logging.ERROR
case 'CRITICAL':
return logging.CRITICAL
case _:
return None
# 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 +56,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 +71,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 +98,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 +144,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 +162,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):
@@ -133,6 +191,9 @@ 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}$')
class Notifier(DownloadQueueNotifier): class Notifier(DownloadQueueNotifier):
async def added(self, dl): async def added(self, dl):
@@ -140,7 +201,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):
@@ -220,24 +281,67 @@ async def add(request):
format = post.get('format') format = post.get('format')
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_format = post.get('subtitle_format')
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_format is None:
subtitle_format = 'srt'
if subtitle_language is None:
subtitle_language = 'en'
if subtitle_mode is None:
subtitle_mode = 'prefer_manual'
subtitle_format = str(subtitle_format).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 subtitle_format not in VALID_SUBTITLE_FORMATS:
raise web.HTTPBadRequest(reason=f'subtitle_format must be one of {sorted(VALID_SUBTITLE_FORMATS)}')
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)}')
playlist_item_limit = int(playlist_item_limit) playlist_item_limit = int(playlist_item_limit)
status = await dqueue.add(url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start) status = await dqueue.add(
url,
quality,
format,
folder,
custom_name_prefix,
playlist_item_limit,
auto_start,
split_by_chapters,
chapter_template,
subtitle_format,
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 request.json()
@@ -258,6 +362,65 @@ async def start(request):
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'}))
size = 0
with open(COOKIES_PATH, 'wb') as f:
while True:
chunk = await field.read_chunk()
if not chunk:
break
size += len(chunk)
if size > 1_000_000: # 1MB limit
os.remove(COOKIES_PATH)
return web.Response(status=400, text=serializer.encode({'status': 'error', 'msg': 'Cookie file too large (max 1MB)'}))
f.write(chunk)
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,7 +439,7 @@ 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:
@@ -304,8 +467,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
@@ -369,6 +536,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 +556,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 +563,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)
+485 -72
View File
@@ -1,4 +1,5 @@
import os import os
import shutil
import yt_dlp import yt_dlp
from collections import OrderedDict from collections import OrderedDict
import shelve import shelve
@@ -8,13 +9,75 @@ 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')
@lru_cache(maxsize=None)
def _compile_outtmpl_pattern(field: str) -> re.Pattern:
"""Compile a regex pattern to match a specific field in an output template, including optional format specifiers."""
conversion_types = f"[{re.escape(STR_FORMAT_TYPES)}]"
return re.compile(STR_FORMAT_RE_TMPL.format(re.escape(field), conversion_types))
# 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:
coerced_value = value
return f"{prefix}{('%' + format_spec) % coerced_value}"
except (ValueError, TypeError):
return f"{prefix}{value}"
return pattern.sub(replacement, template)
def _convert_generators_to_lists(obj): def _convert_generators_to_lists(obj):
"""Recursively convert generators to lists in a dictionary to make it pickleable.""" """Recursively convert generators to lists in a dictionary to make it pickleable."""
if isinstance(obj, types.GeneratorType): if isinstance(obj, types.GeneratorType):
@@ -26,6 +89,45 @@ def _convert_generators_to_lists(obj):
else: else:
return obj return obj
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):
raise NotImplementedError raise NotImplementedError
@@ -43,7 +145,24 @@ 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,
format,
folder,
custom_name_prefix,
error,
entry,
playlist_item_limit,
split_by_chapters,
chapter_template,
subtitle_format="srt",
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
@@ -59,6 +178,12 @@ class DownloadInfo:
# Convert generators to lists to make entry pickleable # Convert generators to lists to make entry pickleable
self.entry = _convert_generators_to_lists(entry) if entry is not None else None self.entry = _convert_generators_to_lists(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_format = subtitle_format
self.subtitle_language = subtitle_language
self.subtitle_mode = subtitle_mode
self.subtitle_files = []
class Download: class Download:
manager = None manager = None
@@ -68,11 +193,18 @@ class Download:
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.info = info
self.format = get_format(format, quality) self.format = get_format(format, quality)
self.ytdl_opts = get_opts(format, quality, ytdl_opts) self.ytdl_opts = get_opts(
format,
quality,
ytdl_opts,
subtitle_format=getattr(info, 'subtitle_format', 'srt'),
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 +215,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 +231,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 self.info.format == '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 +270,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 +300,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 +324,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 +346,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 self.info.format == 'captions':
requested_subtitle_format = str(getattr(self.info, 'subtitle_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 self.info.format == '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 self.info.format == 'captions' and str(getattr(self.info, 'subtitle_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 self.info.format == 'captions' and (
not getattr(self.info, 'filename', None) or
str(getattr(self.info, 'subtitle_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,17 +413,20 @@ 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):
@@ -236,22 +465,93 @@ 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:
result = subprocess.run(
f"sqlite3 {self.path} '.recover' | sqlite3 {self.path}.tmp",
capture_output=True,
text=True,
shell=True,
timeout=60
)
if result.stderr:
log.debug(f"{log_prefix} failed: {result.stderr}")
else:
shutil.move(f"{self.path}.tmp", self.path)
log.debug(f"{log_prefix}{result.stdout or " was successful, no output"}")
except FileNotFoundError:
log.debug(f"{log_prefix} failed: 'sqlite3' was not found")
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 self.semaphore = asyncio.Semaphore(int(self.config.MAX_CONCURRENT_DOWNLOADS))
# 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.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,31 +570,12 @@ 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) if download.canceled:
log.info(f"Download {download.info.title} was canceled, skipping start.")
async def _run_download(self, download): return
if download.canceled: await download.start(self.notifier)
log.info(f"Download {download.info.title} is canceled; skipping start.") self._post_download_cleanup(download)
return
await download.start(self.notifier)
self._post_download_cleanup(download)
def _post_download_cleanup(self, download): def _post_download_cleanup(self, download):
if download.info.status != 'finished': if download.info.status != 'finished':
@@ -312,14 +593,30 @@ 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 {}),
@@ -329,7 +626,7 @@ class DownloadQueue:
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.DOWNLOAD_DIR if (quality != 'audio' and format not in AUDIO_FORMATS) else self.config.AUDIO_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):
@@ -349,12 +646,18 @@ class DownloadQueue:
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 +671,23 @@ 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,
quality,
format,
folder,
custom_name_prefix,
playlist_item_limit,
auto_start,
split_by_chapters,
chapter_template,
subtitle_format,
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 +702,119 @@ 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') quality,
format,
folder,
custom_name_prefix,
playlist_item_limit,
auto_start,
split_by_chapters,
chapter_template,
subtitle_format,
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,
quality,
format,
folder,
custom_name_prefix,
playlist_item_limit,
auto_start,
split_by_chapters,
chapter_template,
subtitle_format,
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(
entry['id'],
entry.get('title') or entry['id'],
key,
quality,
format,
folder,
custom_name_prefix,
error,
entry,
playlist_item_limit,
split_by_chapters,
chapter_template,
subtitle_format,
subtitle_language,
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,
quality,
format,
folder,
custom_name_prefix,
playlist_item_limit,
auto_start=True,
split_by_chapters=False,
chapter_template=None,
subtitle_format="srt",
subtitle_language="en",
subtitle_mode="prefer_manual",
already=None,
_add_gen=None,
):
log.info(
f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} '
f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} '
f'{subtitle_format=} {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,10 +822,25 @@ 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,
quality,
format,
folder,
custom_name_prefix,
playlist_item_limit,
auto_start,
split_by_chapters,
chapter_template,
subtitle_format,
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:
@@ -444,6 +855,8 @@ 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)
+15 -6
View File
@@ -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
+1 -1
View File
@@ -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",
+27 -64
View File
@@ -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",
@@ -41,17 +37,14 @@
"node_modules/bootstrap/dist/js/bootstrap.bundle.min.js" "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 +61,45 @@
"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"
} }
} },
}, "defaultConfiguration": "development"
"extract-i18n": {
"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": {
-36
View File
@@ -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
}
}));
}
};
-23
View File
@@ -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));
});
});
-11
View File
@@ -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>;
}
}
-14
View File
@@ -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"
]
}
}
+44
View File
@@ -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: {},
}
]);
-32
View File
@@ -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
});
};
-14890
View File
File diff suppressed because it is too large Load Diff
+47 -32
View File
@@ -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.1",
"@angular/common": "^20.3.15", "@angular/common": "^21.2.1",
"@angular/compiler": "^20.3.15", "@angular/compiler": "^21.2.1",
"@angular/core": "^20.3.15", "@angular/core": "^21.2.1",
"@angular/forms": "^20.3.15", "@angular/forms": "^21.2.1",
"@angular/localize": "^20.3.15", "@angular/platform-browser": "^21.2.1",
"@angular/platform-browser": "^20.3.15", "@angular/platform-browser-dynamic": "^21.2.1",
"@angular/platform-browser-dynamic": "^20.3.15", "@angular/service-worker": "^21.2.1",
"@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.1.0",
"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.1",
"@angular/compiler-cli": "^20.3.15", "@angular/cli": "^21.2.1",
"@types/node": "^22.15.29", "@angular/compiler-cli": "^21.2.1",
"codelyzer": "^6.0.2", "@angular/localize": "^21.2.1",
"ts-node": "~10.9.1", "@eslint/js": "^9.39.3",
"tslint": "~6.1.3", "angular-eslint": "21.1.0",
"typescript": "~5.8.3" "eslint": "^9.39.3",
"jsdom": "^27.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "8.47.0",
"vitest": "^4.0.18"
} }
} }
+6852
View File
File diff suppressed because it is too large Load Diff
-402
View File
@@ -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>&nbsp; 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>&nbsp; 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>&nbsp; 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>&nbsp; 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>&nbsp; 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>&nbsp; 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>&nbsp; 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>
-31
View File
@@ -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!');
});
});
-519
View File
@@ -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);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode, provideZonelessChangeDetection, 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()),
]
};
+633
View File
@@ -0,0 +1,633 @@
<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">
<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">
@if(activeTheme){
<fa-icon [icon]="activeTheme.icon" />
}
</button>
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select">
@for (theme of themes; track theme) {
<li>
<button type="button" class="dropdown-item d-flex align-items-center"
[class.active]="activeTheme === theme"
(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-danger btn-lg px-3" type="button" (click)="cancelAdding()">
<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">
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Quality</span>
<select class="form-select"
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading">
@for (q of qualities; track q) {
<option [ngValue]="q.id">{{ q.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Format</span>
<select class="form-select"
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading">
@for (f of formats; track f) {
<option [ngValue]="f.id">{{ f.text }}</option>
}
</select>
</div>
</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>
@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>
@if (format === 'captions') {
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">Subtitles</span>
<select class="form-select"
name="subtitleFormat"
[(ngModel)]="subtitleFormat"
(change)="subtitleFormatChanged()"
[disabled]="addInProgress || downloads.loading"
ngbTooltip="Subtitle output format for captions mode">
@for (fmt of subtitleFormats; track fmt.id) {
<option [ngValue]="fmt.id">{{ fmt.text }}</option>
}
</select>
</div>
@if (subtitleFormat === 'txt') {
<div class="form-text">TXT is generated from SRT by stripping timestamps and cue numbers.</div>
}
</div>
<div class="col-md-4">
<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-4">
<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>
}
<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"
[class.show]="batchImportModalOpen"
[style.display]="batchImportModalOpen ? 'block' : 'none'">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Batch Import URLs</h5>
<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">
@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" />&nbsp; 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" />&nbsp; Download selected</button>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-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-slave-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" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
}
<button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
</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" />&nbsp; {{ 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" />&nbsp; 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" />&nbsp; 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" />&nbsp; 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" />&nbsp; 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" />&nbsp; Download Selected</button>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<app-master-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" />
</th>
<th scope="col">Video</th>
<th scope="col">File Size</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-slave-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 {
<span [style.cursor]="entry[1].status === 'error' ? 'pointer' : 'default'"
(click)="entry[1].status === 'error' ? toggleErrorDetail(entry[0]) : null">
{{entry[1].title}}
@if (entry[1].status === 'error' && !isErrorExpanded(entry[0])) {
<small class="text-danger ms-2">
<fa-icon [icon]="faChevronRight" size="xs" class="me-1" />Click for details
</small>
}
</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>
@if (entry[1].size) {
<span>{{ entry[1].size | fileSize }}</span>
}
</td>
<td>
<div class="d-flex">
@if (entry[1].status === 'error') {
<button type="button" class="btn btn-link" (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"><fa-icon [icon]="faDownload" /></a>
}
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
<button type="button" class="btn btn-link" (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">{{
getChapterFileName(chapterFile.filename) }}</a>
</div>
</td>
<td>
@if (chapterFile.size) {
<span>{{ chapterFile.size | fileSize }}</span>
}
</td>
<td>
<div class="d-flex">
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download
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>
-36
View File
@@ -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 { }
@@ -209,3 +209,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)
+33
View File
@@ -0,0 +1,33 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
vi.hoisted(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
enumerable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});
+871
View File
@@ -0,0 +1,871 @@
import { AsyncPipe, KeyValuePipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { AfterViewInit, Component, ElementRef, viewChild, inject, OnInit } from '@angular/core';
import { Observable, map, distinctUntilChanged } from 'rxjs';
import { FormsModule } from '@angular/forms';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgSelectModule } from '@ng-select/ng-select';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faUpload } from '@fortawesome/free-solid-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service';
import { DownloadsService } from './services/downloads.service';
import { Themes } from './theme';
import { Download, Status, Theme , Quality, Format, Formats, State } from './interfaces';
import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/';
@Component({
selector: 'app-root',
imports: [
FormsModule,
KeyValuePipe,
AsyncPipe,
FontAwesomeModule,
NgbModule,
NgSelectModule,
EtaPipe,
SpeedPipe,
FileSizePipe,
MasterCheckboxComponent,
SlaveCheckboxComponent,
],
templateUrl: './app.html',
styleUrl: './app.sass',
})
export class App implements AfterViewInit, OnInit {
downloads = inject(DownloadsService);
private cookieService = inject(CookieService);
private http = inject(HttpClient);
addUrl!: string;
formats: Format[] = Formats;
qualities!: Quality[];
quality: string;
format: string;
folder!: string;
customNamePrefix!: string;
autoStart: boolean;
playlistItemLimit!: number;
splitByChapters: boolean;
chapterTemplate: string;
subtitleFormat: string;
subtitleLanguage: string;
subtitleMode: string;
addInProgress = false;
cancelRequested = false;
hasCookies = false;
cookieUploadInProgress = false;
themes: Theme[] = Themes;
activeTheme: Theme | undefined;
customDirs$!: Observable<string[]>;
showBatchPanel = false;
batchImportModalOpen = false;
batchImportText = '';
batchImportStatus = '';
importInProgress = false;
cancelImportFlag = false;
ytDlpOptionsUpdateTime: string | null = null;
ytDlpVersion: string | null = null;
metubeVersion: string | null = null;
isAdvancedOpen = false;
sortAscending = false;
expandedErrors: Set<string> = new Set();
cachedSortedDone: [string, Download][] = [];
lastCopiedErrorId: string | null = null;
// Download metrics
activeDownloads = 0;
queuedDownloads = 0;
completedDownloads = 0;
failedDownloads = 0;
totalSpeed = 0;
readonly queueMasterCheckbox = viewChild<MasterCheckboxComponent>('queueMasterCheckboxRef');
readonly queueDelSelected = viewChild.required<ElementRef>('queueDelSelected');
readonly queueDownloadSelected = viewChild.required<ElementRef>('queueDownloadSelected');
readonly doneMasterCheckbox = viewChild<MasterCheckboxComponent>('doneMasterCheckboxRef');
readonly doneDelSelected = viewChild.required<ElementRef>('doneDelSelected');
readonly doneClearCompleted = viewChild.required<ElementRef>('doneClearCompleted');
readonly doneClearFailed = viewChild.required<ElementRef>('doneClearFailed');
readonly doneRetryFailed = viewChild.required<ElementRef>('doneRetryFailed');
readonly doneDownloadSelected = viewChild.required<ElementRef>('doneDownloadSelected');
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;
faSortAmountDown = faSortAmountDown;
faSortAmountUp = faSortAmountUp;
faChevronRight = faChevronRight;
faUpload = faUpload;
subtitleFormats = [
{ id: 'srt', text: 'SRT' },
{ id: 'txt', text: 'TXT (Text only)' },
{ id: 'vtt', text: 'VTT' },
{ id: 'ttml', text: 'TTML' }
];
subtitleLanguages = [
{ id: 'en', text: 'English' },
{ id: 'ar', text: 'Arabic' },
{ id: 'bn', text: 'Bengali' },
{ id: 'bg', text: 'Bulgarian' },
{ id: 'ca', text: 'Catalan' },
{ id: 'cs', text: 'Czech' },
{ id: 'da', text: 'Danish' },
{ id: 'nl', text: 'Dutch' },
{ id: 'es', text: 'Spanish' },
{ id: 'et', text: 'Estonian' },
{ id: 'fi', text: 'Finnish' },
{ id: 'fr', text: 'French' },
{ id: 'de', text: 'German' },
{ id: 'el', text: 'Greek' },
{ id: 'he', text: 'Hebrew' },
{ id: 'hi', text: 'Hindi' },
{ id: 'hu', text: 'Hungarian' },
{ id: 'id', text: 'Indonesian' },
{ id: 'it', text: 'Italian' },
{ id: 'lt', text: 'Lithuanian' },
{ id: 'lv', text: 'Latvian' },
{ id: 'ms', text: 'Malay' },
{ id: 'no', text: 'Norwegian' },
{ id: 'pl', text: 'Polish' },
{ id: 'pt', text: 'Portuguese' },
{ id: 'pt-BR', text: 'Portuguese (Brazil)' },
{ id: 'ro', text: 'Romanian' },
{ id: 'ru', text: 'Russian' },
{ id: 'sk', text: 'Slovak' },
{ id: 'sl', text: 'Slovenian' },
{ id: 'sr', text: 'Serbian' },
{ id: 'sv', text: 'Swedish' },
{ id: 'ta', text: 'Tamil' },
{ id: 'te', text: 'Telugu' },
{ id: 'th', text: 'Thai' },
{ id: 'tr', text: 'Turkish' },
{ id: 'uk', text: 'Ukrainian' },
{ id: 'ur', text: 'Urdu' },
{ id: 'vi', text: 'Vietnamese' },
{ id: 'ja', text: 'Japanese' },
{ id: 'ko', text: 'Korean' },
{ id: 'zh-Hans', text: 'Chinese (Simplified)' },
{ id: 'zh-Hant', text: 'Chinese (Traditional)' },
];
subtitleModes = [
{ id: 'prefer_manual', text: 'Prefer Manual' },
{ id: 'prefer_auto', text: 'Prefer Auto' },
{ id: 'manual_only', text: 'Manual Only' },
{ id: 'auto_only', text: 'Auto Only' },
];
constructor() {
this.format = this.cookieService.get('metube_format') || 'any';
// Needs to be set or qualities won't automatically be set
this.setQualities()
this.quality = this.cookieService.get('metube_quality') || 'best';
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
// Will be set from backend configuration, use empty string as placeholder
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
this.subtitleFormat = this.cookieService.get('metube_subtitle_format') || 'srt';
this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en';
this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual';
const allowedSubtitleFormats = new Set(this.subtitleFormats.map(fmt => fmt.id));
const allowedSubtitleModes = new Set(this.subtitleModes.map(mode => mode.id));
if (!allowedSubtitleFormats.has(this.subtitleFormat)) {
this.subtitleFormat = 'srt';
}
if (!allowedSubtitleModes.has(this.subtitleMode)) {
this.subtitleMode = 'prefer_manual';
}
this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true';
this.activeTheme = this.getPreferredTheme(this.cookieService);
// Subscribe to download updates
this.downloads.queueChanged.subscribe(() => {
this.updateMetrics();
});
this.downloads.doneChanged.subscribe(() => {
this.updateMetrics();
this.rebuildSortedDone();
});
// Subscribe to real-time updates
this.downloads.updated.subscribe(() => {
this.updateMetrics();
});
}
ngOnInit() {
this.downloads.getCookieStatus().subscribe(data => {
this.hasCookies = data?.has_cookies || false;
});
this.getConfiguration();
this.getYtdlOptionsUpdateTime();
this.customDirs$ = this.getMatchingCustomDir();
this.setTheme(this.activeTheme!);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.activeTheme && 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 = 0, failed = 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() {
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(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map((output: any) => {
// 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({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (data:any) => {
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({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (config: any) => {
const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
if (playlistItemLimit !== '0') {
this.playlistItemLimit = playlistItemLimit;
}
// Set chapter template from backend config if not already set by cookie
if (!this.chapterTemplate) {
this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER'];
}
}
});
}
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 });
}
splitByChaptersChanged() {
this.cookieService.set('metube_split_chapters', this.splitByChapters ? 'true' : 'false', { expires: 3650 });
}
chapterTemplateChanged() {
// Restore default if template is cleared - get from configuration
if (!this.chapterTemplate || this.chapterTemplate.trim() === '') {
this.chapterTemplate = this.downloads.configuration['OUTPUT_TEMPLATE_CHAPTER'];
}
this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: 3650 });
}
subtitleFormatChanged() {
this.cookieService.set('metube_subtitle_format', this.subtitleFormat, { expires: 3650 });
}
subtitleLanguageChanged() {
this.cookieService.set('metube_subtitle_language', this.subtitleLanguage, { expires: 3650 });
}
subtitleModeChanged() {
this.cookieService.set('metube_subtitle_mode', this.subtitleMode, { 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
const format = this.formats.find(el => el.id == this.format)
if (format) {
this.qualities = 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,
playlistItemLimit?: number,
autoStart?: boolean,
splitByChapters?: boolean,
chapterTemplate?: string,
subtitleFormat?: string,
subtitleLanguage?: string,
subtitleMode?: string,
) {
url = url ?? this.addUrl
quality = quality ?? this.quality
format = format ?? this.format
folder = folder ?? this.folder
customNamePrefix = customNamePrefix ?? this.customNamePrefix
playlistItemLimit = playlistItemLimit ?? this.playlistItemLimit
autoStart = autoStart ?? this.autoStart
splitByChapters = splitByChapters ?? this.splitByChapters
chapterTemplate = chapterTemplate ?? this.chapterTemplate
subtitleFormat = subtitleFormat ?? this.subtitleFormat
subtitleLanguage = subtitleLanguage ?? this.subtitleLanguage
subtitleMode = subtitleMode ?? this.subtitleMode
// Validate chapter template if chapter splitting is enabled
if (splitByChapters && !chapterTemplate.includes('%(section_number)')) {
alert('Chapter template must include %(section_number)');
return;
}
console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate + ' subtitleFormat=' + subtitleFormat + ' subtitleLanguage=' + subtitleLanguage + ' subtitleMode=' + subtitleMode);
this.addInProgress = true;
this.cancelRequested = false;
this.downloads.add(url, quality, format, folder, customNamePrefix, playlistItemLimit, autoStart, splitByChapters, chapterTemplate, subtitleFormat, subtitleLanguage, subtitleMode).subscribe((status: Status) => {
if (status.status === 'error' && !this.cancelRequested) {
alert(`Error adding URL: ${status.msg}`);
} else if (status.status !== 'error') {
this.addUrl = '';
}
this.addInProgress = false;
this.cancelRequested = false;
});
}
cancelAdding() {
this.cancelRequested = true;
this.downloads.cancelAdd().subscribe({
error: (err) => {
console.error('Failed to cancel adding:', err?.message || err);
}
});
}
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_item_limit,
true,
download.split_by_chapters,
download.chapter_template,
download.subtitle_format,
download.subtitle_language,
download.subtitle_mode,
);
this.downloads.delById('done', [key]).subscribe();
}
delDownload(where: State, id: string) {
this.downloads.delById(where, [id]).subscribe();
}
startSelectedDownloads(where: State){
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe();
}
delSelectedDownloads(where: State) {
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() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
this.downloads.done.forEach((dl, _) => {
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);
}
buildResultItemTooltip(download: Download) {
const parts = [];
if (download.msg) {
parts.push(download.msg);
}
if (download.error) {
parts.push(download.error);
}
return parts.join(' | ');
}
buildChapterDownloadLink(download: Download, chapterFilename: string) {
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
if (download.quality == 'audio' || chapterFilename.endsWith('.mp3')) {
baseDir = this.downloads.configuration["PUBLIC_HOST_AUDIO_URL"];
}
if (download.folder) {
baseDir += download.folder + '/';
}
return baseDir + encodeURIComponent(chapterFilename);
}
getChapterFileName(filepath: string) {
// Extract just the filename from the path
const parts = filepath.split('/');
return parts[parts.length - 1];
}
isNumber(event: KeyboardEvent) {
const charCode = +event.code || 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.playlistItemLimit, this.autoStart, this.splitByChapters, this.chapterTemplate,
this.subtitleFormat, this.subtitleLanguage, this.subtitleMode)
.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 {
// eslint-disable-next-line no-useless-escape
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;
}
toggleSortOrder() {
this.sortAscending = !this.sortAscending;
this.cookieService.set('metube_sort_ascending', this.sortAscending ? 'true' : 'false', { expires: 3650 });
this.rebuildSortedDone();
}
private rebuildSortedDone() {
const result: [string, Download][] = [];
this.downloads.done.forEach((dl, key) => {
result.push([key, dl]);
});
if (!this.sortAscending) {
result.reverse();
}
this.cachedSortedDone = result;
}
toggleErrorDetail(id: string) {
if (this.expandedErrors.has(id)) this.expandedErrors.delete(id);
else this.expandedErrors.add(id);
}
copyErrorMessage(id: string, download: Download) {
const parts: string[] = [];
if (download.title) parts.push(`Title: ${download.title}`);
if (download.url) parts.push(`URL: ${download.url}`);
if (download.msg) parts.push(`Message: ${download.msg}`);
if (download.error) parts.push(`Error: ${download.error}`);
const text = parts.join('\n');
if (!text.trim()) return;
const done = () => {
this.lastCopiedErrorId = id;
setTimeout(() => { this.lastCopiedErrorId = null; }, 1500);
};
const fail = (err?: unknown) => {
console.error('Clipboard write failed:', err);
alert('Failed to copy to clipboard. Your browser may require HTTPS for clipboard access.');
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(done).catch(fail);
} else {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
done();
} catch (e) {
fail(e);
}
}
}
isErrorExpanded(id: string): boolean {
return this.expandedErrors.has(id);
}
onCookieFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;
this.cookieUploadInProgress = true;
this.downloads.uploadCookies(input.files[0]).subscribe({
next: (response) => {
if (response?.status === 'ok') {
this.hasCookies = true;
} else {
this.refreshCookieStatus();
alert(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
}
this.cookieUploadInProgress = false;
input.value = '';
},
error: () => {
this.refreshCookieStatus();
this.cookieUploadInProgress = false;
input.value = '';
alert('Error uploading cookies.');
}
});
}
private formatErrorMessage(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (error && typeof error === 'object') {
const obj = error as Record<string, unknown>;
for (const key of ['msg', 'reason', 'error', 'detail']) {
const value = obj[key];
if (typeof value === 'string' && value.trim()) {
return value;
}
}
try {
return JSON.stringify(error);
} catch {
return 'Unknown error';
}
}
return 'Unknown error';
}
deleteCookies() {
this.downloads.deleteCookies().subscribe({
next: (response) => {
if (response?.status === 'ok') {
this.refreshCookieStatus();
return;
}
this.refreshCookieStatus();
alert(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
},
error: () => {
this.refreshCookieStatus();
alert('Error deleting cookies.');
}
});
}
private refreshCookieStatus() {
this.downloads.getCookieStatus().subscribe(data => {
this.hasCookies = data?.has_cookies || false;
});
}
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);
}
}
+2
View File
@@ -0,0 +1,2 @@
export { MasterCheckboxComponent } from './master-checkbox.component';
export { SlaveCheckboxComponent } from './slave-checkbox.component';
@@ -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-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>
`,
imports: [
FormsModule
]
})
export class MasterCheckboxComponent {
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,22 @@
import { Component, input } from '@angular/core';
import { MasterCheckboxComponent } from './master-checkbox.component';
import { Checkable } from '../interfaces';
import { FormsModule } from '@angular/forms';
@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>
`,
imports: [
FormsModule
]
})
export class SlaveCheckboxComponent {
readonly id = input.required<string>();
readonly master = input.required<MasterCheckboxComponent>();
readonly checkable = input.required<Checkable>();
}
-93
View File
@@ -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]}`;
}
}
-161
View File
@@ -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);
}
}
+3
View File
@@ -0,0 +1,3 @@
export interface Checkable {
checked: boolean;
}
+27
View File
@@ -0,0 +1,27 @@
export interface Download {
id: string;
title: string;
url: string;
quality: string;
format: string;
folder: string;
custom_name_prefix: string;
playlist_item_limit: number;
split_by_chapters?: boolean;
chapter_template?: string;
subtitle_format?: string;
subtitle_language?: string;
subtitle_mode?: string;
status: string;
msg: string;
percent: number;
speed: number;
eta: number;
filename: string;
checked: boolean;
size?: number;
error?: string;
deleting?: boolean;
chapter_files?: Array<{ filename: string, size: number }>;
}
+7
View File
@@ -0,0 +1,7 @@
import { Quality } from "./quality";
export interface Format {
id: string;
text: string;
qualities: Quality[];
}
@@ -1,13 +1,5 @@
export interface Format { import { Format } from "./format";
id: string;
text: string;
qualities: Quality[];
}
export interface Quality {
id: string;
text: string;
}
export const Formats: Format[] = [ export const Formats: Format[] = [
{ {
@@ -81,4 +73,9 @@ export const Formats: Format[] = [
text: 'Thumbnail', text: 'Thumbnail',
qualities: [{ id: 'best', text: 'Best' }], qualities: [{ id: 'best', text: 'Best' }],
}, },
{
id: 'captions',
text: 'Captions',
qualities: [{ id: 'best', text: 'Best' }],
},
]; ];
+9
View File
@@ -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';
+5
View File
@@ -0,0 +1,5 @@
export interface Quality {
id: string;
text: string;
}
+1
View File
@@ -0,0 +1 @@
export type State = 'queue' | 'done';
+4
View File
@@ -0,0 +1,4 @@
export interface Status {
status: string;
msg?: string;
}
+7
View File
@@ -0,0 +1,7 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
export interface Theme {
id: string;
displayName: string;
icon: IconDefinition;
}
-55
View File
@@ -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;
}
+21
View File
@@ -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`;
}
}
+16
View File
@@ -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]}`;
}
}
+3
View File
@@ -0,0 +1,3 @@
export { EtaPipe } from './eta.pipe';
export { SpeedPipe } from './speed.pipe';
export { FileSizePipe } from './file-size.pipe';
+43
View File
@@ -0,0 +1,43 @@
import { Pipe, PipeTransform } from "@angular/core";
import { BehaviorSubject, throttleTime } from "rxjs";
@Pipe({
name: 'speed',
pure: false // Make the pipe impure so it can handle async updates
})
export class SpeedPipe implements PipeTransform {
private speedSubject = new BehaviorSubject<number>(0);
private formattedSpeed = '';
constructor() {
// Throttle updates to once per second
this.speedSubject.pipe(
throttleTime(1000)
).subscribe(speed => {
// If speed is invalid or 0, return empty string
if (speed === null || speed === undefined || isNaN(speed) || speed <= 0) {
this.formattedSpeed = '';
return;
}
const k = 1024;
const dm = 2;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
const i = Math.floor(Math.log(speed) / Math.log(k));
this.formattedSpeed = parseFloat((speed / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
});
}
transform(value: number): string {
// If speed is invalid or 0, return empty string
if (value === null || value === undefined || isNaN(value) || value <= 0) {
return '';
}
// Update the speed subject
this.speedSubject.next(value);
// Return the last formatted speed
return this.formattedSpeed;
}
}
+236
View File
@@ -0,0 +1,236 @@
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';
@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();
doneChanged = new Subject();
customDirsChanged = new Subject();
ytdlOptionsChanged = new Subject();
configurationChanged = new Subject();
updated = new Subject();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
configuration: any = {};
customDirs = {};
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(null);
this.doneChanged.next(null);
});
this.socket.fromEvent('added')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
this.queue.set(data.url, data);
this.queueChanged.next(null);
});
this.socket.fromEvent('updated')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata);
const dl: Download | undefined = this.queue.get(data.url);
data.checked = !!dl?.checked;
data.deleting = !!dl?.deleting;
this.queue.set(data.url, data);
this.updated.next(null);
});
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(null);
this.doneChanged.next(null);
});
this.socket.fromEvent('canceled')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: string = JSON.parse(strdata);
this.queue.delete(data);
this.queueChanged.next(null);
});
this.socket.fromEvent('cleared')
.pipe(takeUntilDestroyed())
.subscribe((strdata: string) => {
const data: string = JSON.parse(strdata);
this.done.delete(data);
this.doneChanged.next(null);
});
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 : error.error;
return of({status: 'error', msg: msg})
}
public add(
url: string,
quality: string,
format: string,
folder: string,
customNamePrefix: string,
playlistItemLimit: number,
autoStart: boolean,
splitByChapters: boolean,
chapterTemplate: string,
subtitleFormat: string,
subtitleLanguage: string,
subtitleMode: string,
) {
return this.http.post<Status>('add', {
url: url,
quality: quality,
format: format,
folder: folder,
custom_name_prefix: customNamePrefix,
playlist_item_limit: playlistItemLimit,
auto_start: autoStart,
split_by_chapters: splitByChapters,
chapter_template: chapterTemplate,
subtitle_format: subtitleFormat,
subtitle_language: subtitleLanguage,
subtitle_mode: subtitleMode
}).pipe(
catchError(this.handleHTTPError)
);
}
public startById(ids: string[]) {
return this.http.post('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 addDownloadByUrl(url: string): Promise<{
response: Status} | {
status: string;
msg?: string;
}> {
const defaultQuality = 'best';
const defaultFormat = 'mp4';
const defaultFolder = '';
const defaultCustomNamePrefix = '';
const defaultPlaylistItemLimit = 0;
const defaultAutoStart = true;
const defaultSplitByChapters = false;
const defaultChapterTemplate = this.configuration['OUTPUT_TEMPLATE_CHAPTER'];
const defaultSubtitleFormat = 'srt';
const defaultSubtitleLanguage = 'en';
const defaultSubtitleMode = 'prefer_manual';
return new Promise((resolve, reject) => {
this.add(
url,
defaultQuality,
defaultFormat,
defaultFolder,
defaultCustomNamePrefix,
defaultPlaylistItemLimit,
defaultAutoStart,
defaultSplitByChapters,
defaultChapterTemplate,
defaultSubtitleFormat,
defaultSubtitleLanguage,
defaultSubtitleMode,
)
.subscribe({
next: (response) => resolve(response),
error: (error) => reject(error)
});
});
}
public exportQueueUrls(): string[] {
return Array.from(this.queue.values()).map(download => download.url);
}
public cancelAdd() {
return this.http.post<Status>('cancel-add', {}).pipe(
catchError(this.handleHTTPError)
);
}
uploadCookies(file: File) {
const formData = new FormData();
formData.append('cookies', file);
return this.http.post<any>('upload-cookies', formData).pipe(
catchError(this.handleHTTPError)
);
}
deleteCookies() {
return this.http.post<any>('delete-cookies', {}).pipe(
catchError(this.handleHTTPError)
);
}
getCookieStatus() {
return this.http.get<any>('cookie-status').pipe(
catchError(this.handleHTTPError)
);
}
}
+3
View File
@@ -0,0 +1,3 @@
export { DownloadsService } from './downloads.service';
export { SpeedService } from './speed.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 -6
View File
@@ -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[] = [
{ {
-3
View File
@@ -1,3 +0,0 @@
export const environment = {
production: true
};
-16
View File
@@ -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
View File
@@ -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));
-69
View File
@@ -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
*/
-14
View File
@@ -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()
);
+7 -6
View File
@@ -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"
] ]
} }
+23 -13
View File
@@ -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"
}
]
} }
+1 -5
View File
@@ -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
View File
@@ -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"
]
}
}
Generated
+306 -274
View File
@@ -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,24 +93,23 @@ 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]]
@@ -177,11 +176,11 @@ wheels = [
[[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]]
@@ -281,32 +280,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.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/29/b2941d53d94094e20e52def86956528140dbe60b49d715803f7e9799d42f/deno-2.7.2.tar.gz", hash = "sha256:3dc9461ac4dd0d6661769f03460861709e17c4e516dfce14676e6a3146824b7b", size = 8167, upload-time = "2026-03-03T16:10:51.429Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/a0/9e6f45c25ef36db827e75bd35bf9378c196a6bed2804a8259d1d63bab84f/deno-2.7.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:67509919fa9df639d9375e441648ae5a3ab9bb1ce6fcddc21c49c08368af4d68", size = 46325714, upload-time = "2026-03-03T16:10:35.82Z" },
{ url = "https://files.pythonhosted.org/packages/83/ce/085c3002cdfc0d33b30896b3d1469024c23e3971cba4a15ae3983c48d2e4/deno-2.7.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a19f75d7a148a2d030543db88734f03648e31dc7385a9c62aa1d975e2b0df8d9", size = 43264279, upload-time = "2026-03-03T16:10:39.011Z" },
{ url = "https://files.pythonhosted.org/packages/38/f0/c415c08ca30fb084887a96b88df7f6511c98575b365db87b0fac76a82773/deno-2.7.2-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:f7b63f13c9fdeb18d0435e80aa4677878ac1b9ac23a49c7570958b9d81772e06", size = 47024484, upload-time = "2026-03-03T16:10:42.619Z" },
{ url = "https://files.pythonhosted.org/packages/e6/14/bfac1928082f78f120aaff7608f211a8beab8f66e72defc0ac85d6f52f84/deno-2.7.2-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:bded39ebc9d19748a13a4c046a715f12c445a3e15c0b4cde6d42cc47793efcf0", size = 48981918, upload-time = "2026-03-03T16:10:45.822Z" },
{ url = "https://files.pythonhosted.org/packages/79/07/b332f98969937d435ba2905195a0b3dd2162f192659595dde88c615b04e1/deno-2.7.2-py3-none-win_amd64.whl", hash = "sha256:5d525d270e16d5ea22ad90a65e1ebc0dff8b83068d698f6bad138bfa857e4d28", size = 48330774, upload-time = "2026-03-03T16:10:49.209Z" },
] ]
[[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]]
@@ -402,11 +416,11 @@ wheels = [
[[package]] [[package]]
name = "isort" name = "isort"
version = "7.0.0" version = "8.0.1"
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/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
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/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,7 +442,7 @@ 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]
@@ -443,7 +457,7 @@ 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]
@@ -451,83 +465,83 @@ dev = [{ name = "pylint" }]
[[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]]
@@ -541,11 +555,11 @@ wheels = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.5.0" version = "4.9.2"
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/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
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/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
] ]
[[package]] [[package]]
@@ -619,11 +633,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]]
@@ -658,7 +672,7 @@ wheels = [
[[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 +683,34 @@ 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]] [[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 +740,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 +817,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 +865,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.3"
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/66/6f/7427d23609353e5ef3470ff43ef551b8bd7b166dd4fef48957f0d0e040fe/yt_dlp-2026.3.3.tar.gz", hash = "sha256:3db7969e3a8964dc786bdebcffa2653f31123bf2a630f04a17bdafb7bbd39952", size = 3118658, upload-time = "2026-03-03T16:54:53.909Z" }
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/7e/a4/8b5cd28ab87aef48ef15e74241befec3445496327db028f34147a9e0f14f/yt_dlp-2026.3.3-py3-none-any.whl", hash = "sha256:166c6e68c49ba526474bd400e0129f58aa522c2896204aa73be669c3d2f15e63", size = 3315599, upload-time = "2026-03-03T16:54:51.899Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -944,12 +973,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.5.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/6b/0d/b9e4ab1b47cdeba0842df634b74b3c0144307640ad5b632a5e189c4ab7ce/yt_dlp_ejs-0.5.0.tar.gz", hash = "sha256:8dfae59e418232f485253dcf8e197fefa232423c3af7824fe19e4517b173293b", size = 98925, upload-time = "2026-02-21T19:29:16.844Z" }
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/7e/5b/1283356b70d4893a8a050cee15092e1b08ea15310b94365f88067146721b/yt_dlp_ejs-0.5.0-py3-none-any.whl", hash = "sha256:674fc0efea741d3100cdf3f0f9e123150715ee41edf47ea7a62fbdeda204bdec", size = 54032, upload-time = "2026-02-21T19:29:15.408Z" },
] ]