mirror of
https://github.com/alexta69/metube.git
synced 2026-07-23 13:22:48 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c7261ab59 | |||
| a23d1689e3 | |||
| 4b05022b91 | |||
| 0dc9b0b3d6 | |||
| 96f5ffe34a | |||
| 1eafecd1cc | |||
| 8fe81dea18 | |||
| f054d84108 | |||
| fa02717e12 | |||
| 9ca78be199 | |||
| 8071611a84 | |||
| 220f991fae | |||
| 6d0528783c | |||
| c104e30451 | |||
| fdfbfed5e2 | |||
| 3ea4732c5d | |||
| e2c777842e | |||
| c34a18de7a | |||
| d6bcf182c5 | |||
| ad90609c9b |
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Enable pnpm
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: readme-size
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'README.md'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'README.md'
|
||||
|
||||
jobs:
|
||||
check-size:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- name: Check README stays under Docker Hub's 25k character limit
|
||||
run: |
|
||||
size=$(wc -c < README.md)
|
||||
echo "README.md is ${size} bytes (limit 25000)"
|
||||
test "$size" -lt 25000
|
||||
@@ -1,5 +1,53 @@
|
||||
# Agent Guidelines
|
||||
|
||||
## Project scope — read this before planning a feature
|
||||
|
||||
MeTube's contract is: give it a URL, it runs yt-dlp well, and correct files appear.
|
||||
The maintainer holds a deliberate line on what belongs inside that contract, and PRs
|
||||
on the wrong side of it are declined **regardless of code quality**. Check your plan
|
||||
against this line before writing any code.
|
||||
|
||||
**In scope — improving the write itself:**
|
||||
|
||||
- Features that make the file yt-dlp writes at download time come out more correct,
|
||||
using only data the extractor already provides (e.g. filling a missing album-artist
|
||||
tag from the extractor's own metadata).
|
||||
- Surfacing functionality yt-dlp itself owns and maintains as first-class UI options
|
||||
(e.g. a SponsorBlock toggle that just passes yt-dlp postprocessor params).
|
||||
- Download queue, subscriptions, output templates, and UI improvements to the
|
||||
download workflow.
|
||||
|
||||
**Out of scope — managing files after they exist:**
|
||||
|
||||
- Tag editors, metadata dialogs, or any workflow that rewrites files after the
|
||||
download has finished. This holds even for slimmed-down versions.
|
||||
- Lookups against external metadata services (iTunes, Deezer, MusicBrainz, etc.).
|
||||
More broadly: any new dependency on a third-party API, or new network egress from
|
||||
self-hosted instances, beyond what yt-dlp itself performs.
|
||||
- Library organization: moving/renaming existing files into Artist/Album layouts,
|
||||
watch-folder processing, and similar media-manager features. Dedicated tools
|
||||
(beets, MusicBrainz Picard, Lidarr) do this properly; the README points users
|
||||
to them.
|
||||
|
||||
**Corollaries that shape borderline PRs:**
|
||||
|
||||
- Site-specific intelligence (parsing playlist-ID prefixes, URL path conventions,
|
||||
and other platform internals) is extractor work and belongs upstream in yt-dlp,
|
||||
not re-implemented here — it silently breaks when the platform changes and
|
||||
MeTube would own the breakage.
|
||||
- Prefer enriching yt-dlp's info dict and letting its existing pipeline
|
||||
(FFmpegMetadata etc.) do the writing, over adding custom per-format tag-writing
|
||||
code to MeTube.
|
||||
- Supplemental processing must never fail a download that otherwise succeeded:
|
||||
warn and continue, don't raise.
|
||||
- Keep feature scope minimal on first submission. A hardcoded sensible default
|
||||
beats a configuration surface; follow-ups can add options when users actually
|
||||
ask. PRs that bundle several "reasonable next steps" invite rejection of the
|
||||
whole.
|
||||
|
||||
If a feature idea fails this test, the accepted alternative is usually a README
|
||||
section documenting how to pair MeTube with the right dedicated tool.
|
||||
|
||||
## README.md size constraint
|
||||
|
||||
The README.md is synced to Docker Hub, which has a **25,000 character limit**.
|
||||
@@ -30,6 +78,24 @@ uv run pytest app/tests/
|
||||
|
||||
All of these run in CI (`.github/workflows/main.yml`) on every push to master and must pass.
|
||||
|
||||
Gotchas:
|
||||
|
||||
- Backend tests must run **from the repo root**: `main.py` resolves the static-assets
|
||||
path relative to the cwd, and several test modules import `main`. Running from
|
||||
`app/` makes five test modules fail to import.
|
||||
- The frontend must be **built before** running backend tests (same reason — the
|
||||
assets at `ui/dist/metube/browser` must exist). The command order above is
|
||||
load-bearing.
|
||||
- `app/tests/test_ytdl_utils.py` stubs `yt_dlp` at import time. Run standalone,
|
||||
two tests fail with `AttributeError: <module 'yt_dlp'> does not have the
|
||||
attribute 'YoutubeDL'`; under the full suite the real module is imported first
|
||||
and they pass. This is a known quirk, not a bug to fix in the code under test.
|
||||
|
||||
Every non-markdown push to master builds multi-arch Docker images and cuts a dated
|
||||
release the same day. **Master is continuously released** — a PR must be
|
||||
release-ready exactly as merged; there is no stabilization window for follow-up
|
||||
fixes.
|
||||
|
||||
## Code style
|
||||
|
||||
Follow `.editorconfig`:
|
||||
@@ -56,5 +122,36 @@ ui/src/app/ — Angular standalone components (no NgModules)
|
||||
- Backend configuration lives in the `Config` class in `app/main.py` with env-var defaults in `_DEFAULTS`. New env vars go there.
|
||||
- Real-time communication uses Socket.IO events, not REST polling.
|
||||
- Frontend uses standalone Angular components with `inject()` for DI, RxJS Subjects for state, and `takeUntilDestroyed()` for cleanup.
|
||||
- Frontend components use OnPush change detection: subscribe callbacks must call `cdr.markForCheck()`.
|
||||
- State is persisted as JSON files via `AtomicJsonStore` in `app/state_store.py`.
|
||||
- Persisted state stays compact: the completed queue deliberately drops bulky entry data (see `_compact_persisted_entry` in `app/ytdl.py`). Don't expand what gets persisted without discussion.
|
||||
- Custom yt-dlp postprocessors added to `ytdl_params['postprocessors']` run in **list order** within a stage. When combining postprocessors, mirror the ordering the yt-dlp CLI would produce (e.g. sponsor-segment removal before chapter splitting).
|
||||
- No pre-commit hooks — linting and tests are enforced in CI only.
|
||||
|
||||
## Checklist: adding a per-download option
|
||||
|
||||
New options on the download form (the `split_by_chapters` pattern) need **all** of
|
||||
these pieces — the last three are the ones commonly missed:
|
||||
|
||||
1. `parse_download_options` in `app/main.py`.
|
||||
2. A field on `DownloadInfo` in `app/ytdl.py`.
|
||||
3. A `hasattr` backfill in `DownloadInfo.__setstate__` for old persisted records.
|
||||
4. The safe-deserialization field list in `app/ytdl.py`.
|
||||
5. UI form control + cookie persistence in `ui/src/app/app.ts` / `app.html`, and
|
||||
the payload in `downloads.service.ts` (plus its spec).
|
||||
6. The redownload path in `app.ts`, so retries carry the option.
|
||||
7. If the option makes sense for unattended downloads: threading through
|
||||
`app/subscriptions.py` (`SubscriptionInfo` field, serializer, add/update
|
||||
routes, the enqueue call) — or a note in the PR that it's deliberately
|
||||
direct-downloads-only.
|
||||
|
||||
## Security invariants
|
||||
|
||||
User input and extractor-provided metadata (titles, playlist names, URLs) are
|
||||
untrusted. Use the existing guards instead of hand-rolling:
|
||||
|
||||
- User-submitted URLs go through the SSRF guard (see `test_url_guard.py` for the
|
||||
expected behavior).
|
||||
- Anything that becomes a filesystem path goes through `_is_within_directory` and
|
||||
`_sanitize_path_component` in `app/ytdl.py` — including values that arrive via
|
||||
yt-dlp metadata, which sites can influence.
|
||||
|
||||
+7
-2
@@ -1,4 +1,8 @@
|
||||
FROM node:lts-alpine AS builder
|
||||
# Pinned to a major version rather than the lts-alpine floating tag: that tag
|
||||
# has lagged behind and resolved to a Node patch older than the Angular CLI's
|
||||
# minimum supported version, breaking the build. node:22-alpine currently
|
||||
# satisfies @angular/cli's >=22.22.3 requirement.
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /metube
|
||||
COPY ui ./
|
||||
@@ -66,7 +70,8 @@ ENV TEMP_DIR=/downloads
|
||||
ENV PORT=8081
|
||||
VOLUME /downloads
|
||||
EXPOSE 8081
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS "http://localhost:${PORT}/" || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD case "$HTTPS" in true|True|on|1) curl -fsSk "https://localhost:${PORT}/";; *) curl -fsS "http://localhost:${PORT}/";; esac || exit 1
|
||||
|
||||
# Add build-time argument for version
|
||||
ARG VERSION=dev
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||

|
||||

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

|
||||
|
||||
@@ -18,7 +18,7 @@ Key capabilities:
|
||||
docker run -d -p 8081:8081 -v /path/to/downloads:/downloads ghcr.io/alexta69/metube
|
||||
```
|
||||
|
||||
## 🐳 Run using docker-compose
|
||||
## 🐳 Run using Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -34,7 +34,16 @@ services:
|
||||
|
||||
## ⚙️ Configuration via environment variables
|
||||
|
||||
Certain values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in docker-compose.
|
||||
Certain values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in Docker Compose.
|
||||
|
||||
### 🏠 Runtime & Permissions
|
||||
|
||||
* __PUID__: User under which MeTube will run. Defaults to `1000` (legacy `UID` also supported).
|
||||
* __PGID__: Group under which MeTube will run. Defaults to `1000` (legacy `GID` also supported).
|
||||
* __UMASK__: Umask value used by MeTube. Defaults to `022`.
|
||||
* __DEFAULT_THEME__: Default theme to use for the UI, can be set to `light`, `dark`, or `auto`. Defaults to `auto`.
|
||||
* __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or `NONE`. Defaults to `INFO`.
|
||||
* __ENABLE_ACCESSLOG__: Whether to enable access log. Defaults to `false`.
|
||||
|
||||
### ⬇️ Download Behavior
|
||||
|
||||
@@ -83,18 +92,9 @@ Certain values can be set via environment variables, using the `-e` parameter on
|
||||
* __HTTPS__: Use `https` instead of `http` (__CERTFILE__ and __KEYFILE__ required). Defaults to `false`.
|
||||
* __CERTFILE__: HTTPS certificate file path.
|
||||
* __KEYFILE__: HTTPS key file path.
|
||||
* __CORS_ALLOWED_ORIGINS__: Comma-separated list of origins permitted to make cross-origin requests to the MeTube API. When unset or empty, all cross-origin requests are denied. Set to `*` to allow all origins. This must be configured for [browser extensions](#-browser-extensions), [bookmarklets](#-bookmarklet), and any other browser-based tools that contact MeTube from a different origin. For browser extensions use `*` (see below); for bookmarklets you can list specific sites, e.g. `https://www.youtube.com,https://www.vimeo.com`.
|
||||
* __CORS_ALLOWED_ORIGINS__: Comma-separated list of origins permitted to make cross-origin requests to the MeTube API; `*` allows all. When unset or empty, all cross-origin requests are denied. Required for browser extensions and bookmarklets — see [Sending links to MeTube](#-sending-links-to-metube).
|
||||
* __ROBOTS_TXT__: A path to a `robots.txt` file mounted in the container.
|
||||
|
||||
### 🏠 Basic Setup
|
||||
|
||||
* __PUID__: User under which MeTube will run. Defaults to `1000` (legacy `UID` also supported).
|
||||
* __PGID__: Group under which MeTube will run. Defaults to `1000` (legacy `GID` also supported).
|
||||
* __UMASK__: Umask value used by MeTube. Defaults to `022`.
|
||||
* __DEFAULT_THEME__: Default theme to use for the UI, can be set to `light`, `dark`, or `auto`. Defaults to `auto`.
|
||||
* __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or `NONE`. Defaults to `INFO`.
|
||||
* __ENABLE_ACCESSLOG__: Whether to enable access log. Defaults to `false`.
|
||||
|
||||
## 🎛️ Configuring yt-dlp options
|
||||
|
||||
MeTube lets you customize how [yt-dlp](https://github.com/yt-dlp/yt-dlp) behaves at three levels, from broadest to most specific:
|
||||
@@ -229,47 +229,27 @@ In case you need to use your browser's cookies with MeTube, for example to downl
|
||||
* After upload, the cookie indicator should show as active.
|
||||
* Use **Delete Cookies** in the same section to remove uploaded cookies.
|
||||
|
||||
## 🔌 Browser extensions
|
||||
## 🔗 Sending links to MeTube
|
||||
|
||||
Browser extensions allow right-clicking videos and sending them directly to MeTube. If you're on an HTTPS page, your MeTube instance must be behind an HTTPS reverse proxy (see below) for extensions to work.
|
||||
Several integrations let you send URLs to MeTube from wherever you are, instead of pasting them into the UI. The browser-based ones make cross-origin requests, so they require `CORS_ALLOWED_ORIGINS` to be set; and if you're on an HTTPS page, your MeTube instance must be served over HTTPS too (with `HTTPS=true` or behind an HTTPS reverse proxy — see below).
|
||||
|
||||
Since browser extensions make requests from their own origin (`chrome-extension://...` or `moz-extension://...`), you must set `CORS_ALLOWED_ORIGINS=*` for them to work.
|
||||
__Browser extensions__ allow right-clicking videos and sending them directly to MeTube. Since extensions request from their own origin, set `CORS_ALLOWED_ORIGINS=*`.
|
||||
* __Chrome:__ contributed by [Rpsl](https://github.com/rpsl) — install from the [Chrome Webstore](https://chrome.google.com/webstore/detail/metube-downloader/fbmkmdnlhacefjljljlbhkodfmfkijdh) or [from sources](https://github.com/Rpsl/metube-browser-extension).
|
||||
* __Firefox:__ contributed by [nanocortex](https://github.com/nanocortex) — install from [Firefox Addons](https://addons.mozilla.org/en-US/firefox/addon/metube-downloader) or get sources [here](https://github.com/nanocortex/metube-firefox-addon).
|
||||
|
||||
__Chrome:__ contributed by [Rpsl](https://github.com/rpsl). You can install it from [Google Chrome Webstore](https://chrome.google.com/webstore/detail/metube-downloader/fbmkmdnlhacefjljljlbhkodfmfkijdh) or use developer mode and install [from sources](https://github.com/Rpsl/metube-browser-extension).
|
||||
__Bookmarklets__ send the currently open page to MeTube with one click. Add the origins of the sites where you use them to `CORS_ALLOWED_ORIGINS`, e.g. `https://www.youtube.com,https://www.vimeo.com`. The code (Chrome and Firefox variants, contributed by [kushfest](https://github.com/kushfest) and [shoonya75](https://github.com/shoonya75)) is in the [Bookmarklets wiki page](https://github.com/alexta69/metube/wiki/Bookmarklets).
|
||||
|
||||
__Firefox:__ contributed by [nanocortex](https://github.com/nanocortex). You can install it from [Firefox Addons](https://addons.mozilla.org/en-US/firefox/addon/metube-downloader) or get sources from [here](https://github.com/nanocortex/metube-firefox-addon).
|
||||
__iOS Shortcut:__ [rithask](https://github.com/rithask) created an [iOS shortcut](https://www.icloud.com/shortcuts/66627a9f334c467baabdb2769763a1a6) for sending URLs to MeTube from Safari's share menu; it prompts for your instance address on first use.
|
||||
|
||||
## 📱 iOS Shortcut
|
||||
__Raycast:__ [dotvhs](https://github.com/dotvhs) has created an [extension for Raycast](https://www.raycast.com/dot/metube) for adding videos to MeTube directly from Raycast.
|
||||
|
||||
[rithask](https://github.com/rithask) created an iOS shortcut to send URLs to MeTube from Safari. Enter the MeTube instance address when prompted which will be saved for later use. You can run the shortcut from Safari’s share menu. The shortcut can be downloaded from [this iCloud link](https://www.icloud.com/shortcuts/66627a9f334c467baabdb2769763a1a6).
|
||||
## 🎵 Pairing with a music tagger
|
||||
|
||||
## 🔖 Bookmarklet
|
||||
MeTube deliberately stops once the file is written — tagging and library organization belong to dedicated tools. Point one at your audio download folder (`AUDIO_DOWNLOAD_DIR`):
|
||||
|
||||
[kushfest](https://github.com/kushfest) has created a Chrome bookmarklet for sending the currently open webpage to MeTube. Please note that if you're on an HTTPS page, your MeTube instance must be configured with `HTTPS` as `true` in the environment, or be behind an HTTPS reverse proxy (see below) for the bookmarklet to work.
|
||||
|
||||
Since bookmarklets run in the context of the current page (e.g. youtube.com), the requests they make to MeTube are cross-origin. You must add the origins of sites where you use the bookmarklet to the __CORS_ALLOWED_ORIGINS__ environment variable, otherwise the browser will block the requests. For example, to use the bookmarklet on YouTube and Vimeo: `CORS_ALLOWED_ORIGINS=https://www.youtube.com,https://www.vimeo.com`.
|
||||
|
||||
GitHub doesn't allow embedding JavaScript as a link, so the bookmarklet has to be created manually by copying the following code to a new bookmark you create on your bookmarks bar. Change the hostname in the URL below to point to your MeTube instance.
|
||||
|
||||
```javascript
|
||||
javascript:!function(){xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.withCredentials=true;xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to metube!")}else{alert("Send to metube failed. Check the javascript console for clues.")}}}();
|
||||
```
|
||||
|
||||
[shoonya75](https://github.com/shoonya75) has contributed a Firefox version:
|
||||
|
||||
```javascript
|
||||
javascript:(function(){xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function(){if(xhr.status==200){alert("Sent to metube!")}else{alert("Send to metube failed. Check the javascript console for clues.")}}})();
|
||||
```
|
||||
|
||||
The above bookmarklets use `alert()` for notifications. This variant shows a toast instead (Chrome — for Firefox, replace the `!function(){...}()` wrapper with `(function(){...})()`):
|
||||
|
||||
```javascript
|
||||
javascript:!function(){function notify(msg) {var sc = document.scrollingElement.scrollTop; var text = document.createElement('span');text.innerHTML=msg;var ts = text.style;ts.all = 'revert';ts.color = '#000';ts.fontFamily = 'Verdana, sans-serif';ts.fontSize = '15px';ts.backgroundColor = 'white';ts.padding = '15px';ts.border = '1px solid gainsboro';ts.boxShadow = '3px 3px 10px';ts.zIndex = '100';document.body.appendChild(text);ts.position = 'absolute'; ts.top = 50 + sc + 'px'; ts.left = (window.innerWidth / 2)-(text.offsetWidth / 2) + 'px'; setTimeout(function () { text.style.visibility = "hidden"; }, 1500);}xhr=new XMLHttpRequest();xhr.open("POST","https://metube.domain.com/add");xhr.send(JSON.stringify({"url":document.location.href,"quality":"best"}));xhr.onload=function() { if(xhr.status==200){notify("Sent to metube!")}else {notify("Send to metube failed. Check the javascript console for clues.")}}}();
|
||||
```
|
||||
|
||||
## ⚡ Raycast extension
|
||||
|
||||
[dotvhs](https://github.com/dotvhs) has created an [extension for Raycast](https://www.raycast.com/dot/metube) for adding videos to MeTube directly from Raycast.
|
||||
* [beets](https://beets.io) — `beet import` matches tracks against MusicBrainz, fixes tags, and files them into an Artist/Album library; headless and scriptable.
|
||||
* [MusicBrainz Picard](https://picard.musicbrainz.org) — GUI tagger with acoustic fingerprinting.
|
||||
* [Lidarr](https://lidarr.audio) — full music library manager; add the folder as an import path.
|
||||
|
||||
## 🔒 HTTPS support, and running behind a reverse proxy
|
||||
|
||||
@@ -293,11 +273,7 @@ services:
|
||||
- KEYFILE=/ssl/key.pem
|
||||
```
|
||||
|
||||
MeTube can also run behind a reverse proxy for HTTPS termination or authentication. When serving under a subdirectory, set `URL_PREFIX` accordingly.
|
||||
|
||||
The [linuxserver/swag](https://docs.linuxserver.io/general/swag) image includes ready-made snippets for MeTube in [subfolder](https://github.com/linuxserver/reverse-proxy-confs/blob/master/metube.subfolder.conf.sample) and [subdomain](https://github.com/linuxserver/reverse-proxy-confs/blob/master/metube.subdomain.conf.sample) modes, plus Authelia for authentication.
|
||||
|
||||
### 🌐 NGINX
|
||||
MeTube can also run behind a reverse proxy for HTTPS termination or authentication. When serving under a subdirectory, set `URL_PREFIX` accordingly. MeTube uses WebSocket for real-time updates, so the proxy must pass the `Upgrade`/`Connection` headers, as in this NGINX example:
|
||||
|
||||
```nginx
|
||||
location /metube/ {
|
||||
@@ -309,41 +285,7 @@ location /metube/ {
|
||||
}
|
||||
```
|
||||
|
||||
Note: the extra `proxy_set_header` directives are there to make WebSocket work.
|
||||
|
||||
### 🌐 Apache
|
||||
|
||||
Contributed by [PIE-yt](https://github.com/PIE-yt). Source [here](https://gist.github.com/PIE-yt/29e7116588379032427f5bd446b2cac4).
|
||||
|
||||
```apache
|
||||
# For putting in your Apache sites site.conf
|
||||
# Serves MeTube under a /metube/ subdir (http://yourdomain.com/metube/)
|
||||
<Location /metube/>
|
||||
ProxyPass http://localhost:8081/ retry=0 timeout=30
|
||||
ProxyPassReverse http://localhost:8081/
|
||||
</Location>
|
||||
|
||||
<Location /metube/socket.io>
|
||||
RewriteEngine On
|
||||
RewriteCond %{QUERY_STRING} transport=websocket [NC]
|
||||
RewriteRule /(.*) ws://localhost:8081/socket.io/$1 [P,L]
|
||||
ProxyPass http://localhost:8081/socket.io retry=0 timeout=30
|
||||
ProxyPassReverse http://localhost:8081/socket.io
|
||||
</Location>
|
||||
```
|
||||
|
||||
### 🌐 Caddy
|
||||
|
||||
The following example Caddyfile gets a reverse proxy going behind [caddy](https://caddyserver.com).
|
||||
|
||||
```caddyfile
|
||||
example.com {
|
||||
route /metube/* {
|
||||
uri strip_prefix metube
|
||||
reverse_proxy metube:8081
|
||||
}
|
||||
}
|
||||
```
|
||||
Apache, Caddy, and [linuxserver/swag](https://docs.linuxserver.io/general/swag) (with Authelia) examples are in the [Reverse proxy configurations wiki page](https://github.com/alexta69/metube/wiki/Reverse-proxy-configurations).
|
||||
|
||||
## 🔄 Updating yt-dlp
|
||||
|
||||
@@ -358,9 +300,11 @@ docker exec -ti metube sh
|
||||
cd /downloads
|
||||
```
|
||||
|
||||
Common issues and their fixes are collected in the [Troubleshooting FAQ](https://github.com/alexta69/metube/wiki/Troubleshooting-FAQ) on the wiki.
|
||||
|
||||
## 💡 Submitting feature requests
|
||||
|
||||
MeTube development relies on community contributions. If you need additional features, please submit a PR. Create an issue first to discuss the implementation — some PRs may not be accepted to reduce bloat. Feature requests without an accompanying PR are unlikely to be fulfilled.
|
||||
MeTube development relies on community contributions. If you need additional features, please submit a PR. Create an issue first to discuss the implementation before writing code — MeTube's scope is deliberately narrow: it downloads well and stops once the file is written. Features that improve the download itself are welcome; post-download file management (tag editing, metadata lookups, library organization) is out of scope regardless of implementation quality — see [AGENTS.md](AGENTS.md) for the full policy. Feature requests without an accompanying PR are unlikely to be fulfilled.
|
||||
|
||||
## 🛠️ Building and running locally
|
||||
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report vulnerabilities privately via
|
||||
[GitHub private vulnerability reporting](https://github.com/alexta69/metube/security/advisories/new)
|
||||
(Security tab → "Report a vulnerability"). Do **not** open a public issue for
|
||||
security problems.
|
||||
|
||||
You can expect an initial response within a few days. Please include a
|
||||
reproduction and the MeTube release version (visible in the UI footer).
|
||||
|
||||
## Supported versions
|
||||
|
||||
MeTube is continuously released; only the **latest release** is supported.
|
||||
Update to the current Docker image before reporting.
|
||||
|
||||
## Scope notes
|
||||
|
||||
MeTube ships **without authentication by design** — it is intended to run on a
|
||||
trusted network or behind an authenticating reverse proxy (see the
|
||||
[wiki](https://github.com/alexta69/metube/wiki/Reverse-proxy-configurations)).
|
||||
Reports that reduce to "the UI is reachable without a login" are expected
|
||||
behavior, not vulnerabilities.
|
||||
@@ -0,0 +1,26 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("bg_tasks")
|
||||
_TASKS: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def create_task(coro, *, name: str | None = None) -> asyncio.Task:
|
||||
"""create_task that keeps a strong reference and logs unexpected failures.
|
||||
|
||||
A bare ``asyncio.create_task(...)`` is only weakly referenced by the event
|
||||
loop; if nothing else holds the returned Task, it can be garbage collected
|
||||
mid-flight. Keeping a module-level strong reference (removed once the task
|
||||
finishes) avoids that, and the done-callback surfaces otherwise-silent
|
||||
failures.
|
||||
"""
|
||||
task = asyncio.get_running_loop().create_task(coro, name=name)
|
||||
_TASKS.add(task)
|
||||
|
||||
def _done(t: asyncio.Task) -> None:
|
||||
_TASKS.discard(t)
|
||||
if not t.cancelled() and t.exception() is not None:
|
||||
log.error("Background task %s failed", t.get_name(), exc_info=t.exception())
|
||||
|
||||
task.add_done_callback(_done)
|
||||
return task
|
||||
+33
-1
@@ -3,6 +3,21 @@ import copy
|
||||
AUDIO_FORMATS = ("m4a", "mp3", "opus", "wav", "flac")
|
||||
CAPTION_MODES = ("auto_only", "manual_only", "prefer_manual", "prefer_auto")
|
||||
|
||||
|
||||
def merge_ytdl_option_layers(presets, overrides, presets_config) -> dict:
|
||||
"""Overlay named presets (in order) then per-item overrides onto a fresh dict.
|
||||
|
||||
Does NOT include any base ``YTDL_OPTIONS`` — callers layer this on top of
|
||||
their own base (a per-download build adds the global base; a subscription
|
||||
scan relies on ``**config.YTDL_OPTIONS`` already being present in its
|
||||
params). ``presets_config`` maps a preset name to its options dict.
|
||||
"""
|
||||
merged: dict = {}
|
||||
for name in presets or []:
|
||||
merged.update(presets_config.get(name, {}))
|
||||
merged.update(overrides or {})
|
||||
return merged
|
||||
|
||||
CODEC_FILTER_MAP = {
|
||||
'h264': "[vcodec~='^(h264|avc)']",
|
||||
'h265': "[vcodec~='^(h265|hevc)']",
|
||||
@@ -43,6 +58,10 @@ def get_format(download_type: str, codec: str, format: str, quality: str) -> str
|
||||
quality = (quality or "best").strip().lower()
|
||||
|
||||
if format.startswith("custom:"):
|
||||
# Unreachable via the HTTP API (format is validated against a fixed
|
||||
# set in main.py), but legacy persisted downloads may carry a
|
||||
# custom: format from before that validation existed; removing this
|
||||
# would crash PersistentQueue.load() for those records.
|
||||
return format[7:]
|
||||
|
||||
if download_type == "thumbnail":
|
||||
@@ -137,7 +156,20 @@ def get_opts(
|
||||
requested_subtitle_format = (format or "srt").lower()
|
||||
if requested_subtitle_format == "txt":
|
||||
requested_subtitle_format = "srt"
|
||||
opts["subtitlesformat"] = requested_subtitle_format
|
||||
opts["subtitlesformat"] = f"{requested_subtitle_format}/best"
|
||||
if requested_subtitle_format in ("srt", "vtt"):
|
||||
# subtitlesformat above is only a preference: if the extractor
|
||||
# doesn't natively offer this ext (e.g. YouTube has no native srt),
|
||||
# yt-dlp silently falls back to whatever it has. ffmpeg can only
|
||||
# convert to srt/vtt/ass/lrc, so only guarantee the requested
|
||||
# container for those; other formats stay best-effort.
|
||||
postprocessors.append(
|
||||
{
|
||||
"key": "FFmpegSubtitlesConvertor",
|
||||
"format": requested_subtitle_format,
|
||||
"when": "before_dl",
|
||||
}
|
||||
)
|
||||
if mode == "manual_only":
|
||||
opts["writesubtitles"] = True
|
||||
opts["writeautomaticsub"] = False
|
||||
|
||||
+49
-17
@@ -16,9 +16,11 @@ import logging
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
from watchfiles import DefaultFilter, Change, awatch
|
||||
|
||||
import bg_tasks
|
||||
from ytdl import DownloadQueueNotifier, DownloadQueue, Download
|
||||
from subscriptions import SubscriptionManager, SubscriptionNotifier, SubscriptionInfo, coerce_optional_bool
|
||||
from yt_dlp.version import __version__ as yt_dlp_version
|
||||
@@ -140,6 +142,10 @@ class Config:
|
||||
self._validate_int('MAX_CONCURRENT_DOWNLOADS', minimum=1)
|
||||
self._validate_int('PORT', minimum=1, maximum=65535)
|
||||
self._validate_int('CLEAR_COMPLETED_AFTER', minimum=0)
|
||||
self._validate_int('DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT', minimum=0)
|
||||
self._validate_int('SUBSCRIPTION_DEFAULT_CHECK_INTERVAL', minimum=1)
|
||||
self._validate_int('SUBSCRIPTION_SCAN_PLAYLIST_END', minimum=1)
|
||||
self._validate_int('SUBSCRIPTION_MAX_SEEN_IDS', minimum=1)
|
||||
|
||||
self._runtime_overrides = {}
|
||||
|
||||
@@ -301,7 +307,10 @@ async def state_dir_guard(request, handler):
|
||||
(config.URL_PREFIX + 'audio_download/', config.AUDIO_DOWNLOAD_DIR),
|
||||
):
|
||||
if request.path.startswith(prefix):
|
||||
rel = unquote(request.path[len(prefix):])
|
||||
# request.path is already percent-decoded by aiohttp; decoding it
|
||||
# again would mangle a download whose filename contains a literal
|
||||
# '%' (e.g. "%" turning into a truncated escape) into a false 404.
|
||||
rel = request.path[len(prefix):]
|
||||
target = os.path.realpath(os.path.join(base, rel))
|
||||
if _is_within_state_dir(target):
|
||||
raise web.HTTPNotFound()
|
||||
@@ -425,12 +434,20 @@ def _clip_field_provided_in_post(raw) -> bool:
|
||||
|
||||
|
||||
def _extract_t_query_from_url(url: str) -> tuple[str, float | None]:
|
||||
"""If ``t=`` is present and parseable, return URL without ``t`` and start seconds."""
|
||||
"""If ``t=`` is present and parseable, return URL without ``t`` and start seconds.
|
||||
|
||||
Restricted to YouTube hosts: ``t`` is a generic query parameter name that
|
||||
other sites may use for unrelated purposes, so rewriting it there would
|
||||
silently mutate the URL and inject a bogus clip start.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
params = parse_qs(parsed.query)
|
||||
except Exception:
|
||||
return url, None
|
||||
host = (parsed.hostname or '').lower()
|
||||
if not (host in ('youtu.be', 'youtube.com') or host.endswith('.youtube.com')):
|
||||
return url, None
|
||||
t_values = params.get('t')
|
||||
if not t_values:
|
||||
return url, None
|
||||
@@ -552,6 +569,7 @@ async def _download_queue_startup(app):
|
||||
|
||||
|
||||
async def _shutdown_download_manager(app):
|
||||
dqueue.close()
|
||||
Download.shutdown_manager()
|
||||
|
||||
|
||||
@@ -607,7 +625,7 @@ async def _schedule_nightly_update() -> None:
|
||||
|
||||
|
||||
async def _start_nightly_update_schedule(app):
|
||||
asyncio.create_task(_schedule_nightly_update())
|
||||
bg_tasks.create_task(_schedule_nightly_update(), name="nightly_update_schedule")
|
||||
|
||||
|
||||
app.on_startup.append(_start_nightly_update_schedule)
|
||||
@@ -656,7 +674,7 @@ async def watch_files():
|
||||
await sio.emit('ytdl_options_changed', serializer.encode(result))
|
||||
|
||||
log.info(f'Starting Watch File: {config.YTDL_OPTIONS_FILE}')
|
||||
asyncio.create_task(_watch_files())
|
||||
bg_tasks.create_task(_watch_files(), name="watch_ytdl_options_file")
|
||||
|
||||
async def _watch_files_startup(app):
|
||||
await watch_files()
|
||||
@@ -970,13 +988,20 @@ async def subscriptions_check(request):
|
||||
result = await submgr.check_now([str(i) for i in ids] if ids else None)
|
||||
return web.Response(text=serializer.encode(result))
|
||||
|
||||
def _require_id_list(post: dict) -> list:
|
||||
ids = post.get('ids')
|
||||
if not isinstance(ids, list) or not ids or not all(isinstance(i, str) for i in ids):
|
||||
raise web.HTTPBadRequest(reason="'ids' must be a non-empty list of strings")
|
||||
return ids
|
||||
|
||||
|
||||
@routes.post(config.URL_PREFIX + 'delete')
|
||||
async def delete(request):
|
||||
post = await _read_json_request(request)
|
||||
ids = post.get('ids')
|
||||
ids = _require_id_list(post)
|
||||
where = post.get('where')
|
||||
if not ids or where not in ['queue', 'done']:
|
||||
log.error("Bad request: missing 'ids' or incorrect 'where' value")
|
||||
if where not in ['queue', 'done']:
|
||||
log.error("Bad request: incorrect 'where' value")
|
||||
raise web.HTTPBadRequest()
|
||||
status = await (dqueue.cancel(ids) if where == 'queue' else dqueue.clear(ids))
|
||||
log.info(f"Download delete request processed for ids: {ids}, where: {where}")
|
||||
@@ -985,7 +1010,7 @@ async def delete(request):
|
||||
@routes.post(config.URL_PREFIX + 'start')
|
||||
async def start(request):
|
||||
post = await _read_json_request(request)
|
||||
ids = post.get('ids')
|
||||
ids = _require_id_list(post)
|
||||
log.info(f"Received request to start pending downloads for ids: {ids}")
|
||||
status = await dqueue.start_pending(ids)
|
||||
return web.Response(text=serializer.encode(status))
|
||||
@@ -1065,12 +1090,15 @@ async def cookie_status(request):
|
||||
async def history(request):
|
||||
history = { 'done': [], 'queue': [], 'pending': []}
|
||||
|
||||
for _, v in dqueue.queue.saved_items():
|
||||
history['queue'].append(v)
|
||||
for _, v in dqueue.done.saved_items():
|
||||
history['done'].append(v)
|
||||
for _, v in dqueue.pending.saved_items():
|
||||
history['pending'].append(v)
|
||||
# Served from the in-memory queues (like the socket 'all' event) rather
|
||||
# than saved_items(), which reloads and re-compacts the on-disk state on
|
||||
# every call.
|
||||
for _, v in dqueue.queue.items():
|
||||
history['queue'].append(v.info)
|
||||
for _, v in dqueue.done.items():
|
||||
history['done'].append(v.info)
|
||||
for _, v in dqueue.pending.items():
|
||||
history['pending'].append(v.info)
|
||||
|
||||
log.info("Sending download history")
|
||||
return web.Response(text=serializer.encode(history))
|
||||
@@ -1082,13 +1110,17 @@ async def connect(sid, environ):
|
||||
await sio.emit('subscriptions_all', serializer.encode([s.to_public_dict() for s in submgr.list_all()]), to=sid)
|
||||
await sio.emit('configuration', serializer.encode(config.frontend_safe()), to=sid)
|
||||
if config.CUSTOM_DIRS:
|
||||
await sio.emit('custom_dirs', serializer.encode(get_custom_dirs()), to=sid)
|
||||
# get_custom_dirs() can walk the whole download tree on a cache miss;
|
||||
# keep that off the event loop so a large library doesn't stall every
|
||||
# client's connect handshake.
|
||||
dirs = await asyncio.get_running_loop().run_in_executor(None, get_custom_dirs)
|
||||
await sio.emit('custom_dirs', serializer.encode(dirs), to=sid)
|
||||
if config.YTDL_OPTIONS_FILE:
|
||||
await sio.emit('ytdl_options_changed', serializer.encode(get_options_update_time()), to=sid)
|
||||
|
||||
def get_custom_dirs():
|
||||
cache_ttl_seconds = 5
|
||||
now = asyncio.get_running_loop().time()
|
||||
now = time.monotonic()
|
||||
cache_key = (
|
||||
config.DOWNLOAD_DIR,
|
||||
config.AUDIO_DOWNLOAD_DIR,
|
||||
|
||||
+129
-20
@@ -11,14 +11,23 @@ import time
|
||||
import types
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, fields
|
||||
from functools import partial
|
||||
from typing import Any, Optional
|
||||
|
||||
import yt_dlp
|
||||
import yt_dlp.networking.impersonate
|
||||
import bg_tasks
|
||||
from dl_formats import merge_ytdl_option_layers
|
||||
from state_store import AtomicJsonStore, read_legacy_shelf
|
||||
from url_guard import validate_url
|
||||
|
||||
log = logging.getLogger("subscriptions")
|
||||
|
||||
# How many subscription feeds to scan at once. Bounded so one slow/hung feed
|
||||
# doesn't serialize the rest, without bursting a large subscription list at the
|
||||
# extractor (which risks rate-limiting / bot detection).
|
||||
_MAX_CONCURRENT_CHECKS = 4
|
||||
|
||||
VIDEO_ONLY_MSG = (
|
||||
"This URL points to a single video, not a channel or playlist. Use Download instead."
|
||||
)
|
||||
@@ -42,7 +51,9 @@ def _impersonate_opt(ytdl_options: dict) -> dict:
|
||||
return opts
|
||||
|
||||
|
||||
def _build_ydl_params(config, *, playlistend: Optional[int] = None) -> dict:
|
||||
def _build_ydl_params(
|
||||
config, *, playlistend: Optional[int] = None, extra_opts: Optional[dict[str, Any]] = None
|
||||
) -> dict:
|
||||
params: dict[str, Any] = {
|
||||
"quiet": not logging.getLogger().isEnabledFor(logging.DEBUG),
|
||||
"verbose": logging.getLogger().isEnabledFor(logging.DEBUG),
|
||||
@@ -52,6 +63,7 @@ def _build_ydl_params(config, *, playlistend: Optional[int] = None) -> dict:
|
||||
"lazy_playlist": True,
|
||||
"paths": {"home": config.DOWNLOAD_DIR, "temp": config.TEMP_DIR},
|
||||
**config.YTDL_OPTIONS,
|
||||
**(extra_opts or {}),
|
||||
}
|
||||
params = _impersonate_opt(params)
|
||||
if playlistend is not None and playlistend > 0:
|
||||
@@ -76,9 +88,11 @@ def _is_media_entry(entry: Any) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def extract_flat_playlist(config, url: str, playlistend: int, *, _depth: int = 0):
|
||||
def extract_flat_playlist(
|
||||
config, url: str, playlistend: int, *, extra_opts: Optional[dict[str, Any]] = None, _depth: int = 0
|
||||
):
|
||||
"""Return (info_dict, entries_list) for playlist/channel URLs."""
|
||||
params = _build_ydl_params(config, playlistend=playlistend)
|
||||
params = _build_ydl_params(config, playlistend=playlistend, extra_opts=extra_opts)
|
||||
with yt_dlp.YoutubeDL(params=params) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
if not info:
|
||||
@@ -100,10 +114,14 @@ def extract_flat_playlist(config, url: str, playlistend: int, *, _depth: int = 0
|
||||
nested_url = _entry_video_url(ent)
|
||||
if not nested_url:
|
||||
continue
|
||||
# nested_url comes from remote playlist content; guard it too.
|
||||
if validate_url(nested_url) is not None:
|
||||
continue
|
||||
nested_info, nested_entries = extract_flat_playlist(
|
||||
config,
|
||||
nested_url,
|
||||
playlistend,
|
||||
extra_opts=extra_opts,
|
||||
_depth=_depth + 1,
|
||||
)
|
||||
if nested_entries:
|
||||
@@ -370,6 +388,23 @@ class SubscriptionManager:
|
||||
def _save_locked(self) -> None:
|
||||
self._store.save({"items": [_subscription_to_record(sub) for sub in self._subs.values()]})
|
||||
|
||||
def _scan_extra_opts(
|
||||
self,
|
||||
ytdl_options_presets: Optional[list[str]],
|
||||
ytdl_options_overrides: Optional[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""Merge configured presets (in order) with per-subscription overrides.
|
||||
|
||||
Applied on top of the global YTDL_OPTIONS when scanning a
|
||||
subscription's feed, so cookies/impersonation/etc. configured via a
|
||||
preset or override also take effect during the flat-playlist scan,
|
||||
not just the eventual per-video download. (The global YTDL_OPTIONS base
|
||||
is already spread into the scan params by ``_build_ydl_params``.)
|
||||
"""
|
||||
return merge_ytdl_option_layers(
|
||||
ytdl_options_presets, ytdl_options_overrides, self.config.YTDL_OPTIONS_PRESETS
|
||||
)
|
||||
|
||||
async def _queue_subscription_entries(
|
||||
self,
|
||||
entries: list[dict],
|
||||
@@ -436,12 +471,8 @@ class SubscriptionManager:
|
||||
def start_background_loop(self) -> None:
|
||||
if self._loop_task is not None and not self._loop_task.done():
|
||||
return
|
||||
self._loop_task = asyncio.create_task(self._periodic_loop())
|
||||
self._loop_task.add_done_callback(
|
||||
lambda t: log.error("Subscription loop failed: %s", t.exception())
|
||||
if not t.cancelled() and t.exception()
|
||||
else None
|
||||
)
|
||||
# bg_tasks.create_task already logs unexpected task failures with the name.
|
||||
self._loop_task = bg_tasks.create_task(self._periodic_loop(), name="subscription_loop")
|
||||
|
||||
async def _periodic_loop(self) -> None:
|
||||
while True:
|
||||
@@ -465,8 +496,30 @@ class SubscriptionManager:
|
||||
if now - sub.last_checked < interval_sec:
|
||||
continue
|
||||
due.append(sub)
|
||||
for sub in due:
|
||||
await self._check_one_unlocked(sub)
|
||||
await self._check_many(due)
|
||||
|
||||
async def _check_many(self, subs: list[SubscriptionInfo]) -> None:
|
||||
"""Check subscriptions with bounded concurrency so one slow feed does
|
||||
not serialize the rest. Failures are isolated per subscription."""
|
||||
if not subs:
|
||||
return
|
||||
sem = asyncio.Semaphore(_MAX_CONCURRENT_CHECKS)
|
||||
|
||||
async def _guarded(sub: SubscriptionInfo) -> None:
|
||||
async with sem:
|
||||
await self._check_one_unlocked(sub)
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(_guarded(sub) for sub in subs), return_exceptions=True
|
||||
)
|
||||
for sub, result in zip(subs, results):
|
||||
if isinstance(result, Exception):
|
||||
log.error(
|
||||
"Subscription check crashed for %s: %s",
|
||||
sub.name,
|
||||
result,
|
||||
exc_info=result,
|
||||
)
|
||||
|
||||
async def add_subscription(
|
||||
self,
|
||||
@@ -493,6 +546,12 @@ class SubscriptionManager:
|
||||
url = self._normalize_url(url)
|
||||
if not url:
|
||||
return {"status": "error", "msg": "Missing URL"}
|
||||
# SSRF guard: block non-http(s) schemes and internal/metadata hosts
|
||||
# before yt-dlp fetches the feed. May do a DNS lookup, so run off-loop.
|
||||
url_error = await asyncio.get_running_loop().run_in_executor(None, validate_url, url)
|
||||
if url_error is not None:
|
||||
log.warning('Rejected subscription URL "%s": %s', url, url_error)
|
||||
return {"status": "error", "msg": url_error}
|
||||
try:
|
||||
title_regex_stored = validate_title_regex(title_regex)
|
||||
except re.error as exc:
|
||||
@@ -513,8 +572,12 @@ class SubscriptionManager:
|
||||
|
||||
try:
|
||||
scan_first = max(int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50)), 1)
|
||||
scan_extra_opts = self._scan_extra_opts(ytdl_options_presets, ytdl_options_overrides)
|
||||
try:
|
||||
info, entries = extract_flat_playlist(self.config, url, scan_first)
|
||||
info, entries = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
partial(extract_flat_playlist, self.config, url, scan_first, extra_opts=scan_extra_opts),
|
||||
)
|
||||
except yt_dlp.utils.YoutubeDLError as exc:
|
||||
return {"status": "error", "msg": str(exc)}
|
||||
|
||||
@@ -629,6 +692,24 @@ class SubscriptionManager:
|
||||
except ValueError as exc:
|
||||
return {"status": "error", "msg": str(exc)}
|
||||
|
||||
enabled_set = False
|
||||
validated_enabled = False
|
||||
if "enabled" in changes:
|
||||
try:
|
||||
validated_enabled = _coerce_bool(changes["enabled"])
|
||||
enabled_set = True
|
||||
except ValueError as exc:
|
||||
return {"status": "error", "msg": str(exc)}
|
||||
|
||||
interval_set = False
|
||||
validated_interval = 0
|
||||
if "check_interval_minutes" in changes:
|
||||
try:
|
||||
validated_interval = max(1, int(changes["check_interval_minutes"]))
|
||||
except (TypeError, ValueError):
|
||||
return {"status": "error", "msg": "check_interval_minutes must be an integer"}
|
||||
interval_set = True
|
||||
|
||||
async with self._lock:
|
||||
sub = self._subs.get(sub_id)
|
||||
if not sub:
|
||||
@@ -636,10 +717,10 @@ class SubscriptionManager:
|
||||
previous = copy.deepcopy(sub)
|
||||
old_enabled = sub.enabled
|
||||
|
||||
if "enabled" in changes:
|
||||
sub.enabled = _coerce_bool(changes["enabled"])
|
||||
if "check_interval_minutes" in changes:
|
||||
sub.check_interval_minutes = max(1, int(changes["check_interval_minutes"]))
|
||||
if enabled_set:
|
||||
sub.enabled = validated_enabled
|
||||
if interval_set:
|
||||
sub.check_interval_minutes = validated_interval
|
||||
if "name" in changes and changes["name"]:
|
||||
sub.name = str(changes["name"])
|
||||
if validated_tr is not None:
|
||||
@@ -673,8 +754,7 @@ class SubscriptionManager:
|
||||
"Manual subscription check requested for %d subscription(s)",
|
||||
len(targets),
|
||||
)
|
||||
for sub in targets:
|
||||
await self._check_one_unlocked(sub)
|
||||
await self._check_many(targets)
|
||||
return {"status": "ok"}
|
||||
|
||||
async def _check_one_unlocked(self, sub: SubscriptionInfo) -> None:
|
||||
@@ -696,15 +776,23 @@ class SubscriptionManager:
|
||||
async def _check_one_inner(self, sub: SubscriptionInfo) -> None:
|
||||
sid = sub.id
|
||||
scan = int(getattr(self.config, "SUBSCRIPTION_SCAN_PLAYLIST_END", 50))
|
||||
# ytdl_options_presets/overrides are set at subscription creation and
|
||||
# never mutated afterwards (update_subscription doesn't allow it), so
|
||||
# reading them off `sub` here without holding the lock is safe.
|
||||
scan_extra_opts = self._scan_extra_opts(sub.ytdl_options_presets, sub.ytdl_options_overrides)
|
||||
log.info("Checking subscription: %s", sub.name)
|
||||
try:
|
||||
info, entries = extract_flat_playlist(self.config, sub.url, scan)
|
||||
info, entries = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
partial(extract_flat_playlist, self.config, sub.url, scan, extra_opts=scan_extra_opts),
|
||||
)
|
||||
except yt_dlp.utils.YoutubeDLError as exc:
|
||||
async with self._lock:
|
||||
cur = self._subs.get(sid)
|
||||
if cur:
|
||||
previous = copy.deepcopy(cur)
|
||||
cur.error = str(exc)
|
||||
cur.last_checked = time.time()
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
@@ -717,12 +805,13 @@ class SubscriptionManager:
|
||||
entries = [ent for ent in entries if _is_media_entry(ent)]
|
||||
|
||||
etype = (info or {}).get("_type") or "video"
|
||||
if etype == "video" or not entries:
|
||||
if etype == "video":
|
||||
async with self._lock:
|
||||
cur = self._subs.get(sid)
|
||||
if cur:
|
||||
previous = copy.deepcopy(cur)
|
||||
cur.error = VIDEO_ONLY_MSG
|
||||
cur.last_checked = time.time()
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
@@ -732,6 +821,22 @@ class SubscriptionManager:
|
||||
log.warning("Subscription %s no longer resolves to a subscribable feed", sub.name)
|
||||
await self.notifier.subscription_updated(sub)
|
||||
return
|
||||
if not entries:
|
||||
async with self._lock:
|
||||
cur = self._subs.get(sid)
|
||||
if cur:
|
||||
previous = copy.deepcopy(cur)
|
||||
cur.last_checked = time.time()
|
||||
cur.error = None
|
||||
try:
|
||||
self._save_locked()
|
||||
except Exception:
|
||||
self._subs[sid] = previous
|
||||
raise
|
||||
sub = cur
|
||||
log.warning("Subscription check finished for %s: No entries found", sub.name)
|
||||
await self.notifier.subscription_updated(sub)
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
cur = self._subs.get(sid)
|
||||
@@ -761,6 +866,10 @@ class SubscriptionManager:
|
||||
eid = _entry_id(ent)
|
||||
if not eid:
|
||||
continue
|
||||
# Seen entries that are currently live are deliberately re-queued:
|
||||
# a stream first seen as 'upcoming' must still be captured once it
|
||||
# goes live. The download queue dedups by URL while a capture is
|
||||
# in flight, so this can't double-queue an active capture.
|
||||
if eid in seen and ent.get("live_status") != "is_live":
|
||||
continue
|
||||
new_entries.append(ent)
|
||||
|
||||
+84
-3
@@ -6,6 +6,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
@@ -20,6 +21,7 @@ def mock_dqueue(monkeypatch):
|
||||
d.initialize = AsyncMock(return_value=None)
|
||||
d.add = AsyncMock(return_value={"status": "ok"})
|
||||
d.cancel = AsyncMock(return_value={"status": "ok"})
|
||||
d.clear = AsyncMock(return_value={"status": "ok"})
|
||||
d.start_pending = AsyncMock(return_value={"status": "ok"})
|
||||
d.cancel_add = MagicMock()
|
||||
d.queue = MagicMock()
|
||||
@@ -28,6 +30,9 @@ def mock_dqueue(monkeypatch):
|
||||
d.queue.saved_items = MagicMock(return_value=[])
|
||||
d.done.saved_items = MagicMock(return_value=[])
|
||||
d.pending.saved_items = MagicMock(return_value=[])
|
||||
d.queue.items = MagicMock(return_value=[])
|
||||
d.done.items = MagicMock(return_value=[])
|
||||
d.pending.items = MagicMock(return_value=[])
|
||||
d.get = MagicMock(return_value=([], []))
|
||||
monkeypatch.setattr(main, "dqueue", d)
|
||||
return d
|
||||
@@ -215,11 +220,35 @@ async def test_start_pending(mock_dqueue):
|
||||
mock_dqueue.start_pending.assert_awaited_once_with(["a"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("body", [{}, {"ids": "abc"}, {"ids": []}, {"ids": [1, 2]}])
|
||||
async def test_start_rejects_malformed_ids(mock_dqueue, body):
|
||||
req = _json_request(body)
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.start(req)
|
||||
mock_dqueue.start_pending.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
{"where": "queue"},
|
||||
{"where": "queue", "ids": "abc"},
|
||||
{"where": "queue", "ids": []},
|
||||
{"where": "queue", "ids": [1, 2]},
|
||||
],
|
||||
)
|
||||
async def test_delete_rejects_malformed_ids(mock_dqueue, body):
|
||||
req = _json_request(body)
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.delete(req)
|
||||
mock_dqueue.cancel.assert_not_awaited()
|
||||
mock_dqueue.clear.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_shape(mock_dqueue):
|
||||
mock_dqueue.queue.saved_items.return_value = []
|
||||
mock_dqueue.done.saved_items.return_value = []
|
||||
mock_dqueue.pending.saved_items.return_value = []
|
||||
req = MagicMock(spec=web.Request)
|
||||
resp = await main.history(req)
|
||||
assert resp.status == 200
|
||||
@@ -227,6 +256,30 @@ async def test_history_shape(mock_dqueue):
|
||||
assert set(data.keys()) == {"done", "queue", "pending"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_reads_in_memory_queues_not_disk_state(mock_dqueue):
|
||||
fake_queue_dl = MagicMock()
|
||||
fake_queue_dl.info = {"id": "q1", "title": "Queued"}
|
||||
fake_done_dl = MagicMock()
|
||||
fake_done_dl.info = {"id": "d1", "title": "Done"}
|
||||
fake_pending_dl = MagicMock()
|
||||
fake_pending_dl.info = {"id": "p1", "title": "Pending"}
|
||||
mock_dqueue.queue.items.return_value = [("q1", fake_queue_dl)]
|
||||
mock_dqueue.done.items.return_value = [("d1", fake_done_dl)]
|
||||
mock_dqueue.pending.items.return_value = [("p1", fake_pending_dl)]
|
||||
|
||||
req = MagicMock(spec=web.Request)
|
||||
resp = await main.history(req)
|
||||
assert resp.status == 200
|
||||
data = json.loads(resp.text)
|
||||
assert [item["id"] for item in data["queue"]] == ["q1"]
|
||||
assert [item["id"] for item in data["done"]] == ["d1"]
|
||||
assert [item["id"] for item in data["pending"]] == ["p1"]
|
||||
mock_dqueue.queue.saved_items.assert_not_called()
|
||||
mock_dqueue.done.saved_items.assert_not_called()
|
||||
mock_dqueue.pending.saved_items.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_json(mock_dqueue):
|
||||
req = MagicMock(spec=web.Request)
|
||||
@@ -311,6 +364,24 @@ async def test_subscribe_rejects_clip_options(mock_dqueue, monkeypatch):
|
||||
main.submgr.add_subscription.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscriptions_update_invalid_enabled_returns_error_not_500(mock_dqueue):
|
||||
req = _json_request({"id": "nonexistent", "enabled": "maybe"})
|
||||
resp = await main.subscriptions_update(req)
|
||||
assert resp.status == 200
|
||||
body = json.loads(resp.text)
|
||||
assert body["status"] == "error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscriptions_update_invalid_interval_returns_error_not_500(mock_dqueue):
|
||||
req = _json_request({"id": "nonexistent", "check_interval_minutes": "abc"})
|
||||
resp = await main.subscriptions_update(req)
|
||||
assert resp.status == 200
|
||||
body = json.loads(resp.text)
|
||||
assert body["status"] == "error"
|
||||
|
||||
|
||||
def test_is_within_state_dir_blocks_state_subtree():
|
||||
state_dir = main._STATE_DIR_REAL
|
||||
assert main._is_within_state_dir(state_dir)
|
||||
@@ -331,6 +402,11 @@ async def test_download_blocks_state_dir_files(monkeypatch):
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
(state_dir / "cookies.txt").write_text("# Netscape HTTP Cookie File\n", encoding="utf-8")
|
||||
(download_dir / "video.mp4").write_bytes(b"video")
|
||||
# request.path is already percent-decoded by aiohttp; state_dir_guard must
|
||||
# not decode it a second time, or a filename containing a literal '%'
|
||||
# gets mangled into a false 404.
|
||||
percent_filename = "100% done.mp4"
|
||||
(download_dir / percent_filename).write_bytes(b"percent video")
|
||||
|
||||
monkeypatch.setattr(main.config, "STATE_DIR", str(state_dir))
|
||||
monkeypatch.setattr(main, "_STATE_DIR_REAL", os.path.realpath(str(state_dir)))
|
||||
@@ -343,7 +419,12 @@ async def test_download_blocks_state_dir_files(monkeypatch):
|
||||
allowed = await client.get("/download/video.mp4")
|
||||
assert allowed.status == 200
|
||||
assert await allowed.read() == b"video"
|
||||
|
||||
percent_resp = await client.get("/download/" + quote(percent_filename))
|
||||
assert percent_resp.status == 200
|
||||
assert await percent_resp.read() == b"percent video"
|
||||
finally:
|
||||
(state_dir / "cookies.txt").unlink(missing_ok=True)
|
||||
(download_dir / "video.mp4").unlink(missing_ok=True)
|
||||
(download_dir / percent_filename).unlink(missing_ok=True)
|
||||
state_dir.rmdir()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests for the ``bg_tasks.create_task`` strong-reference/logging helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
import bg_tasks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_removes_itself_from_registry_on_success():
|
||||
async def _ok():
|
||||
return 42
|
||||
|
||||
task = bg_tasks.create_task(_ok(), name="ok_task")
|
||||
assert task in bg_tasks._TASKS
|
||||
result = await task
|
||||
assert result == 42
|
||||
assert task not in bg_tasks._TASKS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_logs_unhandled_exception(caplog):
|
||||
async def _boom():
|
||||
raise ValueError("kaboom")
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="bg_tasks"):
|
||||
task = bg_tasks.create_task(_boom(), name="boom_task")
|
||||
with pytest.raises(ValueError):
|
||||
await task
|
||||
# Let the done-callback (scheduled via call_soon) run.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert task not in bg_tasks._TASKS
|
||||
assert any("boom_task" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_does_not_log_on_cancellation(caplog):
|
||||
async def _sleep_forever():
|
||||
await asyncio.sleep(10)
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="bg_tasks"):
|
||||
task = bg_tasks.create_task(_sleep_forever(), name="cancel_task")
|
||||
await asyncio.sleep(0)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert task not in bg_tasks._TASKS
|
||||
assert not any("cancel_task" in record.message for record in caplog.records)
|
||||
@@ -159,6 +159,35 @@ class ConfigTests(unittest.TestCase):
|
||||
c = Config()
|
||||
self.assertEqual(c.CLEAR_COMPLETED_AFTER, "0")
|
||||
|
||||
def test_invalid_default_option_playlist_item_limit_exits(self):
|
||||
for bad in ("-1", "many"):
|
||||
with patch.dict(os.environ, _base_env(DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT=bad), clear=False):
|
||||
with self.assertRaises(SystemExit):
|
||||
Config()
|
||||
|
||||
def test_default_option_playlist_item_limit_zero_allowed(self):
|
||||
with patch.dict(os.environ, _base_env(DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT="0"), clear=False):
|
||||
c = Config()
|
||||
self.assertEqual(c.DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT, "0")
|
||||
|
||||
def test_invalid_subscription_default_check_interval_exits(self):
|
||||
for bad in ("0", "-1", "often"):
|
||||
with patch.dict(os.environ, _base_env(SUBSCRIPTION_DEFAULT_CHECK_INTERVAL=bad), clear=False):
|
||||
with self.assertRaises(SystemExit):
|
||||
Config()
|
||||
|
||||
def test_invalid_subscription_scan_playlist_end_exits(self):
|
||||
for bad in ("0", "-1", "all"):
|
||||
with patch.dict(os.environ, _base_env(SUBSCRIPTION_SCAN_PLAYLIST_END=bad), clear=False):
|
||||
with self.assertRaises(SystemExit):
|
||||
Config()
|
||||
|
||||
def test_invalid_subscription_max_seen_ids_exits(self):
|
||||
for bad in ("0", "-1", "unlimited"):
|
||||
with patch.dict(os.environ, _base_env(SUBSCRIPTION_MAX_SEEN_IDS=bad), clear=False):
|
||||
with self.assertRaises(SystemExit):
|
||||
Config()
|
||||
|
||||
def test_runtime_override_roundtrip(self):
|
||||
with patch.dict(os.environ, _base_env(), clear=False):
|
||||
c = Config()
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.dl_formats import (
|
||||
_normalize_subtitle_language,
|
||||
get_format,
|
||||
get_opts,
|
||||
merge_ytdl_option_layers,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,7 +119,31 @@ class DlFormatsTests(unittest.TestCase):
|
||||
|
||||
def test_get_opts_captions_txt_maps_to_srt_format(self):
|
||||
opts = get_opts("captions", "auto", "txt", "best", {})
|
||||
self.assertEqual(opts["subtitlesformat"], "srt")
|
||||
self.assertEqual(opts["subtitlesformat"], "srt/best")
|
||||
keys = [p["key"] for p in opts["postprocessors"]]
|
||||
self.assertIn("FFmpegSubtitlesConvertor", keys)
|
||||
convertor = next(p for p in opts["postprocessors"] if p["key"] == "FFmpegSubtitlesConvertor")
|
||||
self.assertEqual(convertor["format"], "srt")
|
||||
|
||||
def test_get_opts_captions_srt_guarantees_convertor(self):
|
||||
opts = get_opts("captions", "auto", "srt", "best", {})
|
||||
self.assertEqual(opts["subtitlesformat"], "srt/best")
|
||||
keys = [p["key"] for p in opts["postprocessors"]]
|
||||
self.assertIn("FFmpegSubtitlesConvertor", keys)
|
||||
|
||||
def test_get_opts_captions_vtt_guarantees_convertor(self):
|
||||
opts = get_opts("captions", "auto", "vtt", "best", {})
|
||||
self.assertEqual(opts["subtitlesformat"], "vtt/best")
|
||||
keys = [p["key"] for p in opts["postprocessors"]]
|
||||
self.assertIn("FFmpegSubtitlesConvertor", keys)
|
||||
convertor = next(p for p in opts["postprocessors"] if p["key"] == "FFmpegSubtitlesConvertor")
|
||||
self.assertEqual(convertor["format"], "vtt")
|
||||
|
||||
def test_get_opts_captions_ttml_has_no_convertor(self):
|
||||
opts = get_opts("captions", "auto", "ttml", "best", {})
|
||||
self.assertEqual(opts["subtitlesformat"], "ttml/best")
|
||||
keys = [p["key"] for p in opts["postprocessors"]]
|
||||
self.assertNotIn("FFmpegSubtitlesConvertor", keys)
|
||||
|
||||
def test_get_opts_merges_existing_postprocessors(self):
|
||||
opts = get_opts("audio", "auto", "opus", "best", {"postprocessors": [{"key": "SponsorBlock"}]})
|
||||
@@ -135,5 +160,23 @@ class DlFormatsTests(unittest.TestCase):
|
||||
self.assertEqual(_normalize_subtitle_language(" "), "en")
|
||||
|
||||
|
||||
class MergeYtdlOptionLayersTests(unittest.TestCase):
|
||||
def test_presets_applied_in_order_then_overrides(self):
|
||||
presets_config = {
|
||||
"a": {"x": 1, "y": 1},
|
||||
"b": {"y": 2, "z": 2},
|
||||
}
|
||||
merged = merge_ytdl_option_layers(["a", "b"], {"z": 3, "w": 4}, presets_config)
|
||||
# b overrides a's y; explicit overrides win over presets.
|
||||
self.assertEqual(merged, {"x": 1, "y": 2, "z": 3, "w": 4})
|
||||
|
||||
def test_no_base_options_included(self):
|
||||
# The helper only produces the preset/override layer, never base opts.
|
||||
self.assertEqual(merge_ytdl_option_layers(None, None, {}), {})
|
||||
|
||||
def test_unknown_preset_names_ignored(self):
|
||||
self.assertEqual(merge_ytdl_option_layers(["missing"], {"a": 1}, {}), {"a": 1})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import time
|
||||
|
||||
from ytdl import DownloadInfo, DownloadQueue
|
||||
from ytdl import Download, DownloadInfo, DownloadQueue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -46,6 +47,37 @@ def test_cancel_add_increments_generation(dq_env):
|
||||
assert dq._add_generation == before + 1
|
||||
|
||||
|
||||
def test_download_queue_has_dedicated_executor_sized_from_config(dq_env):
|
||||
notifier = MagicMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
assert dq._download_executor is not None
|
||||
assert dq._download_executor._max_workers == 2 * int(dq_env.MAX_CONCURRENT_DOWNLOADS) + 2
|
||||
dq.close()
|
||||
|
||||
|
||||
def test_close_cancels_running_downloads_before_shutdown(dq_env):
|
||||
notifier = MagicMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
|
||||
running = MagicMock()
|
||||
running.started.return_value = True
|
||||
running.running.return_value = True
|
||||
idle = MagicMock()
|
||||
idle.started.return_value = False
|
||||
idle.running.return_value = False
|
||||
|
||||
dq.queue.dict["u-running"] = running
|
||||
dq.queue.dict["u-idle"] = idle
|
||||
|
||||
dq.close()
|
||||
|
||||
# The active download's subprocess group is killed; the not-started one is
|
||||
# left alone. Executor is shut down afterwards.
|
||||
running.cancel.assert_called_once()
|
||||
idle.cancel.assert_not_called()
|
||||
assert dq._download_executor._shutdown
|
||||
|
||||
|
||||
def test_get_returns_tuple_of_lists(dq_env):
|
||||
notifier = MagicMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
@@ -222,6 +254,155 @@ async def test_add_entry_queues_single_video_without_reextracting(dq_env):
|
||||
assert dq.pending.exists("https://example.com/watch?v=1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_entry_duplicate_while_pending_is_skipped_not_clobbered(dq_env):
|
||||
notifier = AsyncMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
entry = {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Original Title",
|
||||
"url": "https://example.com/watch?v=1",
|
||||
"webpage_url": "https://example.com/watch?v=1",
|
||||
}
|
||||
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", side_effect=AssertionError("should not re-extract")):
|
||||
first = await dq.add_entry(entry, "video", "auto", "any", "best", "", "", 0, auto_start=False)
|
||||
assert first["status"] == "ok"
|
||||
assert "msg" not in first
|
||||
|
||||
dupe_entry = {**entry, "title": "Different Title"}
|
||||
second = await dq.add_entry(dupe_entry, "audio", "auto", "mp3", "best", "", "", 0, auto_start=False)
|
||||
|
||||
assert second["status"] == "ok"
|
||||
assert "Already in queue" in second["msg"]
|
||||
# The original pending download's options must survive untouched.
|
||||
pending_dl = dq.pending.get("https://example.com/watch?v=1")
|
||||
assert pending_dl.info.download_type == "video"
|
||||
assert pending_dl.info.title == "Original Title"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_entry_duplicate_while_queued_is_skipped(dq_env):
|
||||
notifier = AsyncMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
entry = {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": "https://example.com/watch?v=1",
|
||||
"webpage_url": "https://example.com/watch?v=1",
|
||||
}
|
||||
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", side_effect=AssertionError("should not re-extract")), \
|
||||
patch.object(DownloadQueue, "_DownloadQueue__start_download", new=AsyncMock()):
|
||||
first = await dq.add_entry(entry, "video", "auto", "any", "best", "", "", 0, auto_start=True)
|
||||
assert first["status"] == "ok"
|
||||
assert dq.queue.exists("https://example.com/watch?v=1")
|
||||
|
||||
second = await dq.add_entry(entry, "video", "auto", "any", "best", "", "", 0, auto_start=True)
|
||||
|
||||
assert second["status"] == "ok"
|
||||
assert "Already in queue" in second["msg"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_download_uses_output_template_when_channel_template_empty(dq_env):
|
||||
"""Channel tabs reported as playlists must honor OUTPUT_TEMPLATE when OUTPUT_TEMPLATE_CHANNEL is empty."""
|
||||
notifier = AsyncMock()
|
||||
dq_env.OUTPUT_TEMPLATE = "%(channel)s [YT]/%(title)s.%(ext)s"
|
||||
dq_env.OUTPUT_TEMPLATE_CHANNEL = ""
|
||||
dq_env.OUTPUT_TEMPLATE_PLAYLIST = ""
|
||||
|
||||
channel_id = "UCabcd123"
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "playlist",
|
||||
"id": channel_id,
|
||||
"channel_id": channel_id,
|
||||
"channel": "Odin",
|
||||
"title": "Odin - Videos",
|
||||
"entries": [
|
||||
{
|
||||
"id": "vid1",
|
||||
"title": "Salvia Plath - Pondering",
|
||||
"url": "https://example.com/watch?v=1",
|
||||
"webpage_url": "https://example.com/watch?v=1",
|
||||
"channel": "Odin",
|
||||
"upload_date": "20130804",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
||||
result = await dq.add(
|
||||
"https://www.youtube.com/@odin/videos",
|
||||
"video",
|
||||
"auto",
|
||||
"any",
|
||||
"best",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
auto_start=False,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
url = "https://example.com/watch?v=1"
|
||||
assert dq.pending.exists(url)
|
||||
download = dq.pending.get(url)
|
||||
assert download.output_template.startswith("Odin [YT]/")
|
||||
assert "Odin - Videos" not in download.output_template
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playlist_download_not_treated_as_channel(dq_env):
|
||||
"""Real playlists (id != channel_id) must not be promoted to channel downloads."""
|
||||
notifier = AsyncMock()
|
||||
dq_env.OUTPUT_TEMPLATE = "%(channel)s [YT]/%(title)s.%(ext)s"
|
||||
dq_env.OUTPUT_TEMPLATE_CHANNEL = ""
|
||||
dq_env.OUTPUT_TEMPLATE_PLAYLIST = "%(playlist_title)s/%(title)s.%(ext)s"
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "playlist",
|
||||
"id": "PLxyz789",
|
||||
"channel_id": "UCabcd123",
|
||||
"channel": "Odin",
|
||||
"title": "My Playlist",
|
||||
"entries": [
|
||||
{
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": "https://example.com/watch?v=1",
|
||||
"webpage_url": "https://example.com/watch?v=1",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
||||
result = await dq.add(
|
||||
"https://www.youtube.com/playlist?list=PLxyz789",
|
||||
"video",
|
||||
"auto",
|
||||
"any",
|
||||
"best",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
auto_start=False,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
url = "https://example.com/watch?v=1"
|
||||
assert dq.pending.exists(url)
|
||||
download = dq.pending.get(url)
|
||||
assert download.output_template.startswith("My Playlist/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_merges_global_preset_and_override_options(dq_env):
|
||||
notifier = AsyncMock()
|
||||
@@ -429,6 +610,9 @@ async def test_add_upcoming_stream_scheduled_without_starting(dq_env):
|
||||
assert download.info.live_release_timestamp is not None
|
||||
start_mock.assert_not_called()
|
||||
assert url in dq._scheduled_probe_at
|
||||
# The "scheduled to start at ..." message must include a UTC offset
|
||||
# (a naive datetime's %z would render as an empty string here).
|
||||
assert re.search(r"[+-]\d{4}$", download.info.error)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -687,3 +871,78 @@ def test_download_info_to_public_dict_excludes_server_only_fields():
|
||||
assert public["url"] == "https://example.com/watch?v=1"
|
||||
assert public["title"] == "Test Video"
|
||||
assert public["status"] == "pending"
|
||||
|
||||
|
||||
def _make_download(dq_env, *, download_type="video", status="downloading", filename=None):
|
||||
info = DownloadInfo(
|
||||
id="id1",
|
||||
title="t",
|
||||
url="http://example.com/v",
|
||||
quality="best",
|
||||
download_type=download_type,
|
||||
codec="auto",
|
||||
format="any",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
error=None,
|
||||
entry=None,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
)
|
||||
info.status = status
|
||||
info.filename = filename
|
||||
info.size = 123 if filename else None
|
||||
return Download(
|
||||
dq_env.DOWNLOAD_DIR, dq_env.TEMP_DIR, "%(title)s.%(ext)s", "%(title)s.%(ext)s", "best", "any", {}, info
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_download_cleanup_clears_filename_on_error(dq_env):
|
||||
notifier = AsyncMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
download = _make_download(dq_env, status="downloading", filename="../tmp/partial.mp4")
|
||||
dq.queue.put(download)
|
||||
|
||||
dq._post_download_cleanup(download)
|
||||
|
||||
assert download.info.status == "error"
|
||||
assert download.info.filename is None
|
||||
assert download.info.size is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_download_cleanup_keeps_captured_subtitles_on_error(dq_env):
|
||||
notifier = AsyncMock()
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
download = _make_download(dq_env, download_type="captions", status="downloading", filename="en.srt")
|
||||
download.info.subtitle_files = [{"filename": "en.srt", "size": 42}]
|
||||
dq.queue.put(download)
|
||||
|
||||
dq._post_download_cleanup(download)
|
||||
|
||||
assert download.info.status == "error"
|
||||
assert download.info.filename == "en.srt"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_skips_deletion_outside_download_directory(dq_env):
|
||||
notifier = AsyncMock()
|
||||
dq_env.DELETE_FILE_ON_TRASHCAN = True
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
|
||||
outside_dir = tempfile.mkdtemp()
|
||||
outside_file = os.path.join(outside_dir, "outside.txt")
|
||||
with open(outside_file, "w") as f:
|
||||
f.write("do not delete me")
|
||||
|
||||
# A crafted/legacy relative filename that escapes DOWNLOAD_DIR via '..'.
|
||||
escaping_filename = os.path.relpath(outside_file, dq_env.DOWNLOAD_DIR)
|
||||
download = _make_download(dq_env, status="finished", filename=escaping_filename)
|
||||
dq.done.put(download)
|
||||
|
||||
await dq.clear([download.info.url])
|
||||
|
||||
assert os.path.exists(outside_file)
|
||||
assert not dq.done.exists(download.info.url)
|
||||
|
||||
@@ -220,42 +220,61 @@ class ParseDownloadOptionsTests(unittest.TestCase):
|
||||
|
||||
def test_clip_url_t_param_strips_query_and_sets_start(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=855s",
|
||||
"url": "https://www.youtube.com/watch?v=1&t=855s",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1")
|
||||
self.assertEqual(parsed["url"], "https://www.youtube.com/watch?v=1")
|
||||
self.assertEqual(parsed["clip_start"], 855.0)
|
||||
self.assertIsNone(parsed["clip_end"])
|
||||
|
||||
def test_clip_explicit_start_wins_over_url_t(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=100",
|
||||
"url": "https://www.youtube.com/watch?v=1&t=100",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
"clip_start": "50",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1")
|
||||
self.assertEqual(parsed["url"], "https://www.youtube.com/watch?v=1")
|
||||
self.assertEqual(parsed["clip_start"], 50.0)
|
||||
self.assertIsNone(parsed["clip_end"])
|
||||
|
||||
def test_clip_end_only_sets_start_zero_and_strips_url_t(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=999",
|
||||
"url": "https://www.youtube.com/watch?v=1&t=999",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
"clip_end": "60",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1")
|
||||
self.assertEqual(parsed["url"], "https://www.youtube.com/watch?v=1")
|
||||
self.assertEqual(parsed["clip_start"], 0.0)
|
||||
self.assertEqual(parsed["clip_end"], 60.0)
|
||||
|
||||
def test_clip_url_t_param_ignored_on_non_youtube_host(self):
|
||||
# 't' is a generic query param name; only rewrite it on YouTube hosts
|
||||
# so an unrelated site's URL isn't silently mutated with a bogus clip.
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=855s",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1&t=855s")
|
||||
self.assertIsNone(parsed["clip_start"])
|
||||
self.assertIsNone(parsed["clip_end"])
|
||||
|
||||
def test_extract_t_query_youtu_be_short_host(self):
|
||||
cleaned, start = main._extract_t_query_from_url("https://youtu.be/abc123?t=90")
|
||||
self.assertEqual(cleaned, "https://youtu.be/abc123")
|
||||
self.assertEqual(start, 90.0)
|
||||
|
||||
def test_clip_rejects_end_before_start(self):
|
||||
with self.assertRaises(main.web.HTTPBadRequest):
|
||||
main.parse_download_options({
|
||||
@@ -280,5 +299,17 @@ class ParseDownloadOptionsTests(unittest.TestCase):
|
||||
})
|
||||
|
||||
|
||||
class GetCustomDirsTests(unittest.TestCase):
|
||||
def test_works_without_a_running_event_loop(self):
|
||||
# get_custom_dirs() used to time its cache via
|
||||
# asyncio.get_running_loop().time(), which raises RuntimeError outside
|
||||
# a running loop (e.g. when called from a plain executor thread). It
|
||||
# must work from a synchronous context too.
|
||||
result = main.get_custom_dirs()
|
||||
self.assertIn("download_dir", result)
|
||||
self.assertIn("audio_download_dir", result)
|
||||
self.assertIn("", result["download_dir"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shelve
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
@@ -29,6 +31,7 @@ sys.modules.setdefault("yt_dlp.networking", fake_networking)
|
||||
sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate)
|
||||
|
||||
from subscriptions import (
|
||||
SubscriptionInfo,
|
||||
SubscriptionManager,
|
||||
_is_subscriber_only_entry,
|
||||
coerce_optional_bool,
|
||||
@@ -44,6 +47,7 @@ class _Config:
|
||||
self.DOWNLOAD_DIR = state_dir
|
||||
self.TEMP_DIR = state_dir
|
||||
self.YTDL_OPTIONS = {}
|
||||
self.YTDL_OPTIONS_PRESETS = {}
|
||||
|
||||
|
||||
class _Queue:
|
||||
@@ -571,8 +575,16 @@ class SubscriptionPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
|
||||
sub_id = result["subscription"]["id"]
|
||||
with self.assertRaises(ValueError):
|
||||
await mgr.update_subscription(sub_id, {"enabled": "maybe"})
|
||||
update_result = await mgr.update_subscription(sub_id, {"enabled": "maybe"})
|
||||
self.assertEqual(update_result["status"], "error")
|
||||
stored = mgr.get(sub_id)
|
||||
self.assertTrue(stored.enabled)
|
||||
|
||||
update_result = await mgr.update_subscription(
|
||||
sub_id, {"check_interval_minutes": "abc"}
|
||||
)
|
||||
self.assertEqual(update_result["status"], "error")
|
||||
self.assertEqual(mgr.get(sub_id).check_interval_minutes, 60)
|
||||
|
||||
async def test_add_subscription_rejects_invalid_title_regex(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -1012,6 +1024,223 @@ class ExtractFlatPlaylistTests(unittest.TestCase):
|
||||
self.assertEqual(info.get("_type"), "playlist")
|
||||
self.assertEqual([entry["webpage_url"] for entry in entries], ["https://example.com/v1"])
|
||||
|
||||
def test_extra_opts_applied_on_top_of_config_options(self):
|
||||
captured: dict = {}
|
||||
|
||||
class _FakeYDL:
|
||||
def __init__(self, params):
|
||||
captured.update(params)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def extract_info(self, url, download=False):
|
||||
return {"_type": "video"}
|
||||
|
||||
cfg = _Config(tempfile.mkdtemp())
|
||||
with patch("subscriptions.yt_dlp.YoutubeDL", _FakeYDL, create=True):
|
||||
extract_flat_playlist(cfg, "https://example.com/v1", 50, extra_opts={"cookiefile": "x"})
|
||||
|
||||
self.assertEqual(captured.get("cookiefile"), "x")
|
||||
|
||||
|
||||
def _make_scan_capturing_fake_ydl(captured_params: list, entries: list[dict]):
|
||||
class _FakeYDL:
|
||||
def __init__(self, params):
|
||||
captured_params.append(params)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def extract_info(self, url, download=False):
|
||||
return {"_type": "channel", "title": "Channel", "entries": entries}
|
||||
|
||||
return _FakeYDL
|
||||
|
||||
|
||||
class SubscriptionScanExtraOptsTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_add_subscription_scan_applies_presets_and_overrides(self):
|
||||
captured_params: list = []
|
||||
fake_ydl = _make_scan_capturing_fake_ydl(
|
||||
captured_params,
|
||||
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg = _Config(tmp)
|
||||
cfg.YTDL_OPTIONS_PRESETS = {"mypreset": {"cookiefile": "preset.txt"}}
|
||||
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||
|
||||
with patch("subscriptions.yt_dlp.YoutubeDL", fake_ydl, create=True):
|
||||
await mgr.add_subscription(
|
||||
"https://example.com/channel",
|
||||
check_interval_minutes=60,
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
auto_start=True,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
ytdl_options_presets=["mypreset"],
|
||||
ytdl_options_overrides={"extra": "override"},
|
||||
)
|
||||
|
||||
self.assertTrue(captured_params)
|
||||
self.assertEqual(captured_params[0].get("cookiefile"), "preset.txt")
|
||||
self.assertEqual(captured_params[0].get("extra"), "override")
|
||||
|
||||
async def test_check_now_scan_applies_stored_subscription_presets(self):
|
||||
entries = [{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
cfg = _Config(tmp)
|
||||
cfg.YTDL_OPTIONS_PRESETS = {"mypreset": {"cookiefile": "preset.txt"}}
|
||||
mgr = SubscriptionManager(cfg, _Queue(), _Notifier())
|
||||
|
||||
add_captured: list = []
|
||||
with patch(
|
||||
"subscriptions.yt_dlp.YoutubeDL",
|
||||
_make_scan_capturing_fake_ydl(add_captured, entries),
|
||||
create=True,
|
||||
):
|
||||
result = await mgr.add_subscription(
|
||||
"https://example.com/channel",
|
||||
check_interval_minutes=60,
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
auto_start=True,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
ytdl_options_presets=["mypreset"],
|
||||
)
|
||||
sub_id = result["subscription"]["id"]
|
||||
|
||||
check_captured: list = []
|
||||
with patch(
|
||||
"subscriptions.yt_dlp.YoutubeDL",
|
||||
_make_scan_capturing_fake_ydl(check_captured, entries),
|
||||
create=True,
|
||||
):
|
||||
await mgr.check_now([sub_id])
|
||||
|
||||
self.assertTrue(check_captured)
|
||||
self.assertEqual(check_captured[0].get("cookiefile"), "preset.txt")
|
||||
|
||||
|
||||
class SubscriptionEventLoopTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_check_now_does_not_block_event_loop(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
queue = _Queue()
|
||||
mgr = SubscriptionManager(_Config(tmp), queue, _Notifier())
|
||||
|
||||
with patch(
|
||||
"subscriptions.extract_flat_playlist",
|
||||
return_value=(
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||
),
|
||||
):
|
||||
result = await mgr.add_subscription(
|
||||
"https://example.com/channel",
|
||||
check_interval_minutes=60,
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
quality="best",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
auto_start=True,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
subtitle_language="en",
|
||||
subtitle_mode="prefer_manual",
|
||||
)
|
||||
sub_id = result["subscription"]["id"]
|
||||
|
||||
def _slow_extract(config, url, playlistend, **kwargs):
|
||||
time.sleep(0.3)
|
||||
return (
|
||||
{"_type": "channel", "title": "Channel"},
|
||||
[{"id": "v1", "title": "One", "webpage_url": "https://example.com/v1"}],
|
||||
)
|
||||
|
||||
with patch("subscriptions.extract_flat_playlist", side_effect=_slow_extract):
|
||||
check_task = asyncio.ensure_future(mgr.check_now([sub_id]))
|
||||
# If check_now() blocked the event loop, this would not complete
|
||||
# until after the slow extraction finishes.
|
||||
await asyncio.wait_for(asyncio.sleep(0.05), timeout=0.2)
|
||||
self.assertFalse(check_task.done())
|
||||
await check_task
|
||||
|
||||
async def test_check_many_isolates_a_crashing_subscription(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||
|
||||
good = SubscriptionInfo(id="good", name="Good", url="https://example.com/good")
|
||||
bad = SubscriptionInfo(id="bad", name="Bad", url="https://example.com/bad")
|
||||
other = SubscriptionInfo(id="other", name="Other", url="https://example.com/other")
|
||||
|
||||
checked: list[str] = []
|
||||
|
||||
async def fake_check(sub):
|
||||
if sub.id == "bad":
|
||||
raise RuntimeError("boom")
|
||||
checked.append(sub.id)
|
||||
|
||||
with patch.object(mgr, "_check_one_unlocked", side_effect=fake_check):
|
||||
# The crashing subscription must not prevent the others running.
|
||||
await mgr._check_many([good, bad, other])
|
||||
|
||||
self.assertIn("good", checked)
|
||||
self.assertIn("other", checked)
|
||||
|
||||
async def test_check_many_bounded_concurrency(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mgr = SubscriptionManager(_Config(tmp), _Queue(), _Notifier())
|
||||
subs = [
|
||||
SubscriptionInfo(id=str(i), name=str(i), url=f"https://example.com/{i}")
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
import subscriptions as subs_mod
|
||||
|
||||
concurrent = 0
|
||||
peak = 0
|
||||
|
||||
async def fake_check(sub):
|
||||
nonlocal concurrent, peak
|
||||
concurrent += 1
|
||||
peak = max(peak, concurrent)
|
||||
await asyncio.sleep(0.02)
|
||||
concurrent -= 1
|
||||
|
||||
with patch.object(mgr, "_check_one_unlocked", side_effect=fake_check):
|
||||
await mgr._check_many(subs)
|
||||
|
||||
# Never exceed the configured bound, but do run more than one at once.
|
||||
self.assertLessEqual(peak, subs_mod._MAX_CONCURRENT_CHECKS)
|
||||
self.assertGreater(peak, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Tests for the SSRF URL guard (``url_guard.validate_url``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from url_guard import validate_url
|
||||
|
||||
|
||||
def _addrinfo(*addrs, family=socket.AF_INET):
|
||||
return [(family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (addr, 0)) for addr in addrs]
|
||||
|
||||
|
||||
class NonUrlInputTests(unittest.TestCase):
|
||||
"""Bare IDs and yt-dlp search/extractor prefixes must pass untouched."""
|
||||
|
||||
def test_bare_video_id_allowed(self):
|
||||
self.assertIsNone(validate_url("dQw4w9WgXcQ"))
|
||||
|
||||
def test_ytsearch_prefix_allowed(self):
|
||||
self.assertIsNone(validate_url("ytsearch:some song"))
|
||||
|
||||
def test_empty_string_allowed(self):
|
||||
self.assertIsNone(validate_url(""))
|
||||
|
||||
def test_non_string_rejected(self):
|
||||
self.assertIsNotNone(validate_url(None))
|
||||
|
||||
|
||||
class SchemeTests(unittest.TestCase):
|
||||
def test_file_scheme_blocked(self):
|
||||
self.assertIsNotNone(validate_url("file:///etc/passwd"))
|
||||
|
||||
def test_ftp_scheme_blocked(self):
|
||||
self.assertIsNotNone(validate_url("ftp://example.com/x"))
|
||||
|
||||
def test_data_scheme_blocked(self):
|
||||
self.assertIsNotNone(validate_url("data://text/plain;base64,AAAA"))
|
||||
|
||||
|
||||
class HostnameBlocklistTests(unittest.TestCase):
|
||||
def test_localhost_blocked_without_lookup(self):
|
||||
with mock.patch("url_guard.socket.getaddrinfo") as gai:
|
||||
self.assertIsNotNone(validate_url("http://localhost:8080/x"))
|
||||
gai.assert_not_called()
|
||||
|
||||
def test_localhost_subdomain_blocked(self):
|
||||
self.assertIsNotNone(validate_url("http://foo.localhost/x"))
|
||||
|
||||
def test_gcp_metadata_name_blocked(self):
|
||||
self.assertIsNotNone(validate_url("http://metadata.google.internal/x"))
|
||||
|
||||
|
||||
class AddressResolutionTests(unittest.TestCase):
|
||||
def _validate_with_addrs(self, url, *addrs, family=socket.AF_INET):
|
||||
with mock.patch("url_guard.socket.getaddrinfo", return_value=_addrinfo(*addrs, family=family)):
|
||||
return validate_url(url)
|
||||
|
||||
def test_public_https_allowed(self):
|
||||
self.assertIsNone(self._validate_with_addrs("https://youtube.com/watch?v=x", "142.250.1.1"))
|
||||
|
||||
def test_public_http_allowed(self):
|
||||
self.assertIsNone(self._validate_with_addrs("http://example.com/x", "93.184.216.34"))
|
||||
|
||||
def test_link_local_metadata_blocked(self):
|
||||
self.assertIsNotNone(self._validate_with_addrs("http://metadata/x", "169.254.169.254"))
|
||||
|
||||
def test_loopback_ipv4_blocked(self):
|
||||
self.assertIsNotNone(self._validate_with_addrs("http://127.0.0.1/x", "127.0.0.1"))
|
||||
|
||||
def test_private_rfc1918_blocked(self):
|
||||
self.assertIsNotNone(self._validate_with_addrs("http://intranet/x", "10.0.0.5"))
|
||||
|
||||
def test_decimal_ip_form_blocked(self):
|
||||
# 2852039166 == 169.254.169.254; the OS resolver normalizes it.
|
||||
self.assertIsNotNone(self._validate_with_addrs("http://2852039166/x", "169.254.169.254"))
|
||||
|
||||
def test_ipv6_loopback_blocked(self):
|
||||
self.assertIsNotNone(
|
||||
self._validate_with_addrs("http://[::1]/x", "::1", family=socket.AF_INET6)
|
||||
)
|
||||
|
||||
def test_ipv4_mapped_ipv6_metadata_blocked(self):
|
||||
self.assertIsNotNone(
|
||||
self._validate_with_addrs(
|
||||
"http://evil/x", "::ffff:169.254.169.254", family=socket.AF_INET6
|
||||
)
|
||||
)
|
||||
|
||||
def test_mixed_public_and_private_blocked(self):
|
||||
# If any resolved address is internal, reject the whole URL.
|
||||
self.assertIsNotNone(self._validate_with_addrs("http://mixed/x", "142.250.1.1", "127.0.0.1"))
|
||||
|
||||
def test_resolution_failure_defers_to_ytdlp(self):
|
||||
with mock.patch("url_guard.socket.getaddrinfo", side_effect=socket.gaierror):
|
||||
self.assertIsNone(validate_url("http://does-not-resolve.example/x"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,16 +3,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pickle
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
fake_yt_dlp = types.ModuleType("yt_dlp")
|
||||
fake_networking = types.ModuleType("yt_dlp.networking")
|
||||
fake_impersonate = types.ModuleType("yt_dlp.networking.impersonate")
|
||||
fake_postprocessor = types.ModuleType("yt_dlp.postprocessor")
|
||||
fake_postprocessor_common = types.ModuleType("yt_dlp.postprocessor.common")
|
||||
fake_utils = types.ModuleType("yt_dlp.utils")
|
||||
|
||||
|
||||
@@ -22,24 +26,37 @@ class _ImpersonateTarget:
|
||||
return value
|
||||
|
||||
|
||||
class _PostProcessor:
|
||||
def __init__(self, downloader=None):
|
||||
self._downloader = downloader
|
||||
|
||||
|
||||
fake_impersonate.ImpersonateTarget = _ImpersonateTarget
|
||||
fake_networking.impersonate = fake_impersonate
|
||||
fake_postprocessor_common.PostProcessor = _PostProcessor
|
||||
# The inner ``key`` group mirrors the real ``STR_FORMAT_RE_TMPL`` so that
|
||||
# ``_OUTTMPL_FIELD_RE`` (compiled at import time) has the named group that
|
||||
# ``_resolve_outtmpl_fields`` reads via ``match.group('key')``.
|
||||
fake_utils.STR_FORMAT_RE_TMPL = r"(?P<prefix>)%\((?P<has_key>(?P<key>{}))\)(?P<format>[-0-9.]*{})"
|
||||
fake_utils.STR_FORMAT_TYPES = "diouxXeEfFgGcrsa"
|
||||
fake_yt_dlp.networking = fake_networking
|
||||
fake_yt_dlp.postprocessor = fake_postprocessor
|
||||
fake_yt_dlp.utils = fake_utils
|
||||
sys.modules.setdefault("yt_dlp", fake_yt_dlp)
|
||||
sys.modules.setdefault("yt_dlp.networking", fake_networking)
|
||||
sys.modules.setdefault("yt_dlp.networking.impersonate", fake_impersonate)
|
||||
sys.modules.setdefault("yt_dlp.postprocessor", fake_postprocessor)
|
||||
sys.modules.setdefault("yt_dlp.postprocessor.common", fake_postprocessor_common)
|
||||
sys.modules.setdefault("yt_dlp.utils", fake_utils)
|
||||
|
||||
import ytdl
|
||||
from ytdl import (
|
||||
Download,
|
||||
DownloadInfo,
|
||||
_compact_persisted_entry,
|
||||
_convert_srt_to_txt_file,
|
||||
_AlbumArtistPostProcessor,
|
||||
_output_dir_escapes,
|
||||
_resolve_outtmpl_fields,
|
||||
_sanitize_entry_for_pickle,
|
||||
_sanitize_path_component,
|
||||
@@ -50,6 +67,132 @@ from ytdl import (
|
||||
_has_real_ytdlp = hasattr(sys.modules.get("yt_dlp"), "YoutubeDL")
|
||||
|
||||
|
||||
class AlbumArtistPostProcessorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.postprocessor = _AlbumArtistPostProcessor()
|
||||
|
||||
def test_fills_album_artist_from_artist(self):
|
||||
info = {'album': 'CrasH Talk', 'artist': 'ScHoolboy Q'}
|
||||
|
||||
_, result = self.postprocessor.run(info)
|
||||
|
||||
self.assertEqual(result['album_artist'], 'ScHoolboy Q')
|
||||
|
||||
def test_uses_main_artist_for_featured_track(self):
|
||||
info = {
|
||||
'album': 'CrasH Talk',
|
||||
'artists': ['ScHoolboy Q · Travis Scott'],
|
||||
}
|
||||
|
||||
_, result = self.postprocessor.run(info)
|
||||
|
||||
self.assertEqual(result['album_artist'], 'ScHoolboy Q')
|
||||
|
||||
def test_uses_topic_channel_artist_for_joint_album(self):
|
||||
info = {
|
||||
'album': 'Watch the Throne',
|
||||
'artists': ['JAY-Z', 'Kanye West'],
|
||||
'channel': 'JAY-Z & Kanye West - Topic',
|
||||
}
|
||||
|
||||
_, result = self.postprocessor.run(info)
|
||||
|
||||
self.assertEqual(result['album_artist'], 'JAY-Z & Kanye West')
|
||||
|
||||
def test_uses_topic_uploader_and_strips_suffix_for_compilation(self):
|
||||
info = {
|
||||
'album': 'Compilation',
|
||||
'artist': 'Track Artist',
|
||||
'channel': 'Regular Channel',
|
||||
'uploader': 'Various Artists - Topic',
|
||||
}
|
||||
|
||||
_, result = self.postprocessor.run(info)
|
||||
|
||||
self.assertEqual(result['album_artist'], 'Various Artists')
|
||||
|
||||
def test_regular_channel_falls_back_to_main_artist(self):
|
||||
info = {
|
||||
'album': 'Album',
|
||||
'artist': 'Track Artist',
|
||||
'channel': 'Label Channel',
|
||||
}
|
||||
|
||||
_, result = self.postprocessor.run(info)
|
||||
|
||||
self.assertEqual(result['album_artist'], 'Track Artist')
|
||||
|
||||
def test_preserves_explicit_various_artists(self):
|
||||
info = {
|
||||
'album': 'Revenge of the Dreamers III',
|
||||
'artist': 'J. Cole',
|
||||
'album_artist': 'Various Artists',
|
||||
}
|
||||
|
||||
_, result = self.postprocessor.run(info)
|
||||
|
||||
self.assertEqual(result['album_artist'], 'Various Artists')
|
||||
|
||||
def test_preserves_existing_album_artists_list(self):
|
||||
info = {
|
||||
'album': 'Album',
|
||||
'artist': 'Track Artist',
|
||||
'album_artists': ['Album Artist'],
|
||||
}
|
||||
|
||||
_, result = self.postprocessor.run(info)
|
||||
|
||||
self.assertEqual(result['album_artists'], ['Album Artist'])
|
||||
self.assertNotIn('album_artist', result)
|
||||
|
||||
def test_uses_first_artist_when_artist_list_has_multiple_entries(self):
|
||||
info = {'album': 'Album', 'artists': ['Main Artist', 'Featured Artist']}
|
||||
|
||||
_, result = self.postprocessor.run(info)
|
||||
|
||||
self.assertEqual(result['album_artist'], 'Main Artist')
|
||||
|
||||
def test_does_not_fill_without_album(self):
|
||||
info = {'artist': 'Standalone Artist'}
|
||||
|
||||
_, result = self.postprocessor.run(info)
|
||||
|
||||
self.assertNotIn('album_artist', result)
|
||||
self.assertNotIn('album_artists', result)
|
||||
|
||||
def test_does_not_fill_without_artist(self):
|
||||
info = {'album': 'Instrumental Album'}
|
||||
|
||||
_, result = self.postprocessor.run(info)
|
||||
|
||||
self.assertNotIn('album_artist', result)
|
||||
self.assertNotIn('album_artists', result)
|
||||
|
||||
|
||||
class AlbumArtistRegistrationTests(unittest.TestCase):
|
||||
def test_audio_download_registers_pre_process_postprocessor(self):
|
||||
download = _make_test_download()
|
||||
download.info.download_type = 'audio'
|
||||
fake_ydl = MagicMock()
|
||||
|
||||
with patch('ytdl.yt_dlp.YoutubeDL', return_value=fake_ydl):
|
||||
result = download._make_youtube_dl({'quiet': True})
|
||||
|
||||
self.assertIs(result, fake_ydl)
|
||||
postprocessor, = fake_ydl.add_post_processor.call_args.args
|
||||
self.assertIsInstance(postprocessor, _AlbumArtistPostProcessor)
|
||||
self.assertEqual(fake_ydl.add_post_processor.call_args.kwargs, {'when': 'pre_process'})
|
||||
|
||||
def test_video_download_does_not_register_postprocessor(self):
|
||||
download = _make_test_download()
|
||||
fake_ydl = MagicMock()
|
||||
|
||||
with patch('ytdl.yt_dlp.YoutubeDL', return_value=fake_ydl):
|
||||
download._make_youtube_dl({'quiet': True})
|
||||
|
||||
fake_ydl.add_post_processor.assert_not_called()
|
||||
|
||||
|
||||
class SanitizePathComponentTests(unittest.TestCase):
|
||||
def test_replaces_windows_invalid_chars(self):
|
||||
self.assertEqual(_sanitize_path_component('a:b*c?d"e<f>g|h'), "a_b_c_d_e_f_g_h")
|
||||
@@ -58,6 +201,24 @@ class SanitizePathComponentTests(unittest.TestCase):
|
||||
self.assertIs(_sanitize_path_component(None), None)
|
||||
self.assertEqual(_sanitize_path_component(42), 42)
|
||||
|
||||
def test_strips_path_separators_and_traversal(self):
|
||||
result = _sanitize_path_component('../../../../etc/x')
|
||||
self.assertNotIn('..', result)
|
||||
self.assertNotIn('/', result)
|
||||
self.assertNotIn('\\', result)
|
||||
|
||||
def test_strips_leading_absolute_path_separator(self):
|
||||
result = _sanitize_path_component('/tmp/x')
|
||||
self.assertFalse(result.startswith('/'))
|
||||
self.assertFalse(result.startswith('\\'))
|
||||
self.assertEqual(result, '_tmp_x')
|
||||
|
||||
def test_collapses_slashes_in_legitimate_titles(self):
|
||||
self.assertEqual(_sanitize_path_component('AC/DC'), 'AC_DC')
|
||||
|
||||
def test_empty_after_strip_becomes_underscore(self):
|
||||
self.assertEqual(_sanitize_path_component(' '), '_')
|
||||
|
||||
|
||||
@unittest.skipUnless(_has_real_ytdlp, "requires real yt-dlp")
|
||||
class ResolveOuttmplFieldsTests(unittest.TestCase):
|
||||
@@ -122,6 +283,37 @@ class ResolveOuttmplFieldsTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result, "5 - %(title)s.%(ext)s")
|
||||
|
||||
def test_malicious_playlist_title_cannot_escape_via_template(self):
|
||||
malicious_title = '/tmp/METUBE_ARBITRARY_WRITE_POC'
|
||||
entry = {
|
||||
'playlist_title': malicious_title,
|
||||
'playlist_index': '1',
|
||||
'title': 'video',
|
||||
'ext': 'mp4',
|
||||
}
|
||||
sanitized = {k: _sanitize_path_component(v) for k, v in entry.items()}
|
||||
template = '%(playlist_title)s/%(title)s.%(ext)s'
|
||||
result = _resolve_outtmpl_fields(template, sanitized, ('playlist',))
|
||||
marker = result.find('%(')
|
||||
literal_prefix = result[:marker] if marker != -1 else result
|
||||
self.assertNotIn('..', literal_prefix)
|
||||
self.assertFalse(literal_prefix.startswith('/'))
|
||||
self.assertFalse(literal_prefix.startswith('\\'))
|
||||
|
||||
|
||||
class OutputDirEscapesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.base_dir = tempfile.mkdtemp()
|
||||
|
||||
def test_relative_traversal_escapes(self):
|
||||
self.assertTrue(_output_dir_escapes(self.base_dir, '../../tmp/x/%(title)s.%(ext)s'))
|
||||
|
||||
def test_absolute_path_escapes(self):
|
||||
self.assertTrue(_output_dir_escapes(self.base_dir, '/tmp/x/%(title)s.%(ext)s'))
|
||||
|
||||
def test_normal_playlist_dir_stays_inside(self):
|
||||
self.assertFalse(_output_dir_escapes(self.base_dir, 'Playlist/%(title)s.%(ext)s'))
|
||||
|
||||
|
||||
class SanitizeEntryForPickleTests(unittest.TestCase):
|
||||
def test_nested(self):
|
||||
@@ -160,6 +352,181 @@ class SanitizeEntryForPickleTests(unittest.TestCase):
|
||||
self.assertEqual(out, {"z": 1, "a": 2})
|
||||
|
||||
|
||||
def _make_test_download() -> Download:
|
||||
info = DownloadInfo(
|
||||
id="id1",
|
||||
title="t",
|
||||
url="http://example.com/v",
|
||||
quality="best",
|
||||
download_type="video",
|
||||
codec="auto",
|
||||
format="any",
|
||||
folder="",
|
||||
custom_name_prefix="",
|
||||
error=None,
|
||||
entry=None,
|
||||
playlist_item_limit=0,
|
||||
split_by_chapters=False,
|
||||
chapter_template="",
|
||||
)
|
||||
return Download("/tmp", "/tmp", "%(title)s.%(ext)s", "%(title)s.%(ext)s", "best", "any", {}, info)
|
||||
|
||||
|
||||
class ProgressThrottleTests(unittest.TestCase):
|
||||
def test_downloading_ticks_are_throttled(self):
|
||||
dl = _make_test_download()
|
||||
forwarded = []
|
||||
dl.status_queue = types.SimpleNamespace(put=forwarded.append)
|
||||
hook = dl._make_progress_hook()
|
||||
|
||||
with patch("ytdl.time.monotonic", side_effect=[100.0, 100.1, 100.6, 100.7]):
|
||||
hook({"status": "downloading", "downloaded_bytes": 1})
|
||||
hook({"status": "downloading", "downloaded_bytes": 2})
|
||||
hook({"status": "downloading", "downloaded_bytes": 3})
|
||||
hook({"status": "downloading", "downloaded_bytes": 4})
|
||||
|
||||
# Only the 1st and 3rd ticks are >= 0.5s apart from the last forwarded one.
|
||||
self.assertEqual(len(forwarded), 2)
|
||||
|
||||
def test_finished_and_error_statuses_always_forwarded(self):
|
||||
dl = _make_test_download()
|
||||
forwarded = []
|
||||
dl.status_queue = types.SimpleNamespace(put=forwarded.append)
|
||||
hook = dl._make_progress_hook()
|
||||
|
||||
with patch("ytdl.time.monotonic", side_effect=[200.0, 200.1]):
|
||||
hook({"status": "downloading"})
|
||||
hook({"status": "finished"})
|
||||
hook({"status": "downloading"})
|
||||
hook({"status": "error", "msg": "boom"})
|
||||
|
||||
statuses = [item.get("status") for item in forwarded]
|
||||
self.assertIn("finished", statuses)
|
||||
self.assertIn("error", statuses)
|
||||
|
||||
|
||||
class CancelProcessGroupTests(unittest.TestCase):
|
||||
# cancel() now sends SIGINT first (so yt-dlp/ffmpeg can finalize the
|
||||
# partial file) and schedules a SIGKILL escalation via the event loop
|
||||
# after ytdl._CANCEL_GRACE_SECONDS, instead of SIGKILLing immediately.
|
||||
|
||||
def test_cancel_sends_sigint_to_group_and_schedules_sigkill_escalation(self):
|
||||
# Child successfully ran os.setpgrp(): its pgid equals its own pid.
|
||||
dl = _make_test_download()
|
||||
dl.proc = types.SimpleNamespace(pid=4321)
|
||||
dl.status_queue = types.SimpleNamespace(put=lambda _item: None)
|
||||
dl.loop = MagicMock()
|
||||
|
||||
with patch.object(Download, "running", return_value=True), \
|
||||
patch("ytdl.os.getpgid", return_value=4321) as mock_getpgid, \
|
||||
patch("ytdl.os.killpg") as mock_killpg:
|
||||
dl.cancel()
|
||||
|
||||
mock_getpgid.assert_called_once_with(4321)
|
||||
mock_killpg.assert_called_once_with(4321, signal.SIGINT)
|
||||
dl.loop.call_later.assert_called_once_with(ytdl._CANCEL_GRACE_SECONDS, dl._kill_if_alive)
|
||||
self.assertTrue(dl.canceled)
|
||||
|
||||
def test_cancel_does_not_killpg_parent_group_signals_child_only(self):
|
||||
# Child has NOT become its own group leader yet (pgid != pid, e.g. it is
|
||||
# still in the server's process group). killpg must NOT be called — that
|
||||
# would signal the whole server — and we fall back to os.kill(pid, SIGINT).
|
||||
dl = _make_test_download()
|
||||
dl.proc = types.SimpleNamespace(pid=4321)
|
||||
dl.status_queue = types.SimpleNamespace(put=lambda _item: None)
|
||||
dl.loop = MagicMock()
|
||||
|
||||
with patch.object(Download, "running", return_value=True), \
|
||||
patch("ytdl.os.getpgid", return_value=999), \
|
||||
patch("ytdl.os.killpg") as mock_killpg, \
|
||||
patch("ytdl.os.kill") as mock_kill:
|
||||
dl.cancel()
|
||||
|
||||
mock_killpg.assert_not_called()
|
||||
mock_kill.assert_called_once_with(4321, signal.SIGINT)
|
||||
dl.loop.call_later.assert_called_once_with(ytdl._CANCEL_GRACE_SECONDS, dl._kill_if_alive)
|
||||
self.assertTrue(dl.canceled)
|
||||
|
||||
def test_cancel_falls_back_to_pid_signal_when_getpgid_unavailable(self):
|
||||
dl = _make_test_download()
|
||||
dl.proc = types.SimpleNamespace(pid=4321)
|
||||
dl.status_queue = types.SimpleNamespace(put=lambda _item: None)
|
||||
dl.loop = MagicMock()
|
||||
|
||||
with patch.object(Download, "running", return_value=True), \
|
||||
patch("ytdl.os.getpgid", side_effect=OSError("no such process")), \
|
||||
patch("ytdl.os.kill") as mock_kill:
|
||||
dl.cancel()
|
||||
|
||||
mock_kill.assert_called_once_with(4321, signal.SIGINT)
|
||||
dl.loop.call_later.assert_called_once_with(ytdl._CANCEL_GRACE_SECONDS, dl._kill_if_alive)
|
||||
self.assertTrue(dl.canceled)
|
||||
|
||||
def test_cancel_kills_immediately_when_signal_delivery_fails(self):
|
||||
# Neither killpg nor os.kill succeed (process already gone): cancel()
|
||||
# must fall through to an immediate SIGKILL attempt instead of
|
||||
# scheduling a pointless escalation.
|
||||
dl = _make_test_download()
|
||||
dl.proc = types.SimpleNamespace(pid=4321, kill=MagicMock())
|
||||
dl.status_queue = types.SimpleNamespace(put=lambda _item: None)
|
||||
dl.loop = MagicMock()
|
||||
|
||||
with patch.object(Download, "running", return_value=True), \
|
||||
patch("ytdl.os.getpgid", side_effect=OSError("no such process")), \
|
||||
patch("ytdl.os.kill", side_effect=ProcessLookupError()):
|
||||
dl.cancel()
|
||||
|
||||
dl.loop.call_later.assert_not_called()
|
||||
dl.proc.kill.assert_called_once()
|
||||
self.assertTrue(dl.canceled)
|
||||
|
||||
def test_cancel_schedules_escalation_even_without_running_loop(self):
|
||||
# dl.loop is None (e.g. cancel() called before start()'s run_in_executor
|
||||
# set it up): _kill_if_alive() must run synchronously instead of being
|
||||
# scheduled, since there's no loop to schedule it on.
|
||||
dl = _make_test_download()
|
||||
dl.proc = types.SimpleNamespace(pid=4321)
|
||||
dl.status_queue = types.SimpleNamespace(put=lambda _item: None)
|
||||
self.assertIsNone(dl.loop)
|
||||
|
||||
with patch.object(Download, "running", side_effect=[True, True]), \
|
||||
patch("ytdl.os.getpgid", return_value=4321), \
|
||||
patch("ytdl.os.killpg") as mock_killpg:
|
||||
dl.cancel()
|
||||
|
||||
# First SIGINT, then _kill_if_alive() ran inline and sent SIGKILL.
|
||||
mock_killpg.assert_has_calls([
|
||||
unittest.mock.call(4321, signal.SIGINT),
|
||||
unittest.mock.call(4321, signal.SIGKILL),
|
||||
])
|
||||
self.assertTrue(dl.canceled)
|
||||
|
||||
|
||||
class KillIfAliveTests(unittest.TestCase):
|
||||
def test_kill_if_alive_sigkills_running_process(self):
|
||||
dl = _make_test_download()
|
||||
dl.proc = types.SimpleNamespace(pid=4321)
|
||||
|
||||
with patch.object(Download, "running", return_value=True), \
|
||||
patch("ytdl.os.getpgid", return_value=4321), \
|
||||
patch("ytdl.os.killpg") as mock_killpg:
|
||||
dl._kill_if_alive()
|
||||
|
||||
mock_killpg.assert_called_once_with(4321, signal.SIGKILL)
|
||||
|
||||
def test_kill_if_alive_noop_when_process_already_exited(self):
|
||||
dl = _make_test_download()
|
||||
dl.proc = types.SimpleNamespace(pid=4321)
|
||||
|
||||
with patch.object(Download, "running", return_value=False), \
|
||||
patch("ytdl.os.killpg") as mock_killpg, \
|
||||
patch("ytdl.os.kill") as mock_kill:
|
||||
dl._kill_if_alive()
|
||||
|
||||
mock_killpg.assert_not_called()
|
||||
mock_kill.assert_not_called()
|
||||
|
||||
|
||||
class ConvertSrtToTxtTests(unittest.TestCase):
|
||||
def test_basic_conversion(self):
|
||||
srt = """1
|
||||
@@ -180,6 +547,77 @@ Second line
|
||||
self.assertIn("Hello world", content)
|
||||
self.assertIn("Second line", content)
|
||||
|
||||
def test_vtt_input_strips_header_and_metadata(self):
|
||||
# yt-dlp can fall back to VTT even when srt/txt was requested (the
|
||||
# extractor may not offer a native srt track); the converter must not
|
||||
# leak VTT-only header/metadata lines into the plain-text output.
|
||||
vtt = """WEBVTT
|
||||
Kind: captions
|
||||
Language: en
|
||||
|
||||
NOTE
|
||||
This is a note block
|
||||
|
||||
1
|
||||
00:00:01.000 --> 00:00:02.000
|
||||
Hello <b>world</b>
|
||||
|
||||
2
|
||||
00:00:03.000 --> 00:00:04.000
|
||||
Second line
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "sub.vtt"
|
||||
path.write_text(vtt, encoding="utf-8")
|
||||
txt_path = _convert_srt_to_txt_file(str(path))
|
||||
self.assertIsNotNone(txt_path)
|
||||
content = Path(txt_path).read_text(encoding="utf-8")
|
||||
self.assertIn("Hello world", content)
|
||||
self.assertIn("Second line", content)
|
||||
self.assertNotIn("WEBVTT", content)
|
||||
self.assertNotIn("Kind:", content)
|
||||
self.assertNotIn("Language:", content)
|
||||
self.assertNotIn("This is a note block", content)
|
||||
|
||||
def test_vtt_standalone_header_block_is_stripped(self):
|
||||
# Some VTT files put a blank line after WEBVTT, so Kind:/Language: form
|
||||
# their own block. That header block (before the first timed cue) must
|
||||
# still be stripped.
|
||||
vtt = """WEBVTT
|
||||
|
||||
Kind: captions
|
||||
Language: en
|
||||
|
||||
1
|
||||
00:00:01.000 --> 00:00:02.000
|
||||
Hello world
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "sub.vtt"
|
||||
path.write_text(vtt, encoding="utf-8")
|
||||
content = Path(_convert_srt_to_txt_file(str(path))).read_text(encoding="utf-8")
|
||||
self.assertIn("Hello world", content)
|
||||
self.assertNotIn("Kind:", content)
|
||||
self.assertNotIn("Language:", content)
|
||||
|
||||
def test_cue_text_starting_with_metadata_keyword_is_kept(self):
|
||||
# A real caption line beginning with "Kind:"/"Language:" must NOT be
|
||||
# dropped as if it were VTT header metadata.
|
||||
srt = """1
|
||||
00:00:01,000 --> 00:00:02,000
|
||||
Kind: regards, everyone
|
||||
|
||||
2
|
||||
00:00:03,000 --> 00:00:04,000
|
||||
Language: they spoke French
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "sub.srt"
|
||||
path.write_text(srt, encoding="utf-8")
|
||||
content = Path(_convert_srt_to_txt_file(str(path))).read_text(encoding="utf-8")
|
||||
self.assertIn("Kind: regards, everyone", content)
|
||||
self.assertIn("Language: they spoke French", content)
|
||||
|
||||
|
||||
class DownloadInfoSetstateTests(unittest.TestCase):
|
||||
def _base_state(self, **kwargs):
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Lightweight SSRF guard for user-submitted URLs.
|
||||
|
||||
MeTube hands user-submitted URLs to yt-dlp, whose generic extractor will fetch
|
||||
any ``http(s)`` URL. Without a guard, an attacker can make the server fetch
|
||||
internal endpoints (cloud metadata services, loopback, RFC1918 hosts, etc.) and
|
||||
have the response saved to the download directory and served back.
|
||||
|
||||
This module provides a single cheap validator applied at every URL ingress. It
|
||||
intentionally does NOT attempt DNS-rebinding pinning, redirect-chain
|
||||
re-validation, or validation of every media URL yt-dlp derives from remote
|
||||
metadata — network isolation (e.g. Docker) remains the backstop for those.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
log = logging.getLogger('url_guard')
|
||||
|
||||
_ALLOWED_SCHEMES = ('http', 'https')
|
||||
|
||||
# Hostnames that must be blocked without needing a lookup. ``localhost`` and any
|
||||
# subdomain of it are conventionally loopback, and the GCP metadata name is a
|
||||
# well-known SSRF target that may resolve via a resolver we don't control.
|
||||
_BLOCKED_HOSTNAMES = ('localhost', 'metadata.google.internal')
|
||||
|
||||
|
||||
def _hostname_is_blocked(hostname: str) -> bool:
|
||||
host = hostname.rstrip('.').lower()
|
||||
for blocked in _BLOCKED_HOSTNAMES:
|
||||
if host == blocked or host.endswith('.' + blocked):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _address_is_global(addr: str) -> bool:
|
||||
try:
|
||||
ip = ipaddress.ip_address(addr)
|
||||
except ValueError:
|
||||
return False
|
||||
# Unwrap IPv4-mapped/compatible IPv6 (e.g. ::ffff:169.254.169.254) so the
|
||||
# embedded IPv4 address is judged on its own merits.
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||
ip = ip.ipv4_mapped
|
||||
return ip.is_global
|
||||
|
||||
|
||||
def validate_url(url: str) -> str | None:
|
||||
"""Return an error message if the URL is disallowed, else ``None``.
|
||||
|
||||
Inputs without a ``://`` scheme separator (bare video IDs, ``ytsearch:``
|
||||
and other yt-dlp search/extractor prefixes) are allowed unchanged so that
|
||||
non-URL entries keep working.
|
||||
"""
|
||||
if not isinstance(url, str):
|
||||
return 'Invalid URL'
|
||||
|
||||
candidate = url.strip()
|
||||
if '://' not in candidate:
|
||||
# Not an absolute URL: bare video IDs, ytsearch: prefixes, etc.
|
||||
return None
|
||||
|
||||
parts = urlsplit(candidate)
|
||||
scheme = parts.scheme.lower()
|
||||
if scheme not in _ALLOWED_SCHEMES:
|
||||
return f'URL scheme "{parts.scheme}" is not allowed (only http and https)'
|
||||
|
||||
hostname = parts.hostname
|
||||
if not hostname:
|
||||
return 'URL is missing a host'
|
||||
|
||||
if _hostname_is_blocked(hostname):
|
||||
return f'Refusing to fetch internal host "{hostname}"'
|
||||
|
||||
try:
|
||||
addrinfo = socket.getaddrinfo(hostname, parts.port, proto=socket.IPPROTO_TCP)
|
||||
except socket.gaierror:
|
||||
# Let yt-dlp surface a normal resolution error rather than masking it.
|
||||
return None
|
||||
except (UnicodeError, ValueError):
|
||||
return f'Invalid host "{hostname}"'
|
||||
|
||||
for family, _type, _proto, _canonname, sockaddr in addrinfo:
|
||||
addr = sockaddr[0]
|
||||
if not _address_is_global(addr):
|
||||
return f'Refusing to fetch internal address "{addr}" for host "{hostname}"'
|
||||
|
||||
return None
|
||||
+344
-89
@@ -9,21 +9,48 @@ from collections import OrderedDict
|
||||
import time
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial
|
||||
import logging
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import types
|
||||
from typing import Any, Optional
|
||||
|
||||
import yt_dlp.networking.impersonate
|
||||
from yt_dlp.postprocessor.common import PostProcessor
|
||||
from yt_dlp.utils import STR_FORMAT_RE_TMPL, STR_FORMAT_TYPES
|
||||
from dl_formats import get_format, get_opts, AUDIO_FORMATS
|
||||
import bg_tasks
|
||||
from dl_formats import get_format, get_opts, AUDIO_FORMATS, merge_ytdl_option_layers
|
||||
from datetime import datetime
|
||||
from state_store import AtomicJsonStore, from_json_compatible, read_legacy_shelf, to_json_compatible
|
||||
from subscriptions import _entry_id
|
||||
from url_guard import validate_url
|
||||
|
||||
log = logging.getLogger('ytdl')
|
||||
|
||||
# Python 3.14 switches the default multiprocessing start method on Linux
|
||||
# (this app's only supported deployment target, per the Dockerfile) from fork
|
||||
# to forkserver. Download._download relies on inheriting process state the
|
||||
# way fork provides, so pin it back on Linux specifically.
|
||||
#
|
||||
# This must NOT be widened to "prefer fork wherever available": on macOS the
|
||||
# platform default has long been spawn (never fork) precisely because forking
|
||||
# a multi-threaded parent is hazardous — inherited locks held by threads that
|
||||
# vanish in the child can deadlock it silently before it does any work. This
|
||||
# app creates background threads (executors, notifier callbacks) well before
|
||||
# any download starts, so forcing fork there reproduces exactly that hazard.
|
||||
_MP_CTX = (
|
||||
multiprocessing.get_context("fork")
|
||||
if sys.platform.startswith("linux") and "fork" in multiprocessing.get_all_start_methods()
|
||||
else multiprocessing.get_context()
|
||||
)
|
||||
|
||||
# Grace period between SIGINT (lets yt-dlp/ffmpeg finalize the partial file,
|
||||
# e.g. when cancelling a livestream) and SIGKILL escalation.
|
||||
_CANCEL_GRACE_SECONDS = 15
|
||||
|
||||
_LIVE_CHECK_INTERVAL = 60
|
||||
_LIVE_MAX_CHECK_INTERVAL = 3600
|
||||
# Consecutive probe failures (network blips, rate limits, transient extractor
|
||||
@@ -31,10 +58,73 @@ _LIVE_MAX_CHECK_INTERVAL = 3600
|
||||
_LIVE_PROBE_MAX_FAILURES = 5
|
||||
|
||||
|
||||
class _AlbumArtistPostProcessor(PostProcessor):
|
||||
"""Fill missing album-artist metadata from yt-dlp's album-level signals."""
|
||||
|
||||
_TOPIC_SUFFIX = ' - Topic'
|
||||
|
||||
@staticmethod
|
||||
def _has_value(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return bool(value.strip())
|
||||
if isinstance(value, (list, tuple)):
|
||||
return any(_AlbumArtistPostProcessor._has_value(item) for item in value)
|
||||
return value is not None
|
||||
|
||||
@staticmethod
|
||||
def _main_artist(info) -> Optional[str]:
|
||||
artists = info.get('artists')
|
||||
candidates = artists if isinstance(artists, list) else [info.get('artist')]
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, str) or not candidate.strip():
|
||||
continue
|
||||
# YouTube Music uses a spaced middle dot between credited artists.
|
||||
# The first credit is the primary artist for normal albums.
|
||||
return candidate.split(' · ', 1)[0].strip()
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _topic_artist(cls, info) -> Optional[str]:
|
||||
for field in ('channel', 'uploader'):
|
||||
value = info.get(field)
|
||||
if not isinstance(value, str) or not value.endswith(cls._TOPIC_SUFFIX):
|
||||
continue
|
||||
if artist := value[:-len(cls._TOPIC_SUFFIX)].strip():
|
||||
return artist
|
||||
return None
|
||||
|
||||
def run(self, info):
|
||||
if not self._has_value(info.get('album')):
|
||||
return [], info
|
||||
if self._has_value(info.get('album_artist')) or self._has_value(info.get('album_artists')):
|
||||
return [], info
|
||||
|
||||
if artist := self._topic_artist(info) or self._main_artist(info):
|
||||
info['album_artist'] = artist
|
||||
return [], info
|
||||
|
||||
|
||||
def _is_within_directory(real_base: str, real_target: str) -> bool:
|
||||
"""True if ``real_target`` is inside (or equal to) ``real_base``.
|
||||
|
||||
Both arguments must already be resolved with ``os.path.realpath``. Uses
|
||||
``commonpath`` rather than ``startswith`` so a sibling directory sharing a
|
||||
name prefix (e.g. base ``/downloads`` vs ``/downloads-secret``) cannot be
|
||||
reached via ``../downloads-secret``.
|
||||
"""
|
||||
try:
|
||||
return os.path.commonpath([real_base, real_target]) == real_base
|
||||
except ValueError:
|
||||
# Raised when paths are on different drives (Windows) or mix
|
||||
# absolute/relative; treat as outside the base directory.
|
||||
return False
|
||||
|
||||
|
||||
# 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'[\\:*?"<>|]')
|
||||
_PATH_SEP_OR_TRAVERSAL = re.compile(r'[\\/]|\.\.')
|
||||
|
||||
|
||||
def _sanitize_path_component(value: Any) -> Any:
|
||||
@@ -44,11 +134,27 @@ def _sanitize_path_component(value: Any) -> Any:
|
||||
that numeric format specs (e.g. ``%(playlist_index)02d``) still work.
|
||||
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.
|
||||
that may end up as directory names. Path separators and ``..`` segments
|
||||
are also collapsed so attacker-controlled playlist/channel titles cannot
|
||||
escape the download directory via the output template.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
return _WINDOWS_INVALID_PATH_CHARS.sub('_', value)
|
||||
value = _WINDOWS_INVALID_PATH_CHARS.sub('_', value)
|
||||
value = _PATH_SEP_OR_TRAVERSAL.sub('_', value)
|
||||
return value.lstrip('.').strip() or '_'
|
||||
|
||||
|
||||
def _output_dir_escapes(base_dir: str, output_template: str) -> bool:
|
||||
"""True when the literal directory prefix of *output_template* resolves outside *base_dir*."""
|
||||
marker = output_template.find('%(')
|
||||
literal = output_template if marker == -1 else output_template[:marker]
|
||||
dir_prefix = os.path.dirname(literal)
|
||||
if not dir_prefix:
|
||||
return False
|
||||
real_base = os.path.realpath(base_dir)
|
||||
real_target = os.path.realpath(os.path.join(base_dir, dir_prefix))
|
||||
return not _is_within_directory(real_base, real_target)
|
||||
|
||||
|
||||
# Regex matching yt-dlp output-template field references, e.g. ``%(title)s``
|
||||
@@ -133,10 +239,28 @@ def _convert_srt_to_txt_file(subtitle_path: str):
|
||||
# Normalize newlines so cue splitting is consistent across platforms.
|
||||
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
cues = []
|
||||
# The caption format may resolve to a VTT file even when srt/txt was
|
||||
# requested (yt-dlp falls back when the extractor doesn't offer the
|
||||
# requested ext). VTT header metadata (WEBVTT/NOTE/STYLE and
|
||||
# "Kind:"/"Language:" fields) only ever appears BEFORE the first timed
|
||||
# cue, so only strip it while still in that header region — otherwise a
|
||||
# real caption line like "Kind: regards" would be dropped as metadata.
|
||||
seen_cue = False
|
||||
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
|
||||
has_timing = any("-->" in line for line in lines)
|
||||
if not seen_cue and not has_timing:
|
||||
# Still in the header region: drop metadata-only blocks
|
||||
# (WEBVTT/NOTE/STYLE, or blocks made entirely of "Key: value"
|
||||
# header fields such as a standalone "Kind:/Language:" block).
|
||||
if re.match(r"^(WEBVTT|NOTE|STYLE)\b", lines[0]) or all(
|
||||
re.match(r"^[A-Za-z][\w-]*:\s", line) for line in lines
|
||||
):
|
||||
continue
|
||||
if has_timing:
|
||||
seen_cue = True
|
||||
if re.fullmatch(r"\d+", lines[0]):
|
||||
lines = lines[1:]
|
||||
if lines and "-->" in lines[0]:
|
||||
@@ -442,23 +566,56 @@ class Download:
|
||||
self.proc = None
|
||||
self.loop = None
|
||||
self.notifier = None
|
||||
self._executor = None
|
||||
|
||||
# Minimum interval between forwarded 'downloading' progress ticks. yt-dlp
|
||||
# emits these many times per second; without throttling, each active
|
||||
# download broadcasts hundreds of socket.io events/sec to every client.
|
||||
_PROGRESS_THROTTLE_SECONDS = 0.5
|
||||
|
||||
def _make_progress_hook(self):
|
||||
last_forward = 0.0
|
||||
|
||||
def put_status(st):
|
||||
nonlocal last_forward
|
||||
if st.get('status') == 'downloading':
|
||||
now = time.monotonic()
|
||||
if now - last_forward < self._PROGRESS_THROTTLE_SECONDS:
|
||||
return
|
||||
last_forward = now
|
||||
self.status_queue.put({k: v for k, v in st.items() if k in (
|
||||
'tmpfilename',
|
||||
'filename',
|
||||
'status',
|
||||
'msg',
|
||||
'total_bytes',
|
||||
'total_bytes_estimate',
|
||||
'downloaded_bytes',
|
||||
'speed',
|
||||
'eta',
|
||||
)})
|
||||
|
||||
return put_status
|
||||
|
||||
def _make_youtube_dl(self, params):
|
||||
ydl = yt_dlp.YoutubeDL(params=params)
|
||||
if getattr(self.info, 'download_type', '') == 'audio':
|
||||
ydl.add_post_processor(_AlbumArtistPostProcessor(ydl), when='pre_process')
|
||||
return ydl
|
||||
|
||||
def _download(self):
|
||||
# Run in our own process group so cancel() can SIGKILL the whole
|
||||
# group (yt-dlp + any ffmpeg children it spawned for merge/postproc),
|
||||
# instead of orphaning ffmpeg when only the yt-dlp process is killed.
|
||||
if hasattr(os, 'setpgrp'):
|
||||
try:
|
||||
os.setpgrp()
|
||||
except OSError:
|
||||
pass
|
||||
log.info(f"Starting download for: {self.info.title} ({self.info.url})")
|
||||
try:
|
||||
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
|
||||
def put_status(st):
|
||||
self.status_queue.put({k: v for k, v in st.items() if k in (
|
||||
'tmpfilename',
|
||||
'filename',
|
||||
'status',
|
||||
'msg',
|
||||
'total_bytes',
|
||||
'total_bytes_estimate',
|
||||
'downloaded_bytes',
|
||||
'speed',
|
||||
'eta',
|
||||
)})
|
||||
put_status = self._make_progress_hook()
|
||||
|
||||
def put_status_postprocessor(d):
|
||||
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
|
||||
@@ -523,26 +680,27 @@ class Download:
|
||||
[(start, end)],
|
||||
)
|
||||
|
||||
ret = yt_dlp.YoutubeDL(params=ytdl_params).download([self.info.url])
|
||||
ret = self._make_youtube_dl(ytdl_params).download([self.info.url])
|
||||
self.status_queue.put({'status': 'finished' if ret == 0 else 'error'})
|
||||
log.info(f"Finished download for: {self.info.title}")
|
||||
except yt_dlp.utils.YoutubeDLError as exc:
|
||||
log.error(f"Download error for {self.info.title}: {str(exc)}")
|
||||
self.status_queue.put({'status': 'error', 'msg': str(exc)})
|
||||
|
||||
async def start(self, notifier):
|
||||
async def start(self, notifier, executor=None):
|
||||
log.info(f"Preparing download for: {self.info.title}")
|
||||
if Download.manager is None:
|
||||
Download.manager = multiprocessing.Manager()
|
||||
Download.manager = _MP_CTX.Manager()
|
||||
self.status_queue = Download.manager.Queue()
|
||||
self.proc = multiprocessing.Process(target=self._download)
|
||||
self.proc = _MP_CTX.Process(target=self._download)
|
||||
self.proc.start()
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.notifier = notifier
|
||||
self._executor = executor
|
||||
self.info.status = 'preparing'
|
||||
await self.notifier.updated(self.info)
|
||||
self.status_task = asyncio.create_task(self.update_status())
|
||||
await self.loop.run_in_executor(None, self.proc.join)
|
||||
await self.loop.run_in_executor(self._executor, 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.
|
||||
@@ -550,13 +708,50 @@ class Download:
|
||||
self.status_queue.put(None)
|
||||
await self.status_task
|
||||
|
||||
def _signal_group(self, sig):
|
||||
"""Send *sig* to the download's process group, falling back to the
|
||||
process itself. Returns True if a signal was delivered.
|
||||
|
||||
Only signal the whole group when the child actually became its own
|
||||
group leader via os.setpgrp() in _download() — that sets its pgid
|
||||
equal to its own pid. If it hasn't run setpgrp() yet, or setpgrp()
|
||||
failed, its pgid is still the SERVER's group and killpg would signal
|
||||
the entire MeTube process (PID 1 in Docker). Fall back to signalling
|
||||
just the child process by pid in that case.
|
||||
"""
|
||||
try:
|
||||
pgid = os.getpgid(self.proc.pid)
|
||||
if pgid == self.proc.pid:
|
||||
os.killpg(pgid, sig)
|
||||
return True
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
try:
|
||||
if sig == signal.SIGINT:
|
||||
os.kill(self.proc.pid, sig)
|
||||
else:
|
||||
self.proc.kill()
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"Error signalling process for {self.info.title}: {e}")
|
||||
return False
|
||||
|
||||
def _kill_if_alive(self):
|
||||
if self.running():
|
||||
log.info(f"Escalating cancel to SIGKILL for: {self.info.title}")
|
||||
self._signal_group(signal.SIGKILL)
|
||||
|
||||
def cancel(self):
|
||||
log.info(f"Cancelling download: {self.info.title}")
|
||||
if self.running():
|
||||
try:
|
||||
self.proc.kill()
|
||||
except Exception as e:
|
||||
log.error(f"Error killing process for {self.info.title}: {e}")
|
||||
# SIGINT first so yt-dlp/ffmpeg can finalize the partial file
|
||||
# (livestream recordings stay playable); SIGKILL after a grace
|
||||
# period if the process ignores it.
|
||||
interrupted = self._signal_group(signal.SIGINT)
|
||||
if interrupted and self.loop is not None:
|
||||
self.loop.call_later(_CANCEL_GRACE_SECONDS, self._kill_if_alive)
|
||||
else:
|
||||
self._kill_if_alive()
|
||||
self.canceled = True
|
||||
if self.status_queue is not None:
|
||||
self.status_queue.put(None)
|
||||
@@ -577,7 +772,13 @@ class Download:
|
||||
|
||||
async def update_status(self):
|
||||
while True:
|
||||
status = await self.loop.run_in_executor(None, self.status_queue.get)
|
||||
try:
|
||||
status = await self.loop.run_in_executor(self._executor, self.status_queue.get)
|
||||
except RuntimeError:
|
||||
# The download executor was shut down (server shutting down);
|
||||
# stop polling instead of raising a noisy background-task error.
|
||||
log.info(f"Status polling stopped (executor shut down) for: {self.info.title}")
|
||||
return
|
||||
if status is None:
|
||||
log.info(f"Status update finished for: {self.info.title}")
|
||||
return
|
||||
@@ -771,10 +972,6 @@ class PersistentQueue:
|
||||
self.dict[key] = old
|
||||
raise
|
||||
|
||||
def next(self):
|
||||
k, v = next(iter(self.dict.items()))
|
||||
return k, v
|
||||
|
||||
def empty(self):
|
||||
return not bool(self.dict)
|
||||
|
||||
@@ -787,6 +984,13 @@ class DownloadQueue:
|
||||
self.pending = PersistentQueue("pending", self.config.STATE_DIR + '/pending')
|
||||
self.active_downloads = set()
|
||||
self.semaphore = asyncio.Semaphore(int(self.config.MAX_CONCURRENT_DOWNLOADS))
|
||||
# Each active download parks two threads for its whole duration
|
||||
# (proc.join + status_queue.get). A dedicated pool keeps those from
|
||||
# starving the default executor, which extract_info/live-probes also use.
|
||||
self._download_executor = ThreadPoolExecutor(
|
||||
max_workers=2 * int(self.config.MAX_CONCURRENT_DOWNLOADS) + 2,
|
||||
thread_name_prefix="dl",
|
||||
)
|
||||
self.done.load()
|
||||
self._add_generation = 0
|
||||
self._canceled_urls = set() # URLs canceled during current playlist add
|
||||
@@ -799,6 +1003,16 @@ class DownloadQueue:
|
||||
self._add_generation += 1
|
||||
log.info('Playlist add operation canceled by user')
|
||||
|
||||
@staticmethod
|
||||
def __is_channel_extraction(entry):
|
||||
"""Return True when yt-dlp reported a channel tab as a playlist.
|
||||
|
||||
YouTube channel tabs are extracted with ``_type: 'playlist'`` but set
|
||||
``id`` equal to ``channel_id``; real playlists keep a distinct id.
|
||||
"""
|
||||
channel_id = entry.get('channel_id')
|
||||
return bool(channel_id) and entry.get('id') == channel_id
|
||||
|
||||
async def __import_queue(self):
|
||||
for k, v in self.queue.saved_items():
|
||||
await self.__add_download(v, True)
|
||||
@@ -810,18 +1024,14 @@ class DownloadQueue:
|
||||
async def initialize(self):
|
||||
log.info("Initializing DownloadQueue")
|
||||
self._start_live_monitor()
|
||||
asyncio.create_task(self.__import_queue())
|
||||
asyncio.create_task(self.__import_pending())
|
||||
bg_tasks.create_task(self.__import_queue(), name="import_queue")
|
||||
bg_tasks.create_task(self.__import_pending(), name="import_pending")
|
||||
|
||||
def _start_live_monitor(self) -> None:
|
||||
if self._live_monitor_task is not None and not self._live_monitor_task.done():
|
||||
return
|
||||
self._live_monitor_task = asyncio.create_task(self._live_monitor_loop())
|
||||
self._live_monitor_task.add_done_callback(
|
||||
lambda t: log.error("Live monitor loop failed: %s", t.exception())
|
||||
if not t.cancelled() and t.exception()
|
||||
else None
|
||||
)
|
||||
# bg_tasks.create_task already logs unexpected task failures with the name.
|
||||
self._live_monitor_task = bg_tasks.create_task(self._live_monitor_loop(), name="live_monitor")
|
||||
|
||||
def _register_scheduled(self, download: Download) -> None:
|
||||
self._scheduled_probe_at[download.info.url] = 0
|
||||
@@ -954,7 +1164,7 @@ class DownloadQueue:
|
||||
info.error = None
|
||||
info.msg = None
|
||||
await self.notifier.updated(info)
|
||||
asyncio.create_task(self.__start_download(download))
|
||||
bg_tasks.create_task(self.__start_download(download), name="start_download")
|
||||
|
||||
def _schedule_upcoming_download(self, download: Download) -> None:
|
||||
download.info.status = 'scheduled'
|
||||
@@ -966,7 +1176,7 @@ class DownloadQueue:
|
||||
download.info.status = 'pending'
|
||||
download.info.error = None
|
||||
download.info.msg = None
|
||||
asyncio.create_task(self.__start_download(download))
|
||||
bg_tasks.create_task(self.__start_download(download), name="start_download")
|
||||
|
||||
async def __start_download(self, download):
|
||||
if download.canceled:
|
||||
@@ -976,7 +1186,7 @@ class DownloadQueue:
|
||||
if download.canceled:
|
||||
log.info(f"Download {download.info.title} was canceled, skipping start.")
|
||||
return
|
||||
await download.start(self.notifier)
|
||||
await download.start(self.notifier, self._download_executor)
|
||||
self._post_download_cleanup(download)
|
||||
|
||||
def _post_download_cleanup(self, download):
|
||||
@@ -987,22 +1197,35 @@ class DownloadQueue:
|
||||
except OSError:
|
||||
pass
|
||||
download.info.status = 'error'
|
||||
# A progress tick may have set filename to a temp-directory
|
||||
# relative path before the error occurred; clear it so the UI
|
||||
# doesn't render a broken link (or, worse, so a later trashcan
|
||||
# delete doesn't act on a path outside the download directory).
|
||||
# Captions downloads may still have captured valid subtitle
|
||||
# files even when the overall status is 'error' — keep those.
|
||||
has_captured_subtitles = bool(getattr(download.info, 'subtitle_files', None))
|
||||
if not (download.info.download_type == 'captions' and has_captured_subtitles):
|
||||
download.info.filename = None
|
||||
download.info.size = None
|
||||
download.close()
|
||||
if self.queue.exists(download.info.url):
|
||||
self.queue.delete(download.info.url)
|
||||
if download.canceled:
|
||||
asyncio.create_task(self.notifier.canceled(download.info.url))
|
||||
bg_tasks.create_task(self.notifier.canceled(download.info.url), name="notify_canceled")
|
||||
else:
|
||||
self.done.put(download)
|
||||
asyncio.create_task(self.notifier.completed(download.info))
|
||||
bg_tasks.create_task(self.notifier.completed(download.info), name="notify_completed")
|
||||
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)
|
||||
# bg_tasks.create_task already logs unexpected task failures.
|
||||
bg_tasks.create_task(
|
||||
self.__auto_clear_after_delay(download.info.url, clear_after),
|
||||
name="auto_clear",
|
||||
)
|
||||
|
||||
async def __auto_clear_after_delay(self, url, delay_seconds):
|
||||
await asyncio.sleep(delay_seconds)
|
||||
@@ -1013,9 +1236,9 @@ class DownloadQueue:
|
||||
def _build_ytdl_options(self, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
"""Merge global options, presets (in order), and per-download overrides."""
|
||||
opts = dict(self.config.YTDL_OPTIONS)
|
||||
for preset_name in ytdl_options_presets or []:
|
||||
opts.update(self.config.YTDL_OPTIONS_PRESETS.get(preset_name, {}))
|
||||
opts.update(ytdl_options_overrides or {})
|
||||
opts.update(merge_ytdl_option_layers(
|
||||
ytdl_options_presets, ytdl_options_overrides, self.config.YTDL_OPTIONS_PRESETS
|
||||
))
|
||||
return opts
|
||||
|
||||
def __extract_info(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
@@ -1043,16 +1266,7 @@ class DownloadQueue:
|
||||
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))
|
||||
real_base_directory = os.path.realpath(base_directory)
|
||||
# Use commonpath rather than startswith so that a sibling directory
|
||||
# sharing a name prefix (e.g. base "/downloads" vs "/downloads-secret")
|
||||
# cannot be reached via "../downloads-secret".
|
||||
try:
|
||||
inside_base = os.path.commonpath([real_base_directory, dldirectory]) == real_base_directory
|
||||
except ValueError:
|
||||
# Raised when paths are on different drives (Windows) or mix
|
||||
# absolute/relative; treat as outside the base directory.
|
||||
inside_base = False
|
||||
if not inside_base:
|
||||
if not _is_within_directory(real_base_directory, dldirectory):
|
||||
return None, {'status': 'error', 'msg': f'Folder "{folder}" must resolve inside the base download directory "{real_base_directory}"'}
|
||||
if not os.path.isdir(dldirectory):
|
||||
if not self.config.CREATE_CUSTOM_DIRS:
|
||||
@@ -1087,6 +1301,8 @@ class DownloadQueue:
|
||||
if playlist_item_limit > 0:
|
||||
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
|
||||
ytdl_options['playlistend'] = playlist_item_limit
|
||||
if _output_dir_escapes(dldirectory, output):
|
||||
return {'status': 'error', 'msg': 'Refusing download: resolved output path escapes the download directory'}
|
||||
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, dl.quality, dl.format, ytdl_options, dl)
|
||||
is_upcoming = (
|
||||
getattr(dl, 'live_status', None) == 'is_upcoming'
|
||||
@@ -1097,7 +1313,7 @@ class DownloadQueue:
|
||||
self._schedule_upcoming_download(download)
|
||||
else:
|
||||
self.queue.put(download)
|
||||
asyncio.create_task(self.__start_download(download))
|
||||
bg_tasks.create_task(self.__start_download(download), name="start_download")
|
||||
else:
|
||||
self.pending.put(download)
|
||||
await self.notifier.added(dl)
|
||||
@@ -1129,7 +1345,9 @@ class DownloadQueue:
|
||||
|
||||
error = None
|
||||
if "live_status" in entry and "release_timestamp" in entry and entry.get("live_status") == "is_upcoming":
|
||||
dt_ts = datetime.fromtimestamp(entry.get("release_timestamp")).strftime('%Y-%m-%d %H:%M:%S %z')
|
||||
# astimezone() makes this an aware datetime in the server's local
|
||||
# zone; a naive datetime's %z renders as an empty string.
|
||||
dt_ts = datetime.fromtimestamp(entry.get("release_timestamp")).astimezone().strftime('%Y-%m-%d %H:%M:%S %z')
|
||||
error = f"Live stream is scheduled to start at {dt_ts}"
|
||||
else:
|
||||
if "msg" in entry:
|
||||
@@ -1161,6 +1379,8 @@ class DownloadQueue:
|
||||
_add_gen,
|
||||
)
|
||||
elif etype == 'playlist' or etype == 'channel':
|
||||
if etype == 'playlist' and self.__is_channel_extraction(entry):
|
||||
etype = 'channel'
|
||||
log.debug(f'Processing as a {etype}')
|
||||
entries = entry['entries']
|
||||
# Convert generator to list if needed (for len() and slicing operations)
|
||||
@@ -1180,7 +1400,15 @@ class DownloadQueue:
|
||||
if "id" not in etr:
|
||||
etr["id"] = _entry_id(etr)
|
||||
etr["_type"] = "video"
|
||||
etr[etype] = entry.get("id") or entry.get("channel_id") or entry.get("channel")
|
||||
if etype == 'channel':
|
||||
etr["channel"] = (
|
||||
entry.get("channel")
|
||||
or entry.get("uploader")
|
||||
or entry.get("title")
|
||||
or entry.get("id")
|
||||
)
|
||||
else:
|
||||
etr["playlist"] = entry.get("id") or entry.get("channel_id") or entry.get("channel")
|
||||
etr[f"{etype}_index"] = '{{0:0{0:d}d}}'.format(index_digits).format(index)
|
||||
etr[f"{etype}_count"] = total_entries
|
||||
etr[f"{etype}_autonumber"] = index
|
||||
@@ -1217,38 +1445,43 @@ class DownloadQueue:
|
||||
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': 'ok'}
|
||||
elif etype == 'video' or (etype.startswith('url') and 'id' in entry and 'title' in entry):
|
||||
elif etype == 'video':
|
||||
log.debug('Processing as a video')
|
||||
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):
|
||||
dl = DownloadInfo(
|
||||
id=entry['id'],
|
||||
title=entry.get('title') or entry['id'],
|
||||
url=key,
|
||||
quality=quality,
|
||||
download_type=download_type,
|
||||
codec=codec,
|
||||
format=format,
|
||||
folder=folder,
|
||||
custom_name_prefix=custom_name_prefix,
|
||||
error=error,
|
||||
entry=entry,
|
||||
playlist_item_limit=playlist_item_limit,
|
||||
split_by_chapters=split_by_chapters,
|
||||
chapter_template=chapter_template,
|
||||
subtitle_language=subtitle_language,
|
||||
subtitle_mode=subtitle_mode,
|
||||
ytdl_options_presets=ytdl_options_presets,
|
||||
ytdl_options_overrides=ytdl_options_overrides,
|
||||
clip_start=clip_start,
|
||||
clip_end=clip_end,
|
||||
live_status=entry.get('live_status'),
|
||||
live_release_timestamp=entry.get('release_timestamp'),
|
||||
)
|
||||
await self.__add_download(dl, auto_start)
|
||||
if self.queue.exists(key) or self.pending.exists(key):
|
||||
# Surface the skip instead of silently no-op'ing, and avoid
|
||||
# clobbering an existing pending entry's options with a
|
||||
# fresh DownloadInfo built from possibly-different args.
|
||||
title = entry.get('title') or key
|
||||
return {'status': 'ok', 'msg': f'Already in queue: {title}'}
|
||||
dl = DownloadInfo(
|
||||
id=entry['id'],
|
||||
title=entry.get('title') or entry['id'],
|
||||
url=key,
|
||||
quality=quality,
|
||||
download_type=download_type,
|
||||
codec=codec,
|
||||
format=format,
|
||||
folder=folder,
|
||||
custom_name_prefix=custom_name_prefix,
|
||||
error=error,
|
||||
entry=entry,
|
||||
playlist_item_limit=playlist_item_limit,
|
||||
split_by_chapters=split_by_chapters,
|
||||
chapter_template=chapter_template,
|
||||
subtitle_language=subtitle_language,
|
||||
subtitle_mode=subtitle_mode,
|
||||
ytdl_options_presets=ytdl_options_presets,
|
||||
ytdl_options_overrides=ytdl_options_overrides,
|
||||
clip_start=clip_start,
|
||||
clip_end=clip_end,
|
||||
live_status=entry.get('live_status'),
|
||||
live_release_timestamp=entry.get('release_timestamp'),
|
||||
)
|
||||
await self.__add_download(dl, auto_start)
|
||||
return {'status': 'ok'}
|
||||
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
||||
|
||||
@@ -1290,6 +1523,13 @@ class DownloadQueue:
|
||||
return {'status': 'ok'}
|
||||
else:
|
||||
already.add(url)
|
||||
# SSRF guard: reject non-http(s) schemes and hosts resolving to
|
||||
# internal/loopback/link-local/metadata addresses before yt-dlp fetches
|
||||
# anything. run_in_executor because validate_url may perform a DNS lookup.
|
||||
url_error = await asyncio.get_running_loop().run_in_executor(None, validate_url, url)
|
||||
if url_error is not None:
|
||||
log.warning('Rejected URL "%s": %s', url, url_error)
|
||||
return {'status': 'error', 'msg': url_error}
|
||||
try:
|
||||
entry = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
@@ -1374,7 +1614,7 @@ class DownloadQueue:
|
||||
self._schedule_upcoming_download(dl)
|
||||
else:
|
||||
self.queue.put(dl)
|
||||
asyncio.create_task(self.__start_download(dl))
|
||||
bg_tasks.create_task(self.__start_download(dl), name="start_download")
|
||||
continue
|
||||
if self.queue.exists(id):
|
||||
dl = self.queue.get(id)
|
||||
@@ -1428,9 +1668,14 @@ class DownloadQueue:
|
||||
for extra in (getattr(dl.info, 'subtitle_files', None) or []):
|
||||
if isinstance(extra, dict) and extra.get('filename'):
|
||||
rel_names.append(extra['filename'])
|
||||
real_base_directory = os.path.realpath(dldirectory)
|
||||
for rel_name in rel_names:
|
||||
full_path = os.path.realpath(os.path.join(dldirectory, rel_name))
|
||||
if not _is_within_directory(real_base_directory, full_path):
|
||||
log.warning(f'skipping deletion of "{rel_name}" for download {id}: resolves outside the download directory')
|
||||
continue
|
||||
try:
|
||||
os.remove(os.path.join(dldirectory, rel_name))
|
||||
os.remove(full_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
@@ -1443,3 +1688,13 @@ class DownloadQueue:
|
||||
return (list((k, v.info) for k, v in self.queue.items()) +
|
||||
list((k, v.info) for k, v in self.pending.items()),
|
||||
list((k, v.info) for k, v in self.done.items()))
|
||||
|
||||
def close(self):
|
||||
# Kill any still-running download subprocesses (and their ffmpeg
|
||||
# children) before tearing down the executor, so they aren't orphaned
|
||||
# when the server exits. Their queue entries stay persisted and are
|
||||
# re-imported/restarted on next startup.
|
||||
for _key, download in list(self.queue.items()):
|
||||
if download.started() and download.running():
|
||||
download.cancel()
|
||||
self._download_executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
+19
-19
@@ -24,19 +24,19 @@
|
||||
"packageManager": "pnpm@11.5.2",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^22.0.4",
|
||||
"@angular/common": "^22.0.4",
|
||||
"@angular/compiler": "^22.0.4",
|
||||
"@angular/core": "^22.0.4",
|
||||
"@angular/forms": "^22.0.4",
|
||||
"@angular/platform-browser": "^22.0.4",
|
||||
"@angular/platform-browser-dynamic": "^22.0.4",
|
||||
"@angular/service-worker": "^22.0.4",
|
||||
"@angular/animations": "^22.0.6",
|
||||
"@angular/common": "^22.0.6",
|
||||
"@angular/compiler": "^22.0.6",
|
||||
"@angular/core": "^22.0.6",
|
||||
"@angular/forms": "^22.0.6",
|
||||
"@angular/platform-browser": "^22.0.6",
|
||||
"@angular/platform-browser-dynamic": "^22.0.6",
|
||||
"@angular/service-worker": "^22.0.6",
|
||||
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.3.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.3.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.3.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^7.3.1",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.3.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.3.1",
|
||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||
"@ng-select/ng-select": "^23.2.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
@@ -49,16 +49,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-eslint/builder": "22.0.0",
|
||||
"@angular/build": "^22.0.4",
|
||||
"@angular/cli": "^22.0.4",
|
||||
"@angular/compiler-cli": "^22.0.4",
|
||||
"@angular/localize": "^22.0.4",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@angular/build": "^22.0.7",
|
||||
"@angular/cli": "^22.0.7",
|
||||
"@angular/compiler-cli": "^22.0.6",
|
||||
"@angular/localize": "^22.0.6",
|
||||
"@eslint/js": "^9.39.5",
|
||||
"angular-eslint": "22.0.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint": "^9.39.5",
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "8.62.0",
|
||||
"vitest": "^4.1.9"
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+639
-812
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -699,7 +699,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) {
|
||||
@for (download of downloads.queue | keyvalue: asIsOrder; track download.key) {
|
||||
<tr [class.disabled]='download.value.deleting'>
|
||||
<td>
|
||||
<app-item-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
|
||||
@@ -769,7 +769,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of cachedSortedDone; track entry[1].id) {
|
||||
@for (entry of cachedSortedDone; track entry[0]) {
|
||||
<tr [class.disabled]='entry[1].deleting'>
|
||||
<td>
|
||||
<app-item-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
|
||||
|
||||
@@ -139,6 +139,12 @@ describe('App', () => {
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it('asIsOrder returns a stable comparator value (insertion order preserved)', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.asIsOrder()).toBe(0);
|
||||
});
|
||||
|
||||
it('hides manual override input when disabled', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.componentInstance.isAdvancedOpen = true;
|
||||
|
||||
+36
-16
@@ -351,13 +351,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
|
||||
}
|
||||
|
||||
// workaround to allow fetching of Map values in the order they were inserted
|
||||
// https://github.com/angular/angular/issues/31420
|
||||
|
||||
|
||||
|
||||
// keyvalue comparator that preserves insertion order (Angular's keyvalue
|
||||
// pipe sorts by key by default): https://github.com/angular/angular/issues/31420
|
||||
asIsOrder() {
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
qualityChanged() {
|
||||
@@ -430,8 +427,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
this.downloads.configurationChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (config: any) => {
|
||||
const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
|
||||
if (playlistItemLimit !== '0') {
|
||||
const playlistItemLimit = parseInt(String(config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'] ?? '0'), 10);
|
||||
if (!Number.isNaN(playlistItemLimit) && playlistItemLimit > 0) {
|
||||
this.playlistItemLimit = playlistItemLimit;
|
||||
}
|
||||
// Set chapter template from backend config if not already set by cookie
|
||||
@@ -526,6 +523,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
return status?.status === 'error' ? status.msg || null : null;
|
||||
}
|
||||
|
||||
private handleActionResult(res: unknown, fallbackMsg: string) {
|
||||
const error = this.getStatusError(res);
|
||||
if (error) {
|
||||
this.toasts.error(error || fallbackMsg);
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
private refreshSubscriptionsWithAlert() {
|
||||
this.subscriptionsSvc.refreshList().pipe(takeUntilDestroyed(this.destroyRef)).subscribe((refreshRes) => {
|
||||
const error = this.getStatusError(refreshRes);
|
||||
@@ -1085,6 +1090,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
if (status.status === 'error' && !this.cancelRequested) {
|
||||
this.toasts.error(`Error adding URL: ${status.msg}`);
|
||||
} else if (status.status !== 'error') {
|
||||
// e.g. "Already in queue: ..." when the backend skipped a duplicate.
|
||||
if (status.msg) {
|
||||
this.toasts.info(status.msg);
|
||||
}
|
||||
this.addUrl = '';
|
||||
}
|
||||
this.resetAddState();
|
||||
@@ -1113,7 +1122,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
downloadItemByKey(id: string) {
|
||||
this.downloads.startById([id]).subscribe();
|
||||
this.downloads.startById([id]).subscribe((res) => this.handleActionResult(res, 'Start download failed'));
|
||||
}
|
||||
|
||||
liveCountdownSeconds(download: Download): number | null {
|
||||
@@ -1137,7 +1146,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
retryDownload(key: string, download: Download) {
|
||||
this.addDownload({
|
||||
const payload = this.buildAddPayload({
|
||||
url: download.url,
|
||||
downloadType: download.download_type,
|
||||
codec: download.codec,
|
||||
@@ -1158,27 +1167,38 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
||||
clipStart: download.clip_start != null ? String(download.clip_start) : '',
|
||||
clipEnd: download.clip_end != null ? String(download.clip_end) : '',
|
||||
});
|
||||
this.downloads.delById('done', [key]).subscribe();
|
||||
// Only remove the done-list record once the retry is confirmed queued —
|
||||
// deleting it eagerly would silently lose history if the re-add fails.
|
||||
this.downloads.add(payload)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((status: Status) => {
|
||||
if (status.status === 'error') {
|
||||
this.toasts.error(`Error retrying ${download.title}: ${status.msg}`);
|
||||
this.cdr.markForCheck();
|
||||
return;
|
||||
}
|
||||
this.downloads.delById('done', [key]).subscribe();
|
||||
});
|
||||
}
|
||||
|
||||
delDownload(where: State, id: string) {
|
||||
this.downloads.delById(where, [id]).subscribe();
|
||||
this.downloads.delById(where, [id]).subscribe((res) => this.handleActionResult(res, 'Delete failed'));
|
||||
}
|
||||
|
||||
startSelectedDownloads(where: State){
|
||||
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe();
|
||||
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe((res) => this.handleActionResult(res, 'Start download failed'));
|
||||
}
|
||||
|
||||
delSelectedDownloads(where: State) {
|
||||
this.downloads.delByFilter(where, dl => !!dl.checked).subscribe();
|
||||
this.downloads.delByFilter(where, dl => !!dl.checked).subscribe((res) => this.handleActionResult(res, 'Delete failed'));
|
||||
}
|
||||
|
||||
clearCompletedDownloads() {
|
||||
this.downloads.delByFilter('done', dl => dl.status === 'finished').subscribe();
|
||||
this.downloads.delByFilter('done', dl => dl.status === 'finished').subscribe((res) => this.handleActionResult(res, 'Clear completed failed'));
|
||||
}
|
||||
|
||||
clearFailedDownloads() {
|
||||
this.downloads.delByFilter('done', dl => dl.status === 'error').subscribe();
|
||||
this.downloads.delByFilter('done', dl => dl.status === 'error').subscribe((res) => this.handleActionResult(res, 'Clear failed downloads failed'));
|
||||
}
|
||||
|
||||
retryFailedDownloads() {
|
||||
|
||||
@@ -10,15 +10,16 @@ describe('FileSizePipe', () => {
|
||||
it('formats bytes and larger units', () => {
|
||||
const pipe = new FileSizePipe();
|
||||
expect(pipe.transform(500)).toContain('Bytes');
|
||||
expect(pipe.transform(1000)).toContain('KB');
|
||||
expect(pipe.transform(1000 * 1000)).toContain('MB');
|
||||
expect(pipe.transform(1000 ** 3)).toContain('GB');
|
||||
expect(pipe.transform(1000)).toContain('Bytes');
|
||||
expect(pipe.transform(1024)).toContain('KB');
|
||||
expect(pipe.transform(1024 ** 2)).toContain('MB');
|
||||
expect(pipe.transform(1024 ** 3)).toContain('GB');
|
||||
});
|
||||
|
||||
it('handles boundaries between units', () => {
|
||||
const pipe = new FileSizePipe();
|
||||
expect(pipe.transform(999)).toContain('Bytes');
|
||||
expect(pipe.transform(1000)).toContain('KB');
|
||||
expect(pipe.transform(1001)).toContain('KB');
|
||||
expect(pipe.transform(1023)).toContain('Bytes');
|
||||
expect(pipe.transform(1024)).toContain('KB');
|
||||
expect(pipe.transform(1025)).toContain('KB');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,10 @@ export class FileSizePipe implements PipeTransform {
|
||||
if (isNaN(value) || value === 0) return '0 Bytes';
|
||||
|
||||
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
const unitIndex = Math.floor(Math.log(value) / Math.log(1000)); // Use 1000 for common units
|
||||
const k = 1024; // Matches SpeedPipe's base so file sizes and transfer speeds agree.
|
||||
const unitIndex = Math.floor(Math.log(value) / Math.log(k));
|
||||
|
||||
const unitValue = value / Math.pow(1000, unitIndex);
|
||||
const unitValue = value / Math.pow(k, unitIndex);
|
||||
return `${unitValue.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,61 @@ describe('DownloadsService', () => {
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it('delById resets deleting flag and emits error status on HTTP failure', () => {
|
||||
const dl: Download = {
|
||||
id: '1',
|
||||
title: 't',
|
||||
url: 'u1',
|
||||
download_type: 'video',
|
||||
quality: 'best',
|
||||
format: 'any',
|
||||
folder: '',
|
||||
custom_name_prefix: '',
|
||||
playlist_item_limit: 0,
|
||||
status: 'finished',
|
||||
msg: '',
|
||||
percent: 0,
|
||||
speed: 0,
|
||||
eta: 0,
|
||||
filename: '',
|
||||
checked: false,
|
||||
deleting: false,
|
||||
};
|
||||
service.queue.set('u1', dl);
|
||||
let queueChangedCount = 0;
|
||||
service.queueChanged.subscribe(() => queueChangedCount++);
|
||||
let result: unknown;
|
||||
let threw = false;
|
||||
|
||||
service.delById('queue', ['u1']).subscribe({
|
||||
next: (res) => { result = res; },
|
||||
error: () => { threw = true; },
|
||||
});
|
||||
expect(dl.deleting).toBe(true);
|
||||
const req = httpMock.expectOne('delete');
|
||||
req.flush({ msg: 'boom' }, { status: 500, statusText: 'Server Error' });
|
||||
|
||||
expect(threw).toBe(false);
|
||||
expect(dl.deleting).toBe(false);
|
||||
expect(queueChangedCount).toBeGreaterThan(0);
|
||||
expect((result as { status: string }).status).toBe('error');
|
||||
});
|
||||
|
||||
it('startById surfaces HTTP errors as a status object instead of throwing', () => {
|
||||
let result: unknown;
|
||||
let threw = false;
|
||||
|
||||
service.startById(['a']).subscribe({
|
||||
next: (res) => { result = res; },
|
||||
error: () => { threw = true; },
|
||||
});
|
||||
const req = httpMock.expectOne('start');
|
||||
req.flush({ msg: 'nope' }, { status: 500, statusText: 'Server Error' });
|
||||
|
||||
expect(threw).toBe(false);
|
||||
expect((result as { status: string }).status).toBe('error');
|
||||
});
|
||||
|
||||
it('handleHTTPError extracts msg from object body', async () => {
|
||||
const err = new HttpErrorResponse({
|
||||
error: { msg: 'bad' },
|
||||
@@ -226,6 +281,15 @@ describe('DownloadsService', () => {
|
||||
expect(updated?.deleting).toBe(true);
|
||||
});
|
||||
|
||||
it('socket updated ignores events for urls not already in the queue', () => {
|
||||
expect(service.queue.has('unknown-url')).toBe(false);
|
||||
socket.emit(
|
||||
'updated',
|
||||
JSON.stringify({ url: 'unknown-url', title: 't', status: 'downloading' }),
|
||||
);
|
||||
expect(service.queue.has('unknown-url')).toBe(false);
|
||||
});
|
||||
|
||||
it('socket completed moves entry to done', () => {
|
||||
service.queue.set('u1', {
|
||||
id: '1',
|
||||
|
||||
@@ -69,8 +69,14 @@ export class DownloadsService {
|
||||
.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;
|
||||
// An 'added' event always precedes legitimate updates. If the row is
|
||||
// gone (canceled/completed already processed), this update is stale —
|
||||
// applying it would resurrect a ghost row until the next full refresh.
|
||||
if (!dl) {
|
||||
return;
|
||||
}
|
||||
data.checked = !!dl.checked;
|
||||
data.deleting = !!dl.deleting;
|
||||
this.queue.set(data.url, data);
|
||||
this.updated.next();
|
||||
});
|
||||
@@ -164,7 +170,9 @@ export class DownloadsService {
|
||||
}
|
||||
|
||||
public startById(ids: string[]) {
|
||||
return this.http.post('start', {ids: ids});
|
||||
return this.http.post<Status>('start', {ids: ids}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
);
|
||||
}
|
||||
|
||||
public delById(where: State, ids: string[]) {
|
||||
@@ -177,7 +185,22 @@ export class DownloadsService {
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.http.post('delete', {where: where, ids: ids});
|
||||
return this.http.post<Status>('delete', {where: where, ids: ids}).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
// Request failed — the rows would otherwise stay disabled forever
|
||||
// with no way to retry, since nothing ever clears `deleting`.
|
||||
if (map) {
|
||||
for (const id of ids) {
|
||||
const obj = map.get(id);
|
||||
if (obj) {
|
||||
obj.deleting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
(where === 'queue' ? this.queueChanged : this.doneChanged).next();
|
||||
return this.handleHTTPError(err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public startByFilter(where: State, filter: (dl: Download) => boolean) {
|
||||
|
||||
@@ -4,11 +4,11 @@ requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
version = "2.6.2"
|
||||
version = "2.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -106,14 +106,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.14.1"
|
||||
version = "4.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -203,104 +203,123 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.7"
|
||||
version = "3.4.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -347,15 +366,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "deno"
|
||||
version = "2.9.0"
|
||||
version = "2.9.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/96/098c84eff4b8ec7da8fb1b43d3371c22f021b7642d5b2bdad8d1780cd605/deno-2.9.0.tar.gz", hash = "sha256:7548d37528eaca993a2d4483ce092eca971fa206faadb0f4c8af8c3f039677d2", size = 8168, upload-time = "2026-06-25T15:03:34.872Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/ab/638749d76881f74d100a079414745f0a0fd20bba38e27ab4a3a6495d8264/deno-2.9.3.tar.gz", hash = "sha256:73268cbac7f7c4ff1983e49420a93f3f3f2f2e4e4372436f0942d04d02c9e943", size = 8166, upload-time = "2026-07-15T15:37:50.565Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/64/ebd8bb0fc2ef57b0f8cff53cd9b43261c9c16dfaf0a07db37b9fcb2ec186/deno-2.9.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f53bbb11a9a7eeae3865470baa1d5cb02da105ac073019953e9d59e0588bc10e", size = 42301281, upload-time = "2026-06-25T15:03:18.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/3c/1cc1c8c731e5dada562640dc42c0603c017bc1ba731a69255cecd9b25be0/deno-2.9.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e58aa71c144dcd91ef9fd5b044dd465d1be571b6763c3a777924d4d54f7033e7", size = 37950190, upload-time = "2026-06-25T15:03:21.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/86/e5487fe222840aca26c292cfa2532fb0ba4b080b6106f89cce530a7470a5/deno-2.9.0-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:3401337f5c3b75e8f7c1b07589612cddac66ddc17a0a18e7eff6e55f23a1b53b", size = 42017185, upload-time = "2026-06-25T15:03:25.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/d0/7f73a8d5f77947c327272147d89d005426888d4d5b3d6575181c1a7271e9/deno-2.9.0-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:63988d645e591bc171a574aa6b3ada2ddfc9bcf59f0178412a9fde1991bfc111", size = 43869469, upload-time = "2026-06-25T15:03:28.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/34/ca326c53558f99792eb4e2c19ca88763e6890ce1ffb44b9536ecd7ae983f/deno-2.9.0-py3-none-win_amd64.whl", hash = "sha256:29f6f616b47e5babd9604bcd1a76ec3576895275f8696dedfb33ab6545f6ed85", size = 41589185, upload-time = "2026-06-25T15:03:32.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/fa/b4e1e2b3b894ed992c78a6796a43dc775edff895239d83c0e9a10457f4f7/deno-2.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1deef9fe97ab28d5d4fea07693075e9ccc62f4ea97a653047a770beb01ecd307", size = 42354026, upload-time = "2026-07-15T15:37:34.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/cc/4090ef7370005b740efe57ec6ff75a3471393abe9816f1459e73dbbf0429/deno-2.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:78635cd9f0802ce7125aa3ba6feb5775460f55d432611374f16b454ffb8a1308", size = 37997909, upload-time = "2026-07-15T15:37:38.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/07/70b7c965bbca5f3d158859acda0e90c041f1fa98b8c9ac8eff3fafd8b96c/deno-2.9.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:7c1c669827a480ef9e2710dd762560811da35dca04a9e90b23c0518587dadb8a", size = 42100873, upload-time = "2026-07-15T15:37:41.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f5/0a07cc19a27476011719dd017a98950c7ccc33058c2ffc07019ba3ad9a5e/deno-2.9.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:3e724d5c8df13a90f6b50b16cc8df1acfe64c61a7736a774e11d61f3bca3a3b1", size = 43928967, upload-time = "2026-07-15T15:37:45.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/9a/030847bd4ea6cefbb471cae2b63c9ab29a3ab6c1809484447752bbcbcd52/deno-2.9.3-py3-none-win_amd64.whl", hash = "sha256:e2ebe2b5c1a7ee9daeaf8caf14ebc33fda96b4c2399da259e761fe2f82d22fa4", size = 41630201, upload-time = "2026-07-15T15:37:48.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1004,38 +1023,62 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
version = "16.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/02/b9a097e1e16fee4e2fd1ec8c39f6a9c5d6257bae8fa12640caf869f54436/websockets-16.1.tar.gz", hash = "sha256:299468cbe42e2b9981134c7c51d99387d8a7bf562b00183b3eec53f882846dad", size = 182530, upload-time = "2026-07-10T06:32:57.734Z" }
|
||||
wheels = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/63/df158b155420b566f025e75613424ad9649a24bcb0e9f259321ab3d58bea/websockets-16.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b0232ed141cec3df2af5a3959a071c51f40036336b0d37e17faf9ef52fc73e47", size = 179791, upload-time = "2026-07-10T06:31:33.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/cf/00fe9414dfeafa6fe54eae9f5716c8c8e9ac59d192be3b893c096d395846/websockets-16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a71b73d143991714144e159f767b698f03c4a70b8a65ae1733b650cff488045b", size = 177472, upload-time = "2026-07-10T06:31:34.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/76/b10633424d40681b4e892ffd08ca5226322b2426e62d4ab71eae484c3a32/websockets-16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:187323204c3b2fc465e8fc2609e60437c521790cb9c1acb49c4c452a33e57f37", size = 177737, upload-time = "2026-07-10T06:31:35.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/61/d3bb03b2229bb1afd72008742d586cf1ea240dce64dd48c71c8c7fd3294c/websockets-16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dba74233c8c3ce368850818c98354dad2570f57231b3fd3bd00d7aa57628881", size = 187403, upload-time = "2026-07-10T06:31:37.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/16/cc2e80478f688fc3c39c67dc1fac6a0783858058914ebc2489917462cb42/websockets-16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63339bc8c63c86a463177775cb7c677691f5bcfac7b3b2f01b286d42acd41600", size = 188639, upload-time = "2026-07-10T06:31:38.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/d6/ad87b2507e57de1cbf897a56c963f2925962ed5e85fbe06aaa83ced27acd/websockets-16.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23e545ea8ae4263e37cdfd4e22a217f519e48e432728bc461185bbf585f38a83", size = 190078, upload-time = "2026-07-10T06:31:40.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/1a/5b37b3fd335d5811f29fc829f2646a3e6d1463a4bf09c3100708684c766e/websockets-16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2237081454846fb40403a80ba86d82e2038b9c45865ab96af0abe7d002a91045", size = 189267, upload-time = "2026-07-10T06:31:41.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/98/06afc33e9450d4230f94c664db78875d90f5f6a5fb77f0bc6ec15ae74e1c/websockets-16.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f5218de1ed047385ca53744caba9435d65f75d008364970a3fae95a05812cf9", size = 188022, upload-time = "2026-07-10T06:31:42.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/bf/42fef5d5887c18cf2d148b02debf56cecb9cfbffc68027cde9b12c8f432c/websockets-16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75c98e3920039d0edff03b74478ada504b7ce3a1bc406db2cabfca84320f7baf", size = 185435, upload-time = "2026-07-10T06:31:44.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/9b/8021c133add5fe40ed40312553a6cd1408c069d7efe3444ad483d4973ed3/websockets-16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1facd189d8190af30487a55b4c3688484dd50801628a3b5b2ccd26db08e67057", size = 188080, upload-time = "2026-07-10T06:31:45.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/54/1e37384f395eaa127383aab15c1c45e200890a7d7b99db5c312233d193e0/websockets-16.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cc0c6a6eef613c7da32d4fb068f82ef834b58134f6a16b54e6c1e5bf9529ab3d", size = 186678, upload-time = "2026-07-10T06:31:47.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/79/1caeacab5bc2081e4519288d248bc8bd2de30652e6eaa94be6be09a1fe5b/websockets-16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ad9411eded8988b879be6038206698bf7106c85a78f642c004485bcb95be17eb", size = 188554, upload-time = "2026-07-10T06:31:48.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/83/b3dca5fad71487b726e31cb0acf56f226792c1cc34e6ab18cbf146bd2d74/websockets-16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cd68f0914f3b64694895bc5e9b14e8b447e41d7bf5ffaf989bb8dcb5e2dfdce7", size = 186109, upload-time = "2026-07-10T06:31:50.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/0b/8f246c3712f07f207b52ea5fb47f3b2b66fafec7303162644c74aed51c6a/websockets-16.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fef2debfe7f7ebdda12176f26166f95b7af17af05ba06150fcf889032e0213e9", size = 187061, upload-time = "2026-07-10T06:31:51.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/eb/27d6c92a01696b6495386af4fc941d7d0a13f2eab2bf9c336111d7321491/websockets-16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3cd6c9b798218798f4bb7b2e71c38f0e744bb94ca537b13376f88019d46384d", size = 187347, upload-time = "2026-07-10T06:31:53.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/d5/eeee439921f55d5eaeabcea18d0f7ce32cdc39cb8fc1e185431a094c5c7b/websockets-16.1-cp313-cp313-win32.whl", hash = "sha256:84c170c6869633536921e4474b1cce7254c0c9b0053ef5725f966cee47e718e4", size = 180149, upload-time = "2026-07-10T06:31:55.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/03/971e98d4a4864cf263f9e94c5b2b7c9a9b7682d77bfbba4e732c55ee85a9/websockets-16.1-cp313-cp313-win_amd64.whl", hash = "sha256:bef52d327d70fa75dad93ee61ea2cb1d1489aca9f35c188833563f5a3b4df0a5", size = 180458, upload-time = "2026-07-10T06:31:56.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/e6/da1dc11507f8118145a81c751fe0c77e5e1c11b8554496addb39389e2dc2/websockets-16.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f881fca0a45dd6789939bd6637cd98169b92f1c3fdc78262f2cb9ec2cb1f324e", size = 179833, upload-time = "2026-07-10T06:31:58.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/ac/c0d46f62e31e232487b2c123bc3cfd9a4e45684ca7dc0c37f0987f29baae/websockets-16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c379d5b207d3a7f0ba4c2e4602a895b0bcc63fb5f5371a4ae7fbddb03b672b", size = 177524, upload-time = "2026-07-10T06:31:59.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/33/abd966074b34a51e4f134e0aaed80f5a4a0a35163ea5ac58a1bc5a076d23/websockets-16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:98ab58a4faa72b46da0127ccc1931dcbfc0985b0778892300a092185910c4cbe", size = 177743, upload-time = "2026-07-10T06:32:00.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/30/646e47b8a8dff04e227bdab512e6dde60663a647eeac7bbd6edddd92bbc5/websockets-16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e9c4e369fc181b2d41a99e01477215cecdc8546a39f7d41a59cc0a7065a0b09", size = 187474, upload-time = "2026-07-10T06:32:02.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/72/890ab9d77494af93ea65268230bfbc0a90ba789401ed7a44356a44785644/websockets-16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0704df094b2d5fa7f6f410925a594c2a5c9a09167731a76292e5410934208209", size = 188717, upload-time = "2026-07-10T06:32:04.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/aa/baedbbaa6bf9ed6029617ed5e8976535bd805f483ca9b3484e7ad9ee08bf/websockets-16.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b22b1f4950f6ab7126623329c3b47b3b90a14c05db517f2db2a026ad6c928352", size = 190090, upload-time = "2026-07-10T06:32:05.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/4f/d813ec94e18002571ef4959d87a630eff6e01b72a51bcb0832b75ae8c51a/websockets-16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1ae4a686a662964a6671069f84f7f908cc3475e782227726b0c622c715962105", size = 189320, upload-time = "2026-07-10T06:32:07.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/3c/8ec52a6662f3df64090fba28cd521d405d54759268d8e820477037e8c80d/websockets-16.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:856bdd638f8277f86465057bfdd4da097c73058fb0f9d2bd5baea29e2bf2d367", size = 188068, upload-time = "2026-07-10T06:32:08.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/7f/f0ae6042b14f86fa5f996c6563ea4cf107adc036ccbedc9d4f418d0095f9/websockets-16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9003a1fde1c21a322a3ca3fa0c4bda8c639da81dbc925162766086643b05ba87", size = 185493, upload-time = "2026-07-10T06:32:09.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/ad/5ffc53af9939c49fd653d147fa5b8f78ced1f6bce6c49a7446860945b0ce/websockets-16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e947b1f5fdab045174306e3916785bf3ed537648acc1549827c08c33b10953", size = 188141, upload-time = "2026-07-10T06:32:11.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/62/729206c0ee577a4db8eae6dd06e0eef725a1287c6df11b2ef831d003df31/websockets-16.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5dd0e666b5931c0509cf65714686a1c5126771e663a79ac5d40da4f58b1f9502", size = 186653, upload-time = "2026-07-10T06:32:12.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/86/e8806a99ec4589914f255e6b658853fe537bf359c05e6ba5762ad9c27917/websockets-16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a0285df7925657ad65a65fb8dc330808bce082827538fd50ef45fa12d1fc5bca", size = 188614, upload-time = "2026-07-10T06:32:14.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/38/ac554e2fc6ff0b8deeff9798b92e7abd8f99e2bd9731532e7033de208220/websockets-16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:82d1c2cab3c133e9d059b3a5420bed9376bd30e21c185c63dda4ddadf6ddda47", size = 186165, upload-time = "2026-07-10T06:32:15.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/c5/4ef4d8e53342f94f3c49e1ae089b32c1e8b3878e15e0022c7708c647f351/websockets-16.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c39907f1eaf11f6277def65aa02d68f30576b693d0c1ca332aafa3caa723ac6d", size = 187119, upload-time = "2026-07-10T06:32:17.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/33/4788b1dd417bd97eeb2698af3b9df6775ac656f96e9987da0419a067602f/websockets-16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:45c5ea55446171949eb99fd34b771ceddd511ca21958d40d0197ced33159e5ee", size = 187411, upload-time = "2026-07-10T06:32:18.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/38/00d37aad6dc3244ce349e2864815362e50b3cfc00cac28d216db20efe40f/websockets-16.1-cp314-cp314-win32.whl", hash = "sha256:b8ef8b1c8d6bd029a475ac432e730fba2dfd456715d26c473e2a82291024b99c", size = 179822, upload-time = "2026-07-10T06:32:20.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/37/2a8cb0eaddee5eaebda47a90a3ba0898d1ce3d866b02a4857fea17d82e5b/websockets-16.1-cp314-cp314-win_amd64.whl", hash = "sha256:7358ff21632b5d062707f73e859c824f1c3807e73d8ca25e71caca7c4cdcf145", size = 180167, upload-time = "2026-07-10T06:32:21.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/5a/262ad5fcaef4198997b165060f09a63f861e76939b1786ab546ccc3f8120/websockets-16.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d0f38f4c3e9b359e257c339c2cc1967ccaeedb102e57c1c986bdce4bf4f32268", size = 180166, upload-time = "2026-07-10T06:32:23.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/c7/36377db690f4292826e4501a6dec2801dc55fd1cf0405923b04937e478df/websockets-16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3c3d2cbd1602593bad49bd86fa3fbb25407d87a3b4bf8857c0ac5ac4914e1901", size = 177697, upload-time = "2026-07-10T06:32:25.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/c7/07171abce1e39799a76f473608580fe98bd43a1230f5146159622c02bccf/websockets-16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:36069b74671e7e667f48a7484249f84c45a825a134c8b1bdc01875d0daa10d79", size = 177902, upload-time = "2026-07-10T06:32:26.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/17/c831f48e250bc4749f57c00dcce73337c41cd32f6d59a64567b84e782601/websockets-16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:587f83c2ce8a5d628e166384d77fa7f0ac69b9007d515ab442123e6615aa8da3", size = 187766, upload-time = "2026-07-10T06:32:27.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/2e/4dfe63e245b0ecfaf470cf082d25c6ce35808159135fd88c82653a6b11ab/websockets-16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6db7972d52bc1b66cefe2246902e256cbaebc9ba8a45eac09343d7eb6671b2", size = 188939, upload-time = "2026-07-10T06:32:29.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/e5/5faf65aebd9562f6b4bc473d24ce38cc56f84eb5f5bee66ed9b86733f93c/websockets-16.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e7d6014888a0632e1ed7a4095248bb3095232999447f2d83bfb1900987dd9ed9", size = 191081, upload-time = "2026-07-10T06:32:30.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/cd/2634f2f2c0556c1aae6501ed6840019cc569dd6fdbcac6494378daea4dc0/websockets-16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cb074d150e4ad2a77aa8a332c2be85f3f64f2681519d2570c1225c12c9821ff", size = 189513, upload-time = "2026-07-10T06:32:32.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/bb/2c700b51196104f09715b326b1f092ed25326bdf79a03e00a4842e503743/websockets-16.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d19c9067e1fe9490f974bffbc0e443b80a7674c5efb4980c429cc00771f07c5a", size = 188240, upload-time = "2026-07-10T06:32:33.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/20/86283636e499a1a357fa9441f690ba34f255e731f2fea174132b3b762b57/websockets-16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d440ff0c6c7469ad59c0a412c383c235935b43635e89425e3f6a0c36de90c31b", size = 185955, upload-time = "2026-07-10T06:32:35.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/23/d7fb734b0095d43bc7f1c9f68afd50adb4176e7e513403e8c70ad7daa4fa/websockets-16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8613129a2533f08de24505e69a3e403cedaadae49abdb043c4d170ca71b7e4bd", size = 188491, upload-time = "2026-07-10T06:32:36.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5e/168a192689db468405ecf3b8e4a2c18811936b0724d017ad7e6d252734f0/websockets-16.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a5bf9c23f197b4ec88290fd5463f33db67362a1bb10f85fc2e8e7627f0ddab97", size = 186983, upload-time = "2026-07-10T06:32:38.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/9b/66795fa91ebe49019ebe4fa910282172252e37046b80e08fc52e0c365150/websockets-16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:520b0fd0395f075febb283c76755af724ab9fd19dffa4f3bfd18cb4e622790a3", size = 188890, upload-time = "2026-07-10T06:32:39.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/32/126bbc844be5afb3613fd43211dac10a9645f4cf39741d04acaa2ec7030c/websockets-16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7143aa09a67e1c013be44e81a88dfe90fc6244198ab86c7edd064152cf619805", size = 186583, upload-time = "2026-07-10T06:32:41.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/b9/0b5db9cbcf6e4970db4496893244a8d92e07f71a8ef27cf34b08aa02fef1/websockets-16.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:7acb811fad08e611755800d1560e395c67e11a6bd563598ea6abb319afb86938", size = 187353, upload-time = "2026-07-10T06:32:42.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/2e/254b2131a10d831b76e2c18dfe7add9729c6292c674a8085bf8de01ad151/websockets-16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c5cf88e3faa2f7931bc6baeee7599c97656a3f6ac7f831f4fccba233e141783a", size = 187784, upload-time = "2026-07-10T06:32:43.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/dc/e7288aa8e3ac5a88a0924619984d663c1abf2a87d0ea98290c66fdaee0ec/websockets-16.1-cp314-cp314t-win32.whl", hash = "sha256:589f8842521c8307684ce0b40ce4ad70c5e0aa46484c6f1225a94ef4b8970341", size = 179947, upload-time = "2026-07-10T06:32:45.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/de/37edf1260ff0fbbd2f82433489c4cfbe799ac2ff21355331609879329fe6/websockets-16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c0e0857c30bbbc2bb5c30687508f0b7ec19aa026cd9f2ff8424d0fee42dcc07", size = 180291, upload-time = "2026-07-10T06:32:47.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/58/bd83247f39ddc26ffc2c24eb05087a3b749e00cb4509fc6d19daa23c8495/websockets-16.1-py3-none-any.whl", hash = "sha256:c5149dfe490ec7e5ee5dbf624c642fb725f93a5575c7f00ab594ca9eddb8dd81", size = 174031, upload-time = "2026-07-10T06:32:56.079Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user